diff --git a/DECISIONS.md b/DECISIONS.md index 9f09c5f0..4df21adb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1384,6 +1384,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys. [2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total. [2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none). +[2026-08-31] Per-line discount stores NET line_total via computeLineNet (roundOre(gross) minus roundOre(gross*d/100)) rather than round(gross*(1-d/100)): the subtracted-rounded-discount form keeps gross = discount + net exact in ore arithmetic, which the Peppol BG-27 line allowance (Amount + LineExtensionAmount = BaseAmount) and the PDF discount column both need; undiscounted lines keep the legacy unrounded qty*price so existing invoices and the Peppol LINE_TOTAL check stay byte-identical. ROT/RUT deducts on the discounted net (the customer pays that). invoice_marking is deliberately NOT copied by copy-invoice (recipient/PO-specific, same rule as your_reference) and NOT added to recurring schedules (follow-up if requested). [2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread). [2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict). [2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated. diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index 62cffbb9..d39b95a7 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -79,6 +79,8 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( reverse_charge_text: proforma.reverse_charge_text, your_reference: proforma.your_reference, our_reference: proforma.our_reference, + // Buyer routing survives conversion (Peppol BT-10 may rely on it alone). + invoice_marking: proforma.invoice_marking ?? null, notes: proforma.notes, document_type: 'invoice', converted_from_id: id, @@ -92,7 +94,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 }) } - const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record }) => ({ + const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; discount_percent?: number | null; line_total: number; dimensions?: Record }) => ({ invoice_id: invoice.id, sort_order: item.sort_order, line_type: item.line_type ?? 'product', @@ -100,6 +102,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + // The stored line_total is net of this; dropping it would make the + // converted invoice fail the Peppol line check and lose the rebate on + // the next builder pass. + discount_percent: item.discount_percent ?? 0, line_total: item.line_total, dimensions: item.dimensions ?? {}, })) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 226be027..c2b2d688 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -9,6 +9,8 @@ import { contentDisposition } from '@/lib/api/content-disposition' import type { InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { computeDeduction, computeInvoiceDeductionTotal, type DeductionType } from '@/lib/invoices/rot-rut-rules' +import { computeLineNet } from '@/lib/invoices/line-amounts' +import { roundOre } from '@/lib/money' import { expandPersonnummerTo12, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { revealStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' import { @@ -29,6 +31,8 @@ interface PreviewItemInput { quantity: number unit: string unit_price: number + /** Line discount 0-100; amounts render net of it (line-amounts.ts). */ + discount_percent?: number | null vat_rate?: number deduction_type?: DeductionType | null labor_hours?: number | null @@ -83,7 +87,8 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { }) => { const body = await request.json() const { - customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, + customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, + invoice_marking, notes, document_type, invoice_number, payment_link_url, deduction_personnummer, deduction_housing_designation, deduction_apartment_number, deduction_brf_org_number, } = body @@ -227,15 +232,19 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { // deduction, mirroring build-invoice-write.ts so the preview states the // same avdrag row, info box and "Att betala" as the invoice it becomes. const invoiceItems: InvoiceItem[] = items.map((item: PreviewItemInput, index: number) => { - const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100 + // Net of any per-line discount, same math as build-invoice-write.ts, so + // the preview totals equal the invoice the form creates. + const discountPercent = item.discount_percent ?? 0 + const lineTotal = roundOre(computeLineNet(item.quantity, item.unit_price, discountPercent)) const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate) const deductionType = deductionsApply ? (item.deduction_type ?? null) : null - // Same base as the write path: the line total inkl. moms at the rate the - // line is rendered with (HUSFL 6-9 §§). + // Same base as the write path: the NET line total inkl. moms at the rate + // the line is rendered with (HUSFL 6-9 §§). const deductionAmount = deductionType ? computeDeduction({ unit_price: item.unit_price, quantity: item.quantity, + discount_percent: discountPercent, deduction_type: deductionType, vat_rate: rate, }) @@ -248,6 +257,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + discount_percent: discountPercent, line_total: lineTotal, vat_rate: rate, vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100, @@ -275,6 +285,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { invoiceItems.map((item) => ({ unit_price: item.unit_price, quantity: item.quantity, + discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type ?? null, vat_rate: item.vat_rate, })), @@ -314,6 +325,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { moms_ruta: vatRules.momsRuta, your_reference: your_reference || null, our_reference: our_reference || null, + invoice_marking: (typeof invoice_marking === 'string' && invoice_marking.trim()) || null, notes: notes || null, payment_link_url: previewPaymentLink, reverse_charge_text: vatRules.reverseChargeText || null, diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 6b85954b..8e7984b2 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -392,6 +392,8 @@ async function createCreditNote( reverse_charge_text: originalInvoice.reverse_charge_text, your_reference: originalInvoice.your_reference, our_reference: originalInvoice.our_reference, + // Same buyer routing on the kreditfaktura as the original. + invoice_marking: originalInvoice.invoice_marking ?? null, // Positive magnitude, unlike the negated amounts above: the DB has // CHECK (deduction_total >= 0), and every reader either recomputes the // ROT/RUT amount from the items or skips credit notes entirely. diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts index 5ec47b11..500a9137 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -39,7 +39,7 @@ const CreditNoteRequest = z.object({ }) const ORIGINAL_INVOICE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions' // default_dimensions stays in this projection: the inserted credit-note row is // handed to createCreditNoteJournalEntry, which reads the bag off the row so @@ -48,7 +48,7 @@ const CREDIT_NOTE_RESPONSE_COLUMNS = 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at' const ORIGINAL_ITEMS_COLUMNS = - 'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions' + 'sort_order, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, dimensions' const CreditNoteCreated = z.object({ id: z.string().uuid(), @@ -181,6 +181,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string quantity: number unit: string unit_price: number + discount_percent?: number | null line_total: number vat_rate?: number | null vat_amount?: number | null @@ -246,6 +247,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string reverse_charge_text: original.reverse_charge_text ?? null, your_reference: original.your_reference ?? null, our_reference: original.our_reference ?? null, + // Same buyer routing on the kreditfaktura as the original. + invoice_marking: original.invoice_marking ?? null, notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`, credited_invoice_id: originalId, status: 'sent' as const, @@ -261,6 +264,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string quantity: -Math.abs(item.quantity), unit: item.unit, unit_price: item.unit_price, + // Carried so the kreditfaktura's face arithmetic multiplies out and the + // Rabatt column renders (ML 17 kap 24 §). + discount_percent: item.discount_percent ?? 0, line_total: -Math.abs(item.line_total), vat_rate: item.vat_rate ?? 0, vat_amount: -Math.abs(item.vat_amount ?? 0), diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index eae5fa17..a93197c2 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -37,8 +37,9 @@ import { import { sortArticles } from '@/lib/articles/sort' import ArticleCombobox from '@/components/invoices/ArticleCombobox' import { getAmountToPay } from '@/lib/invoices/rounding' +import { computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' -import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy } from 'lucide-react' +import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy, Percent } from 'lucide-react' import { DropdownMenu, DropdownMenuTrigger, @@ -259,6 +260,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat quantity: z.number(), unit: z.string(), unit_price: z.number(), + // Rabatt i procent per rad (⋮ menu). null = no discount. + discount_percent: z + .number() + .min(0, t('validation_discount_range')) + .max(100, t('validation_discount_range')) + .nullable() + .optional(), vat_rate: z.number().min(0).max(25), // Article linkage (artikelregister). Optional: free-text lines omit them. article_id: z.string().nullable().optional(), @@ -343,6 +351,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat document_type: z.enum(['invoice', 'proforma', 'delivery_note']), your_reference: z.string().optional(), our_reference: z.string().optional(), + invoice_marking: z.string().optional(), notes: z.string().optional(), // Optional online payment link (pasted from e.g. the Stripe dashboard). // https-only: mirrors the server-side CreateInvoiceSchema gate. @@ -453,6 +462,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat [activeAccounts], ) const [accountOverrideRows, setAccountOverrideRows] = useState>(new Set()) + // Rabatt per rad: rows whose discount strip is open (⋮ menu), same + // lifecycle as the account override above. A stored discount also opens it. + const [discountRows, setDiscountRows] = useState>(new Set()) // Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances // render only when company_settings.dimensions_enabled: a UI-visibility // gate; a draft that already carries bags still round-trips untouched when @@ -529,6 +541,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType, your_reference: initial.your_reference ?? '', our_reference: initial.our_reference ?? '', + invoice_marking: initial.invoice_marking ?? '', notes: initial.notes ?? '', payment_link_url: initial.payment_link_url ?? '', payment_link_auto: initial.payment_link_auto ?? true, @@ -543,6 +556,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null, vat_rate: item.vat_rate ?? 25, article_id: item.article_id ?? null, revenue_account: item.revenue_account ?? null, @@ -568,6 +582,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat document_type: 'invoice' as InvoiceDocumentType, your_reference: '', our_reference: copyInitial.our_reference, + invoice_marking: '', notes: copyInitial.notes, payment_link_url: '', payment_link_auto: true, @@ -635,6 +650,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat const watchReceivedDate = watch('received_date') const watchDeliveryDate = watch('delivery_date') const watchYourReference = watch('your_reference') + const watchInvoiceMarking = watch('invoice_marking') const watchPaymentLinkUrl = watch('payment_link_url') const watchPaymentLinkAuto = watch('payment_link_auto') const watchPersonnummer = watch('deduction_personnummer') @@ -817,6 +833,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat quantity: 1, unit: 'st', unit_price: 0, + discount_percent: null, vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0, article_id: null, revenue_account: null, @@ -871,6 +888,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat quantity: 0, unit: '', unit_price: 0, + discount_percent: null, vat_rate: 0, article_id: null, revenue_account: null, @@ -1101,7 +1119,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat } const subtotal = watchItems.reduce((sum, item) => { - return sum + (item.quantity || 0) * (item.unit_price || 0) + return sum + computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent) }, 0) const vatRules = selectedCustomer @@ -1134,7 +1152,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat let vatAmount = 0 for (const item of watchItems) { const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0 - const lineTotal = (item.quantity || 0) * (item.unit_price || 0) + const lineTotal = computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent) const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 vatAmount += lineVat const existing = vatByRate.get(rate) || { base: 0, vat: 0 } @@ -1226,6 +1244,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat const amount = computeDeduction({ unit_price: item.unit_price || 0, quantity: item.quantity || 0, + discount_percent: item.discount_percent, deduction_type: item.deduction_type, // Same rate resolution as the VAT totals loop above: the deduction // base is the line total inkl. moms (HUSFL 6-9 §§). @@ -1307,6 +1326,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat } } + // Open/close the per-line discount (⋮ menu). Closing clears the value so + // the row books at full price again. + function toggleDiscount(index: number) { + const isOpen = discountRows.has(index) || hasLineDiscount(watchItems[index]?.discount_percent) + if (isOpen) { + setValue(`items.${index}.discount_percent`, null, { shouldDirty: true, shouldValidate: true }) + setDiscountRows((prev) => { + const next = new Set(prev) + next.delete(index) + return next + }) + } else { + setDiscountRows((prev) => new Set(prev).add(index)) + window.setTimeout(() => setFocus(`items.${index}.discount_percent`), 0) + } + } + // Open/close the optional per-item dimensions override (⋮ menu). Closing // clears the bag so the row falls back to the invoice's default_dimensions. function toggleItemDimensions(index: number) { @@ -1754,6 +1790,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat items: data.items, your_reference: data.your_reference, our_reference: data.our_reference, + invoice_marking: data.invoice_marking, notes: data.notes, payment_link_url: data.payment_link_url, invoice_number: numberPreview, @@ -1882,6 +1919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat receivedDate: watchReceivedDate || '', deliveryDate: watchDeliveryDate || '', yourReference: watchYourReference || '', + invoiceMarking: watchInvoiceMarking || '', paymentLink: paymentLinkMode, oreRounding, dims: hasDimensionValues(defaultDims) ? compactDims(defaultDims) : null, @@ -1904,6 +1942,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return t('chip_delivery', { date: chip.date }) case 'your_reference': return t('chip_your_reference', { reference: chip.reference }) + case 'invoice_marking': + return t('chip_invoice_marking', { marking: chip.marking }) case 'payment_link': return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link') case 'ore_off': @@ -2156,7 +2196,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat ) } - const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0) + const lineTotal = computeLineNet( + item?.quantity || 0, + item?.unit_price || 0, + item?.discount_percent, + ) const rowErrors = errors.items?.[index] const rowErrorMsg = rowErrors?.description?.message ?? @@ -2166,6 +2210,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat const articleStripOpen = articlePickerRows.has(index) const accountStripOpen = isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account)) + // Not offered for a received självfaktura: the self-billed + // endpoint's reduced item shape carries no discount, so a + // previewed rebate would silently book gross. + const discountStripOpen = + !isSelfBilled && + (discountRows.has(index) || hasLineDiscount(item?.discount_percent)) const dimensionStripOpen = dimensionsEnabled && isInvoiceDoc && @@ -2292,6 +2342,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat {t('row_menu_pick_article')} + {!isSelfBilled && ( + <> + + toggleDiscount(index)} className="py-2"> + + {discountStripOpen + ? t('row_menu_remove_discount') + : t('row_menu_add_discount')} + + + )} {isInvoiceDoc && ( <> @@ -2422,6 +2483,59 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat )} + {/* Rabatt strip: opened via the ⋮ menu; a stored + discount keeps it open in edit mode. */} + {discountStripOpen && ( +
+
+ +
+ { + if (v === '' || v == null) return null + const n = Number(v) + return Number.isFinite(n) ? n : null + }, + })} + /> + % +
+ {hasLineDiscount(item?.discount_percent) && ( + + −{formatCurrency( + roundOre((item?.quantity || 0) * (item?.unit_price || 0)) - lineTotal, + watchCurrency, + )} + + )} +
+ {rowErrors?.discount_percent && ( +

+ {rowErrors.discount_percent.message} +

+ )} +
+ )} + {/* ROT/RUT-avdrag strip: only when a deduction is active on this row (chosen via the ⋮ menu). */} {isInvoiceDoc && item?.deduction_type && ( @@ -2482,6 +2596,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat const amt = computeDeduction({ unit_price: item?.unit_price || 0, quantity: item?.quantity || 0, + discount_percent: item?.discount_percent, deduction_type: item?.deduction_type, vat_rate: vatRegistered ? (item?.vat_rate ?? (vatRules?.rate || 25)) @@ -2924,6 +3039,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat /> + {/* Fakturamärkning: one buyer-required marking string + (kostnadsställe/projekt/PO), separate from Er + referens. Plain input, never comma-split. */} +
+ +
+ +
+
{/* Online payment link: manual paste or the Stripe auto toggle. Only real invoices; hidden unless the company @@ -3212,6 +3344,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat total={total} yourReference={pendingData?.your_reference} ourReference={pendingData?.our_reference} + invoiceMarking={pendingData?.invoice_marking} notes={pendingData?.notes} numberPreview={numberPreview} oreRounding={oreRounding} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index 27cc9d4e..dbe02972 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -7,6 +7,7 @@ import { Separator } from '@/components/ui/separator' import { formatCurrency, formatDate } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import { isTextLikeLine } from '@/lib/invoices/display' +import { computeLineNet } from '@/lib/invoices/line-amounts' import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' import type { Customer, Currency } from '@/types' @@ -15,6 +16,8 @@ interface ReviewItem { quantity: number unit: string unit_price: number + /** Line discount 0-100; amounts render net of it. */ + discount_percent?: number | null vat_rate?: number /** 'text' rows are free-text/blank lines: description only, no amounts. */ line_type?: 'product' | 'text' @@ -38,6 +41,7 @@ interface InvoiceReviewContentProps { total: number yourReference?: string ourReference?: string + invoiceMarking?: string notes?: string /** The invoice number that will be assigned on confirm. Null when unknown * (e.g. delivery notes use a different sequence) or unfetched. */ @@ -63,6 +67,7 @@ export function InvoiceReviewContent({ total, yourReference, ourReference, + invoiceMarking, notes, numberPreview, oreRounding, @@ -84,7 +89,7 @@ export function InvoiceReviewContent({ for (const item of items) { if (isTextLikeLine(item)) continue const rate = item.vat_rate ?? 0 - const lineTotal = item.quantity * item.unit_price + const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent) const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) } @@ -182,12 +187,19 @@ export function InvoiceReviewContent({ {item.quantity} {item.unit} - {formatCurrency(item.unit_price, currency)} + + {formatCurrency(item.unit_price, currency)} + {(item.discount_percent ?? 0) > 0 && ( + + −{item.discount_percent}% + + )} + {showVatColumn && ( {item.vat_rate ?? 0}% )} - {formatCurrency(item.quantity * item.unit_price, currency)} + {formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)} ) @@ -214,11 +226,14 @@ export function InvoiceReviewContent({

)}
- {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)} + + {item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)} + {(item.discount_percent ?? 0) > 0 && <> −{item.discount_percent}%} + {showVatColumn && {t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}}

- {formatCurrency(item.quantity * item.unit_price, currency)} + {formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}

) @@ -260,7 +275,7 @@ export function InvoiceReviewContent({ {/* References/notes */} - {(yourReference || ourReference || notes) && ( + {(yourReference || ourReference || invoiceMarking || notes) && (
{yourReference && (

@@ -272,6 +287,11 @@ export function InvoiceReviewContent({ {t('our_reference')} {ourReference}

)} + {invoiceMarking && ( +

+ {t('invoice_marking')} {invoiceMarking} +

+ )} {notes &&

{t('notes_prefix', { notes })}

}
)} diff --git a/components/invoices/__tests__/invoice-editor-flow.test.ts b/components/invoices/__tests__/invoice-editor-flow.test.ts index 06b0e928..2ffe61c2 100644 --- a/components/invoices/__tests__/invoice-editor-flow.test.ts +++ b/components/invoices/__tests__/invoice-editor-flow.test.ts @@ -165,6 +165,7 @@ function chipsInput(overrides: Partial = {}): ForvalChipsInput receivedDate: '', deliveryDate: '', yourReference: '', + invoiceMarking: '', paymentLink: null, oreRounding: true, dims: null, @@ -221,6 +222,7 @@ describe('deriveForvalChips', () => { currency: 'EUR', deliveryDate: '2026-08-20', yourReference: 'Anna', + invoiceMarking: 'KST 4711', paymentLink: 'manual', oreRounding: false, dims: 'KS01 · P001', @@ -230,6 +232,7 @@ describe('deriveForvalChips', () => { expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' }) expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' }) expect(chips).toContainEqual({ kind: 'your_reference', reference: 'Anna' }) + expect(chips).toContainEqual({ kind: 'invoice_marking', marking: 'KST 4711' }) expect(chips).toContainEqual({ kind: 'payment_link', mode: 'manual' }) expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' }) // ore_off is SEK-only: an EUR invoice has no öresavrundning to disable. @@ -249,6 +252,7 @@ describe('deriveForvalChips', () => { receivedDate: '2026-08-15', documentType: 'proforma', yourReference: 'x', + invoiceMarking: 'x', paymentLink: 'auto', oreRounding: false, dims: 'KS01', diff --git a/components/invoices/invoice-editor-flow.ts b/components/invoices/invoice-editor-flow.ts index 40c50cd3..02a00576 100644 --- a/components/invoices/invoice-editor-flow.ts +++ b/components/invoices/invoice-editor-flow.ts @@ -121,6 +121,7 @@ export type ForvalChip = | { kind: 'received'; date: string } | { kind: 'delivery'; date: string } | { kind: 'your_reference'; reference: string } + | { kind: 'invoice_marking'; marking: string } | { kind: 'payment_link'; mode: 'auto' | 'manual' } | { kind: 'ore_off' } | { kind: 'dims'; dims: string } @@ -134,6 +135,7 @@ export interface ForvalChipsInput { receivedDate: string deliveryDate: string yourReference: string + invoiceMarking: string paymentLink: 'auto' | 'manual' | null oreRounding: boolean /** Compact display of the invoice-level default dims, or null when none. */ @@ -174,6 +176,9 @@ export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] { if (!input.isSelfBilled && input.yourReference.trim()) { chips.push({ kind: 'your_reference', reference: input.yourReference.trim() }) } + if (!input.isSelfBilled && input.invoiceMarking.trim()) { + chips.push({ kind: 'invoice_marking', marking: input.invoiceMarking.trim() }) + } if (!input.isSelfBilled && input.paymentLink) { chips.push({ kind: 'payment_link', mode: input.paymentLink }) } diff --git a/extensions/general/mcp-server/__tests__/get-invoice.test.ts b/extensions/general/mcp-server/__tests__/get-invoice.test.ts index bd5e7974..a8da6827 100644 --- a/extensions/general/mcp-server/__tests__/get-invoice.test.ts +++ b/extensions/general/mcp-server/__tests__/get-invoice.test.ts @@ -189,6 +189,7 @@ describe('gnubok_get_invoice: execute', () => { quantity: 2, unit: 'tim', unit_price: 1200, + discount_percent: 0, line_total: 2400, vat_rate: 25, vat_amount: 600, diff --git a/extensions/general/mcp-server/__tests__/update-invoice.test.ts b/extensions/general/mcp-server/__tests__/update-invoice.test.ts index 1a364681..e0e39a7c 100644 --- a/extensions/general/mcp-server/__tests__/update-invoice.test.ts +++ b/extensions/general/mcp-server/__tests__/update-invoice.test.ts @@ -359,6 +359,7 @@ describe('gnubok_update_invoice: validation and staging', () => { expect(result.preview.current_items).toEqual( CURRENT_ROWS.map((row) => ({ ...row, + discount_percent: 0, deduction_type: null, accrual_period_start: null, accrual_period_end: null, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index d01b2cef..ada2e5d5 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -59,6 +59,7 @@ import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle' import { eventBus } from '@/lib/events/bus' import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules' import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules' +import { computeLineNet } from '@/lib/invoices/line-amounts' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { getBranding } from '@/lib/branding/service' import { generateIncomeStatement } from '@/lib/reports/income-statement' @@ -303,6 +304,8 @@ type StagedInvoiceLineInput = { quantity: number unit?: string unit_price?: number + /** Line discount 0-100 (rabatt i procent); totals are computed net of it. */ + discount_percent?: number vat_rate?: number article_id?: string revenue_account?: string | null @@ -370,10 +373,21 @@ function resolveInvoiceLineFromArticle( quantity: 0, unit: '', unit_price: 0, + discount_percent: 0, vat_rate: 0, } } if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`) + // Strict typeof: a host that skips inputSchema validation could send a + // string, which JS comparisons would coerce past a bare range check while + // hasLineDiscount (typeof === 'number') then ignores it in the totals. + if ( + item.discount_percent != null && + (typeof item.discount_percent !== 'number' || + !(item.discount_percent >= 0 && item.discount_percent <= 100)) + ) { + throw new Error(`Item ${lineNo}: discount_percent must be a number between 0 and 100`) + } if (item.article_id && !article) { throw new Error(`Item ${lineNo}: article ${item.article_id} not found in this company. Use gnubok_list_articles to find valid IDs.`) } @@ -6287,6 +6301,7 @@ export const tools: McpTool[] = [ remaining_amount: { type: ['number', 'null'] }, your_reference: { type: ['string', 'null'] }, our_reference: { type: ['string', 'null'] }, + invoice_marking: { type: ['string', 'null'], description: 'Fakturamärkning (buyer marking), separate from your_reference' }, notes: { type: ['string', 'null'] }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' } }, editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' }, @@ -6303,6 +6318,7 @@ export const tools: McpTool[] = [ quantity: { type: 'number' }, unit: { type: 'string' }, unit_price: { type: 'number' }, + discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' }, line_total: { type: 'number' }, vat_rate: { type: 'number' }, vat_amount: { type: 'number' }, @@ -6345,7 +6361,7 @@ export const tools: McpTool[] = [ const { data: invoice, error } = await supabase .from('invoices') .select( - 'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)', + 'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)', ) .eq('id', invoiceId) .eq('company_id', companyId) @@ -6362,6 +6378,7 @@ export const tools: McpTool[] = [ quantity: number unit: string unit_price: number + discount_percent: number | null line_total: number vat_rate: number vat_amount: number | null @@ -6390,6 +6407,7 @@ export const tools: McpTool[] = [ quantity: row.quantity, unit: row.unit, unit_price: row.unit_price, + discount_percent: row.discount_percent ?? 0, line_total: row.line_total, vat_rate: row.vat_rate, vat_amount: row.vat_amount ?? 0, @@ -6427,6 +6445,7 @@ export const tools: McpTool[] = [ remaining_amount: invoice.remaining_amount ?? null, your_reference: invoice.your_reference ?? null, our_reference: invoice.our_reference ?? null, + invoice_marking: invoice.invoice_marking ?? null, notes: invoice.notes ?? null, default_dimensions: (invoice.default_dimensions as Record | null) ?? {}, editable_draft: isEditableInvoiceDraft(invoice), @@ -6456,6 +6475,7 @@ export const tools: McpTool[] = [ quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, + discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); line total and VAT computed net of it' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, article_id: { type: 'string', @@ -6483,6 +6503,7 @@ export const tools: McpTool[] = [ currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, our_reference: { type: 'string' }, your_reference: { type: 'string' }, + invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference; feeds Peppol BuyerReference.' }, notes: { type: 'string' }, payment_link_url: { type: 'string', @@ -6603,8 +6624,11 @@ export const tools: McpTool[] = [ const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated) const allowedRates = new Set(permittedRates.map((r) => r.rate)) - // Calculate per-item VAT - const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0) + // Calculate per-item VAT (line totals net of any per-line discount) + const subtotal = items.reduce( + (s, item) => s + computeLineNet(item.quantity, item.unit_price, item.discount_percent), + 0, + ) let vatAmount = 0 for (const item of items) { const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate @@ -6614,7 +6638,7 @@ export const tools: McpTool[] = [ `Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}` ) } - const lineTotal = item.quantity * item.unit_price + const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent) vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } const total = subtotal + vatAmount @@ -6641,6 +6665,7 @@ export const tools: McpTool[] = [ currency, our_reference: (args.our_reference as string) || null, your_reference: (args.your_reference as string) || null, + invoice_marking: (args.invoice_marking as string) || null, notes: (args.notes as string) || null, payment_link_url: paymentLinkUrl, }, @@ -6649,7 +6674,7 @@ export const tools: McpTool[] = [ customer_type: customer.customer_type, items: stagedItems.map(item => ({ ...item, - line_total: item.quantity * item.unit_price, + line_total: computeLineNet(item.quantity, item.unit_price, item.discount_percent), vat_rate: item.vat_rate ?? vatRules.rate, })), subtotal: Math.round(subtotal * 100) / 100, @@ -17174,6 +17199,7 @@ export const tools: McpTool[] = [ delivery_date: { type: ['string', 'null'], description: 'YYYY-MM-DD; null clears the delivery date.' }, your_reference: { type: 'string' }, our_reference: { type: 'string' }, + invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference.' }, items: { type: 'array', items: { @@ -17183,6 +17209,7 @@ export const tools: McpTool[] = [ quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, + discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); pass back to keep it, totals computed net of it.' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, article_id: { type: 'string', @@ -17259,7 +17286,7 @@ export const tools: McpTool[] = [ } const headerChanges: Record = {} - for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference']) { + for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference', 'invoice_marking']) { if (args[key] !== undefined) headerChanges[key] = args[key] } if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) { @@ -17364,7 +17391,7 @@ export const tools: McpTool[] = [ `Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}` ) } - const lineTotal = item.quantity * item.unit_price + const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent) subtotal += lineTotal vatAmount += roundOre(lineTotal * itemRate / 100) } @@ -17379,6 +17406,7 @@ export const tools: McpTool[] = [ deductionLines.map((item) => ({ unit_price: item.unit_price, quantity: item.quantity, + discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type ?? null, vat_rate: item.vat_rate ?? vatRules.rate, labor_hours: item.labor_hours ?? null, @@ -17421,7 +17449,7 @@ export const tools: McpTool[] = [ // the property columns are not needed for the preview. const { data: currentRows, error: currentError } = await supabase .from('invoice_items') - .select('line_type, description, quantity, unit, unit_price, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end') + .select('line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end') .eq('invoice_id', invoice.id) .order('sort_order', { ascending: true }) if (currentError) throw dbError(currentError) @@ -17431,6 +17459,7 @@ export const tools: McpTool[] = [ quantity: row.quantity, unit: row.unit, unit_price: row.unit_price, + discount_percent: row.discount_percent ?? 0, line_total: row.line_total, vat_rate: row.vat_rate, revenue_account: row.revenue_account ?? null, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 10031c75..1e925998 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -14,6 +14,7 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients' import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account' +import { computeLineNet } from '@/lib/invoices/line-amounts' import { DEDUCTION_LINE_ERRORS, HOUSEWORK_TYPE_VALUES, @@ -403,6 +404,10 @@ export const CreateInvoiceItemSchema = z quantity: z.number(), unit: z.string(), unit_price: z.number(), + // Percentage discount on the line (rabatt i procent per artikelrad). + // line_total and vat_amount are computed NET of this server-side + // (lib/invoices/line-amounts.ts); the client never sends a total. + discount_percent: z.number().min(0).max(100).nullable().optional(), vat_rate: z.number().min(0).max(100).optional(), // Article linkage. `article_id` ties the line to a catalog article (text // rows omit it). `revenue_account` is the legacy wire name for the optional @@ -459,7 +464,8 @@ export const CreateInvoiceItemSchema = z message: 'ROT/RUT-rader kan inte periodiseras', }) } - if (item.quantity * item.unit_price <= 0) { + // Net of any line discount: a 100 % rebated row has nothing to defer. + if (computeLineNet(item.quantity, item.unit_price, item.discount_percent) <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['accrual_period_start'], @@ -520,6 +526,10 @@ const CreateInvoiceBaseSchema = z.object({ document_type: InvoiceDocumentTypeSchema.optional(), your_reference: z.string().optional(), our_reference: z.string().optional(), + // Fakturamärkning: buyer-required marking (kostnadsställe/projekt/PO), + // separate from your_reference. Printed on the PDF and mapped to Peppol + // BT-10 BuyerReference when set. + invoice_marking: z.string().max(200).optional(), notes: z.string().optional(), // Optional online payment link (manual MVP): the user pastes a link created // in their PSP dashboard (e.g. a Stripe Payment Link). https-only because the diff --git a/lib/api/v1/invoice-columns.ts b/lib/api/v1/invoice-columns.ts index f884b01a..1f05863f 100644 --- a/lib/api/v1/invoice-columns.ts +++ b/lib/api/v1/invoice-columns.ts @@ -12,7 +12,7 @@ */ export const INVOICE_FULL_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at' /** * Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS @@ -31,8 +31,8 @@ export const INVOICE_FULL_COLUMNS = export const INVOICE_PDF_COLUMNS = 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, document_type, ' + 'currency, subtotal, vat_amount, total, ore_rounding, vat_treatment, vat_rate, moms_ruta, ' + - 'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' + + 'reverse_charge_text, your_reference, our_reference, invoice_marking, notes, credited_invoice_id, ' + 'paid_amount, remaining_amount, deduction_total, deduction_personnummer_last4' export const INVOICE_ITEM_FULL_COLUMNS = - 'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at' + 'id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at' diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts index da85fc1b..43e4c362 100644 --- a/lib/bookkeeping/__tests__/invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts @@ -1316,6 +1316,44 @@ describe('createInvoiceJournalEntry: ROT/RUT-avdrag', () => { expect(totalDebit).toBe(12500) }) + it('discounted ROT line: 1513 books the NET-based deduction, matching the stored deduction_total', async () => { + // 10 000 kr labor, 20% rabatt → net 8 000 + 25% VAT 2 000 = 10 000. + // ROT = 30% of the NET inkl.-moms labor = 30% of 10 000 = 3 000, the same + // figure build-invoice-write stores on deduction_total and the payout + // request claims. Booking the gross (3 750) would strand 750 kr on 1513 + // and push kundfordringar (1510) negative when the customer pays 7 000. + const invoice = makeInvoice({ + subtotal: 8000, + vat_amount: 2000, + total: 10000, + vat_treatment: 'standard_25', + deduction_total: 3000, + items: [ + makeItem({ + quantity: 1, + unit_price: 10000, + discount_percent: 20, + line_total: 8000, + vat_rate: 25, + vat_amount: 2000, + deduction_type: 'rot', + deduction_amount: 3000, + }), + ], + }) + + await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice) + + const input = mockedCreateEntry.mock.calls[0][3] + const debit1513 = input.lines.find((l) => l.account_number === '1513') + expect(debit1513?.debit_amount).toBe(3000) + const debit1510 = input.lines.find((l) => l.account_number === '1510') + expect(debit1510?.debit_amount).toBe(7000) + const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0) + const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0) + expect(totalDebit).toBe(totalCredit) + }) + it('mixed invoice: ROT line + non-deduction line, per-item handling', async () => { // ROT line 10 000 (deduction 30% of 12 500 inkl. moms = 3 750) + // non-deduction materials line 4 000. diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 57a8441d..026d7645 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -331,6 +331,10 @@ function generateRotRutLines( const amount = computeDeduction({ unit_price: side === 'credit' ? Math.abs(item.unit_price) : item.unit_price, quantity: side === 'credit' ? Math.abs(item.quantity) : item.quantity, + // The deduction base is the NET line total (rabatt reduces what the + // customer pays); omitting this books 1513 on the gross while the + // stored deduction_total and the Skatteverket claim carry the net. + discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type, vat_rate: item.vat_rate, }) diff --git a/lib/bookkeeping/propose-send-lines.ts b/lib/bookkeeping/propose-send-lines.ts index 10c9ce58..073a8090 100644 --- a/lib/bookkeeping/propose-send-lines.ts +++ b/lib/bookkeeping/propose-send-lines.ts @@ -245,6 +245,9 @@ function buildSendLines( const deduction = computeDeduction({ unit_price: item.unit_price, quantity: item.quantity, + // Net of any line discount: must match the stored deduction_total or + // the proposed 1513/1510 split cannot clear. + discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type, vat_rate: item.vat_rate, }) diff --git a/lib/invoices/__tests__/build-credit-note-item.test.ts b/lib/invoices/__tests__/build-credit-note-item.test.ts index dadeba1a..b2e6357d 100644 --- a/lib/invoices/__tests__/build-credit-note-item.test.ts +++ b/lib/invoices/__tests__/build-credit-note-item.test.ts @@ -52,4 +52,19 @@ describe('buildCreditNoteItem', () => { dimensions: { '6': 'P001' }, }) }) + + it('carries discount_percent so the kreditfaktura face arithmetic multiplies out', () => { + // Original: 2 x 1000 with 10% rabatt → net 1800. The credit row must keep + // the discount, or -2 x 1000 next to Summa -1800 prints with no visible + // prisnedsättning (ML 17 kap 24 §) and violates the stored net invariant. + const result = buildCreditNoteItem('credit-1', item({ discount_percent: 10, line_total: 1800, vat_amount: 450 })) + expect(result).toMatchObject({ + quantity: -2, + discount_percent: 10, + line_total: -1800, + vat_amount: -450, + }) + // Legacy rows without the column default to 0. + expect(buildCreditNoteItem('credit-1', item()).discount_percent).toBe(0) + }) }) diff --git a/lib/invoices/__tests__/line-amounts.test.ts b/lib/invoices/__tests__/line-amounts.test.ts new file mode 100644 index 00000000..3d4b860f --- /dev/null +++ b/lib/invoices/__tests__/line-amounts.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers' +import { computeLineAmounts, computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts' +import { computeDeduction } from '@/lib/invoices/rot-rut-rules' +import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write' + +describe('computeLineAmounts', () => { + it('passes qty * price through untouched when no discount applies', () => { + // Legacy parity: existing invoices store the unrounded product. + expect(computeLineAmounts(3, 33.333)).toEqual({ + gross: 3 * 33.333, + discount: 0, + net: 3 * 33.333, + }) + expect(computeLineAmounts(2, 100, null)).toEqual({ gross: 200, discount: 0, net: 200 }) + expect(computeLineAmounts(2, 100, 0)).toEqual({ gross: 200, discount: 0, net: 200 }) + }) + + it('computes discount and net in exact ore arithmetic', () => { + expect(computeLineAmounts(2, 100, 10)).toEqual({ gross: 200, discount: 20, net: 180 }) + // 1 * 99.99 at 33%: gross 99.99, discount round(32.9967) = 33.00, net 66.99. + expect(computeLineAmounts(1, 99.99, 33)).toEqual({ gross: 99.99, discount: 33, net: 66.99 }) + // gross - discount is always exact: net + discount reconstructs gross. + const amounts = computeLineAmounts(7, 123.45, 12.5) + expect(amounts.net + amounts.discount).toBeCloseTo(amounts.gross, 10) + }) + + it('handles a 100% discount as a zero net line', () => { + expect(computeLineAmounts(4, 250, 100)).toEqual({ gross: 1000, discount: 1000, net: 0 }) + expect(computeLineNet(4, 250, 100)).toBe(0) + }) + + it('hasLineDiscount treats null/undefined/0 as no discount', () => { + expect(hasLineDiscount(undefined)).toBe(false) + expect(hasLineDiscount(null)).toBe(false) + expect(hasLineDiscount(0)).toBe(false) + expect(hasLineDiscount(0.5)).toBe(true) + }) +}) + +describe('computeDeduction with a line discount', () => { + it('deducts on the net line total (what the customer pays)', () => { + // 10 tim * 1000 = 10 000, 10% rabatt -> 9 000 net, incl VAT 11 250, + // ROT 30% = 3 375 (vs 3 750 undiscounted). + expect( + computeDeduction({ + unit_price: 1000, + quantity: 10, + discount_percent: 10, + deduction_type: 'rot', + vat_rate: 25, + }), + ).toBe(3375) + expect( + computeDeduction({ unit_price: 1000, quantity: 10, deduction_type: 'rot', vat_rate: 25 }), + ).toBe(3750) + }) +}) + +describe('buildInvoiceWriteData with per-line discount and invoice_marking', () => { + const baseHeader = { + customer_id: 'customer-1', + invoice_date: '2026-06-15', + due_date: '2026-07-15', + currency: 'SEK' as const, + } + + it('stores net line totals, VAT on the net, and the discount on the row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'swedish_business' }) + const result = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { + ...baseHeader, + items: [ + { description: 'Konsult', quantity: 10, unit: 'tim', unit_price: 1000, vat_rate: 25, discount_percent: 10 }, + { description: 'Resa', quantity: 1, unit: 'st', unit_price: 500, vat_rate: 25 }, + ], + }, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + // 10 000 - 10% = 9 000 net + 500 undiscounted. + expect(result.invoiceFields.subtotal).toBe(9500) + expect(result.invoiceFields.vat_amount).toBe(2375) + expect(result.invoiceFields.total).toBe(11875) + expect(result.items[0]).toMatchObject({ + discount_percent: 10, + line_total: 9000, + vat_amount: 2250, + unit_price: 1000, + }) + expect(result.items[1]).toMatchObject({ discount_percent: 0, line_total: 500 }) + }) + + it('maps invoice_marking to a concrete trimmed value, null when absent or blank', async () => { + const customer = makeCustomer({ customer_type: 'swedish_business' }) + const items = [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 100, vat_rate: 25 }] + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + const withMarking = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { ...baseHeader, invoice_marking: ' KST 4711 ', items }, + }) + expect(withMarking.ok).toBe(true) + if (!withMarking.ok) return + expect(withMarking.invoiceFields.invoice_marking).toBe('KST 4711') + + // Absent/blank input must produce an explicit null (supabase-js drops + // undefined keys, and a draft edit that cleared the field relies on NULL + // actually being written). + const { supabase: supabase2, enqueue: enqueue2 } = createQueuedMockSupabase() + enqueue2({ data: { vat_registered: true }, error: null }) + const withoutMarking = await buildInvoiceWriteData({ + supabase: supabase2 as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { ...baseHeader, invoice_marking: ' ', items }, + }) + expect(withoutMarking.ok).toBe(true) + if (!withoutMarking.ok) return + expect(withoutMarking.invoiceFields.invoice_marking).toBeNull() + }) + + it('computes the ROT deduction on the discounted line total', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { vat_registered: true }, error: null }) + + const customer = makeCustomer({ customer_type: 'individual' }) + const result = await buildInvoiceWriteData({ + supabase: supabase as unknown as SupabaseClient, + companyId: 'company-1', + customer, + documentType: 'invoice', + input: { + ...baseHeader, + deduction_personnummer: '199001019802', + deduction_housing_designation: 'Testbrand 1:1', + items: [ + { + description: 'Renovering arbete', + quantity: 10, + unit: 'tim', + unit_price: 1000, + vat_rate: 25, + discount_percent: 10, + deduction_type: 'rot', + work_type: 'BYGG', + labor_hours: 10, + }, + ], + }, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + // Net 9 000 excl VAT -> 11 250 incl VAT -> ROT 30% = 3 375. + expect(result.items[0].deduction_amount).toBe(3375) + expect(result.invoiceFields.deduction_total).toBe(3375) + }) +}) diff --git a/lib/invoices/__tests__/peppol-bis-billing.test.ts b/lib/invoices/__tests__/peppol-bis-billing.test.ts index f28016b3..7ab1afcb 100644 --- a/lib/invoices/__tests__/peppol-bis-billing.test.ts +++ b/lib/invoices/__tests__/peppol-bis-billing.test.ts @@ -259,6 +259,71 @@ describe('generatePeppolBisBillingInvoice', () => { ) }) + it('prefers invoice_marking over your_reference for BT-10 BuyerReference', () => { + const input = makeValidInput() + input.invoice = makeInvoice({ ...input.invoice, invoice_marking: 'KST 4711' }) + + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.xml).toContain('KST 4711') + }) + + it('accepts a marking-only invoice (no your_reference) as buyer reference', () => { + const input = makeValidInput() + input.invoice = makeInvoice({ + ...input.invoice, + your_reference: null, + invoice_marking: 'PO-2026-17', + }) + + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.xml).toContain('PO-2026-17') + }) + + it('renders a per-line discount as a BG-27 allowance with net LineExtensionAmount', () => { + const input = makeValidInput() + // 2 × 100 = 200 gross, 10% discount = 20, net 180, VAT 25% on net = 45. + input.items = [ + makeItem({ discount_percent: 10, line_total: 180, vat_amount: 45 }), + ] + input.invoice = makeInvoice({ + ...input.invoice, + subtotal: 180, + vat_amount: 45, + total: 225, + remaining_amount: 225, + }) + + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.xml).toContain('180.00') + expect(result.xml).toContain('false') + expect(result.xml).toContain('95') + expect(result.xml).toContain('10') + expect(result.xml).toContain('20.00') + expect(result.xml).toContain('200.00') + // The undiscounted unit price stays in cac:Price (BT-146). + expect(result.xml).toContain('100') + }) + + it('rejects a discounted line whose stored total is not net of the discount', () => { + const input = makeValidInput() + input.items = [makeItem({ discount_percent: 10, line_total: 200, vat_amount: 50 })] + + const result = generatePeppolBisBillingInvoice(input) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.issues.map(({ code }) => code)).toContain('LINE_TOTAL_MISMATCH') + }) + it('rejects credit notes and self-billed invoices in the generation layer', () => { for (const invoice of [ makeInvoice({ diff --git a/lib/invoices/__tests__/replace-invoice-items.test.ts b/lib/invoices/__tests__/replace-invoice-items.test.ts index 98933e0d..1c3788c5 100644 --- a/lib/invoices/__tests__/replace-invoice-items.test.ts +++ b/lib/invoices/__tests__/replace-invoice-items.test.ts @@ -19,6 +19,7 @@ function makeItem(overrides: Partial = {}): InvoiceWriteIte quantity: 1, unit: 'tim', unit_price: 1000, + discount_percent: 0, line_total: 1000, vat_rate: 25, vat_amount: 250, diff --git a/lib/invoices/build-credit-note-item.ts b/lib/invoices/build-credit-note-item.ts index 5888c76b..0f76c1e9 100644 --- a/lib/invoices/build-credit-note-item.ts +++ b/lib/invoices/build-credit-note-item.ts @@ -9,6 +9,10 @@ export function buildCreditNoteItem(invoiceId: string, item: InvoiceItem) { quantity: -Math.abs(item.quantity), unit: item.unit, unit_price: item.unit_price, + // Carried so the kreditfaktura's face arithmetic still multiplies out + // (antal x a-pris - rabatt = summa) and the PDF shows the same Rabatt + // column the original did (ML 17 kap 24 §: prisnedsattningen ska framga). + discount_percent: item.discount_percent ?? 0, line_total: -Math.abs(item.line_total), vat_rate: item.vat_rate ?? 0, vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), diff --git a/lib/invoices/build-invoice-write.ts b/lib/invoices/build-invoice-write.ts index 1753ea68..247deb49 100644 --- a/lib/invoices/build-invoice-write.ts +++ b/lib/invoices/build-invoice-write.ts @@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Currency, Customer, InvoiceDocumentType } from '@/types' import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules' import { isBalanceSheetAccount } from '@/lib/invoices/posting-account' +import { computeLineNet } from '@/lib/invoices/line-amounts' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions' import { @@ -46,6 +47,9 @@ export interface InvoiceWriteItemInput { quantity: number unit: string unit_price: number + /** Percentage discount on the line (0-100). Omitted/null = 0; line_total + * and vat_amount are computed NET of it (lib/invoices/line-amounts.ts). */ + discount_percent?: number | null vat_rate?: number article_id?: string | null revenue_account?: string | null @@ -70,6 +74,8 @@ export interface InvoiceWriteInput { currency: Currency your_reference?: string our_reference?: string + /** Fakturamärkning: buyer-required marking, separate from your_reference. */ + invoice_marking?: string notes?: string /** Optional https payment link (schema-validated). Omitted/empty → null. */ payment_link_url?: string @@ -111,6 +117,7 @@ export type InvoiceWriteFields = { reverse_charge_text: string | null your_reference: string | null | undefined our_reference: string | null | undefined + invoice_marking: string | null notes: string | null | undefined payment_link_url: string | null payment_link_auto: boolean @@ -129,6 +136,7 @@ export type InvoiceWriteItemRow = { quantity: number unit: string unit_price: number + discount_percent: number line_total: number vat_rate: number vat_amount: number @@ -224,8 +232,12 @@ export async function buildInvoiceWriteData(params: { } // Free-text rows carry no amounts and are excluded from totals + VAT. + // Line totals are net of any per-line discount (rabatt i procent). const subtotal = items.reduce( - (sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price), + (sum, item) => + item.line_type === 'text' + ? sum + : sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent), 0, ) @@ -260,7 +272,7 @@ export async function buildInvoiceWriteData(params: { details: { account: item.revenue_account, vatRate: itemRate }, } } - const lineTotal = item.quantity * item.unit_price + const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent) vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } } @@ -330,6 +342,7 @@ export async function buildInvoiceWriteData(params: { const validateInput = items.map((item) => ({ unit_price: item.unit_price, quantity: item.quantity, + discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type ?? null, // The deduction base is arbetskostnaden inkl. moms (HUSFL 6-9 §§), so // the validator and total need the same per-line rate the item rows @@ -513,6 +526,9 @@ export async function buildInvoiceWriteData(params: { reverse_charge_text: notVatRegistered ? null : (headerRules.reverseChargeText || null), your_reference: input.your_reference, our_reference: input.our_reference, + // Always a concrete value so a draft edit that cleared the field NULLs + // the column (supabase-js drops undefined keys). + invoice_marking: input.invoice_marking?.trim() || null, notes: input.notes, // Always a concrete value (never undefined) so a draft edit that cleared // the field actually NULLs the column: supabase-js drops undefined keys. @@ -542,6 +558,7 @@ export async function buildInvoiceWriteData(params: { quantity: 0, unit: '', unit_price: 0, + discount_percent: 0, line_total: 0, vat_rate: 0, vat_amount: 0, @@ -561,7 +578,8 @@ export async function buildInvoiceWriteData(params: { } } const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - const lineTotal = item.quantity * item.unit_price + const discountPercent = item.discount_percent ?? 0 + const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent) const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100 // ROT/RUT deduction is recomputed server-side so a tampered client can't // expand the 1513 receivable beyond the rules. Non-invoice document types @@ -571,6 +589,7 @@ export async function buildInvoiceWriteData(params: { ? computeDeduction({ unit_price: item.unit_price, quantity: item.quantity, + discount_percent: discountPercent, deduction_type: deductionType, vat_rate: itemRate, }) @@ -582,6 +601,7 @@ export async function buildInvoiceWriteData(params: { quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + discount_percent: discountPercent, line_total: lineTotal, vat_rate: itemRate, vat_amount: itemVat, diff --git a/lib/invoices/copy-invoice.ts b/lib/invoices/copy-invoice.ts index 4b972d0e..244ac49a 100644 --- a/lib/invoices/copy-invoice.ts +++ b/lib/invoices/copy-invoice.ts @@ -22,6 +22,7 @@ export interface InvoiceCopyItem { quantity: number unit: string unit_price: number + discount_percent: number vat_rate: number article_id: null revenue_account: string | null @@ -85,6 +86,8 @@ export function buildInvoiceCopyInitial(source: InvoiceCopySource): InvoiceCopyI quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + // Agreed price reduction is reusable commercial content, like the price. + discount_percent: item.discount_percent ?? 0, vat_rate: item.vat_rate ?? 25, // A copied line keeps the frozen description and price, but is not // linked to a possibly changed or archived article preset. diff --git a/lib/invoices/line-amounts.ts b/lib/invoices/line-amounts.ts new file mode 100644 index 00000000..ab343ff4 --- /dev/null +++ b/lib/invoices/line-amounts.ts @@ -0,0 +1,60 @@ +import { roundOre } from '@/lib/money' + +/** + * Shared per-line amount math for invoice items with an optional percentage + * discount (rabatt i procent per artikelrad). + * + * The formula set is deliberately exact in öre so every surface (editor + * preview, build-invoice-write, staged-operation commit, PDF, Peppol BG-27 + * line allowance) agrees to the öre: + * + * gross = roundOre(quantity * unit_price) + * discount = roundOre(gross * discount_percent / 100) + * net = roundOre(gross - discount) + * + * `net` is what is stored as invoice_items.line_total and what VAT is + * computed on (the discount reduces the beskattningsunderlag, ML 8 kap 13 §). + * Because `discount` is rounded before the subtraction, gross - discount is + * exact 2-decimal arithmetic and the UBL line check + * LineExtensionAmount = base - allowance holds without a tolerance. + * + * A line with no discount keeps the legacy unrounded `quantity * unit_price` + * as its net so existing invoices, stored line_totals and the Peppol + * LINE_TOTAL_MISMATCH check stay byte-identical. + */ +export interface LineAmounts { + /** Line amount before discount (rounded to öre when a discount applies). */ + gross: number + /** Discount amount in invoice currency (0 when no discount). */ + discount: number + /** Line amount after discount: what line_total stores and VAT applies to. */ + net: number +} + +/** True when the value is a discount that actually changes the line. */ +export function hasLineDiscount(discountPercent: number | null | undefined): boolean { + return typeof discountPercent === 'number' && discountPercent > 0 +} + +export function computeLineAmounts( + quantity: number, + unitPrice: number, + discountPercent?: number | null, +): LineAmounts { + const raw = (quantity || 0) * (unitPrice || 0) + if (!hasLineDiscount(discountPercent)) { + return { gross: raw, discount: 0, net: raw } + } + const gross = roundOre(raw) + const discount = roundOre((gross * (discountPercent as number)) / 100) + return { gross, discount, net: roundOre(gross - discount) } +} + +/** Convenience: the net line total (what invoice_items.line_total stores). */ +export function computeLineNet( + quantity: number, + unitPrice: number, + discountPercent?: number | null, +): number { + return computeLineAmounts(quantity, unitPrice, discountPercent).net +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 4b18d534..479421b3 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -55,6 +55,7 @@ const LABELS = { deliveryDate: 'Leveransdatum:', yourReference: 'Er referens:', ourReference: 'Vår referens:', + invoiceMarking: 'Märkning:', // Customer box custNo: 'Kundnr:', orgNo: 'Org.nr:', @@ -64,6 +65,7 @@ const LABELS = { colQty: 'Antal', colUnit: 'Enhet', colUnitPrice: 'à-pris', + colDiscount: 'Rabatt', colVat: 'Moms', colTotal: 'Summa', // Totals @@ -136,6 +138,7 @@ const LABELS = { deliveryDate: 'Delivery date:', yourReference: 'Your reference:', ourReference: 'Our reference:', + invoiceMarking: 'Buyer reference:', custNo: 'Customer no.:', orgNo: 'Reg. no.:', vat: 'VAT:', @@ -143,6 +146,7 @@ const LABELS = { colQty: 'Qty', colUnit: 'Unit', colUnitPrice: 'Unit price', + colDiscount: 'Discount', colVat: 'VAT', colTotal: 'Amount', subtotal: 'Subtotal:', @@ -367,6 +371,10 @@ function createStyles(branding?: InvoiceBranding) { flex: 1.5, textAlign: 'right', }, + colDiscount: { + flex: 1, + textAlign: 'right', + }, colVat: { flex: 1, textAlign: 'right', @@ -785,6 +793,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN ? new Set(billableItems.map((item) => item.vat_rate)) : new Set() const showVatColumn = hasPerLineVat && uniqueRates.size > 1 + // Rabatt column only when some line actually carries a discount: the + // stored line_total is already net, so the column documents the reduction + // (ML 17 kap 24 § p.10: prisnedsättning ska framgå av fakturan). + const showDiscountColumn = billableItems.some((item) => (item.discount_percent ?? 0) > 0) // Calculate per-rate VAT breakdown for totals const vatByRate = hasPerLineVat @@ -950,6 +962,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )} + {/* Fakturamärkning: one buyer-required marking string, never + comma-split (a PO/cost-center label may contain commas). */} + {invoice.invoice_marking && ( + + {L.invoiceMarking} + + + {invoice.invoice_marking.trim()} + + + + )} {/* Customer */} @@ -1003,6 +1027,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {!isDeliveryNote && ( {L.colUnitPrice} )} + {!isDeliveryNote && showDiscountColumn && ( + {L.colDiscount} + )} {!isDeliveryNote && showVatColumn && ( {L.colVat} )} @@ -1029,6 +1056,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {!isDeliveryNote && ( {formatPdfCurrency(item.unit_price, invoice.currency, lang)} )} + {!isDeliveryNote && showDiscountColumn && ( + + {(item.discount_percent ?? 0) > 0 ? `${item.discount_percent}%` : ''} + + )} {!isDeliveryNote && showVatColumn && ( {item.vat_rate ?? 0}% )} diff --git a/lib/invoices/peppol-bis-billing.ts b/lib/invoices/peppol-bis-billing.ts index 14e678f9..37423c77 100644 --- a/lib/invoices/peppol-bis-billing.ts +++ b/lib/invoices/peppol-bis-billing.ts @@ -4,6 +4,7 @@ import { validatePlusgiroNumber, } from '@/lib/bankgiro/luhn' import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants' +import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts' import { getDisplayTotal } from '@/lib/invoices/rounding' import { equalOre, roundOre } from '@/lib/money' import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' @@ -270,11 +271,13 @@ function prepareInvoice(input: PeppolInvoiceInput): 'Invoice, due, and delivery dates must be valid dates.', )) } - if (!hasText(invoice.your_reference)) { + // BT-10 BuyerReference: fakturamärkning wins when set (SFTI convention: + // the buyer's routing/marking string), else Er referens. + if (!hasText(invoice.invoice_marking) && !hasText(invoice.your_reference)) { issues.push(validationIssue( 'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference', - 'Er referens krävs för Peppol när inköpsordernummer saknas.', - 'Buyer reference is required for Peppol when no purchase order reference is available.', + 'Märkning eller Er referens krävs för Peppol när inköpsordernummer saknas.', + 'A marking or buyer reference is required for Peppol when no purchase order reference is available.', )) } if ((invoice.deduction_total ?? 0) !== 0) { @@ -396,11 +399,22 @@ function prepareInvoice(input: PeppolInvoiceInput): `The VAT rate on invoice line ${index + 1} must be 6, 12, or 25 percent.`, )) } - if (!equalMoney(item.line_total, roundMoney(item.quantity * item.unit_price))) { + // Net of any line discount: line_total must equal (qty × price) − rabatt, + // the same exact öre arithmetic the write path stores + // (lib/invoices/line-amounts.ts) and the BG-27 allowance below renders. + const expectedAmounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent) + if (!equalMoney(item.line_total, roundMoney(expectedAmounts.net))) { issues.push(validationIssue( 'LINE_TOTAL_MISMATCH', `${lineField}.line_total`, - `Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris.`, - `The amount on invoice line ${index + 1} does not equal quantity times price.`, + `Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris minus rabatt.`, + `The amount on invoice line ${index + 1} does not equal quantity times price less discount.`, + )) + } + if (item.discount_percent !== undefined && (item.discount_percent < 0 || item.discount_percent > 100)) { + issues.push(validationIssue( + 'LINE_DISCOUNT_INVALID', `${lineField}.discount_percent`, + `Rabatten på fakturarad ${index + 1} måste vara mellan 0 och 100 procent.`, + `The discount on invoice line ${index + 1} must be between 0 and 100 percent.`, )) } if (!equalMoney(item.vat_amount, roundMoney(item.line_total * item.vat_rate / 100))) { @@ -552,11 +566,29 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice): ' ', ' ', ]) - const invoiceLines = prepared.productItems.flatMap((item, index) => [ + const invoiceLines = prepared.productItems.flatMap((item, index) => { + // Line discount as a BG-27 allowance: LineExtensionAmount stays the net + // line_total and the allowance documents base − amount = net exactly + // (the amounts come from the same öre arithmetic as the stored total). + const amounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent) + const allowance = hasLineDiscount(item.discount_percent) + ? [ + ' ', + ' false', + ' 95', + ' Rabatt', + ` ${formatDecimal(item.discount_percent as number)}`, + ` ${formatMoney(amounts.discount)}`, + ` ${formatMoney(amounts.gross)}`, + ' ', + ] + : [] + return [ ' ', ` ${index + 1}`, ` ${formatDecimal(item.quantity)}`, ` ${formatMoney(item.line_total)}`, + ...allowance, ' ', ` ${escapeXml(item.description.trim())}`, ' ', @@ -569,7 +601,8 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice): ` ${formatDecimal(item.unit_price)}`, ' ', ' ', - ]) + ] + }) return [ '', @@ -584,7 +617,7 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice): ' 380', invoice.notes ? ` ${escapeXml(invoice.notes)}` : null, ' SEK', - ` ${escapeXml(invoice.your_reference?.trim() ?? '')}`, + ` ${escapeXml((invoice.invoice_marking?.trim() || invoice.your_reference?.trim()) ?? '')}`, renderParty('AccountingSupplierParty', prepared.supplier, company.f_skatt), renderParty('AccountingCustomerParty', prepared.buyer, false), invoice.delivery_date diff --git a/lib/invoices/rot-rut-rules.ts b/lib/invoices/rot-rut-rules.ts index 01ca8763..95384ff5 100644 --- a/lib/invoices/rot-rut-rules.ts +++ b/lib/invoices/rot-rut-rules.ts @@ -1,4 +1,5 @@ import { roundOre } from '@/lib/money' +import { computeLineNet } from '@/lib/invoices/line-amounts' /** * ROT/RUT-avdrag rules. @@ -211,6 +212,12 @@ export interface ItemForDeduction { unit_price: number /** Quantity. Same field as invoice_items.quantity. */ quantity: number + /** + * Percentage discount on the line (0-100), invoice_items.discount_percent. + * The deduction base is the amount the customer actually pays, so a + * discounted line deducts on the NET line total. Omitted/null = 0. + */ + discount_percent?: number | null /** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */ deduction_type?: DeductionType | null /** @@ -242,7 +249,8 @@ export interface ItemForDeduction { */ export function computeDeduction(item: ItemForDeduction): number { if (!item.deduction_type) return 0 - const lineTotal = item.unit_price * item.quantity + // Net of any line discount: the deduction follows what the customer pays. + const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent) if (lineTotal <= 0) return 0 const rate = item.vat_rate ?? 0 const lineVat = rate > 0 ? Math.round(lineTotal * rate / 100 * 100) / 100 : 0 diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 59258b59..d6775fdb 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -168,6 +168,7 @@ import { type InvoiceWriteInput, type InvoiceWriteItemInput, } from '@/lib/invoices/build-invoice-write' +import { computeLineNet } from '@/lib/invoices/line-amounts' import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice' import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items' @@ -1631,6 +1632,7 @@ async function commitCreateInvoice( const customerId = params.customer_id as string const items = params.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number + discount_percent?: number | null article_id?: string | null; revenue_account?: string | null line_type?: 'product' | 'text' dimensions?: Record @@ -1674,7 +1676,12 @@ async function commitCreateInvoice( const notVatRegistered = vatSettings?.vat_registered === false if (notVatRegistered) for (const item of items) item.vat_rate = 0 - const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) + // Line totals net of any per-line discount, same math as the web path + // (lib/invoices/line-amounts.ts). + const subtotal = billableItems.reduce( + (sum, item) => sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent), + 0, + ) let vatAmount = 0 for (const item of billableItems) { @@ -1682,7 +1689,14 @@ async function commitCreateInvoice( if (!allowedRates.has(itemRate)) { return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 } } - const lineTotal = item.quantity * item.unit_price + // Strict typeof: staged params are JSON a tampered client could shape; + // a string would coerce past a bare range check but be ignored by the + // number-typed totals math, then land in the NUMERIC column anyway. + const discountPercent = item.discount_percent ?? 0 + if (typeof discountPercent !== 'number' || !(discountPercent >= 0 && discountPercent <= 100)) { + return { error: 'Rabatten per rad måste vara mellan 0 och 100 procent', status: 400 } + } + const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent) vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } @@ -1824,6 +1838,7 @@ async function commitCreateInvoice( reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null), our_reference: (params.our_reference as string) || null, your_reference: (params.your_reference as string) || null, + invoice_marking: (params.invoice_marking as string) || null, notes: (params.notes as string) || null, payment_link_url: paymentLinkUrl, default_dimensions: defaultDimensions ?? {}, @@ -1846,6 +1861,7 @@ async function commitCreateInvoice( quantity: 0, unit: '', unit_price: 0, + discount_percent: 0, line_total: 0, vat_rate: 0, vat_amount: 0, @@ -1855,7 +1871,8 @@ async function commitCreateInvoice( } } const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate - const lineTotal = item.quantity * item.unit_price + const discountPercent = item.discount_percent ?? 0 + const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent) const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100 return { invoice_id: invoice.id, @@ -1865,6 +1882,7 @@ async function commitCreateInvoice( quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + discount_percent: discountPercent, line_total: lineTotal, vat_rate: itemRate, vat_amount: itemVat, @@ -1942,7 +1960,7 @@ async function commitUpdateInvoice( const { data: existing, error: fetchError } = await supabase .from('invoices') .select( - 'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4', + 'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4', ) .eq('id', invoiceId) .eq('company_id', companyId) @@ -2003,7 +2021,7 @@ async function commitUpdateInvoice( const { data: itemRows, error: itemsFetchError } = await supabase .from('invoice_items') .select( - 'line_type, description, quantity, unit, unit_price, vat_rate, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions', + 'line_type, description, quantity, unit, unit_price, discount_percent, vat_rate, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions', ) .eq('invoice_id', invoiceId) .order('sort_order', { ascending: true }) @@ -2028,6 +2046,7 @@ async function commitUpdateInvoice( currency: existing.currency as Currency, your_reference: changes.your_reference ?? existing.your_reference ?? undefined, our_reference: changes.our_reference ?? existing.our_reference ?? undefined, + invoice_marking: changes.invoice_marking ?? existing.invoice_marking ?? undefined, notes: changes.notes ?? existing.notes ?? undefined, // Not editable through this operation: fed back so the builder echoes the // stored values instead of clearing them. @@ -4553,6 +4572,7 @@ async function commitCreditInvoice( reverse_charge_text: original.reverse_charge_text, your_reference: original.your_reference, our_reference: original.our_reference, + invoice_marking: original.invoice_marking ?? null, notes: reason || `Krediterar faktura ${original.invoice_number}`, credited_invoice_id: id, // Dimensions PR7: copy so the reversal nets against the same cells. @@ -4573,6 +4593,7 @@ async function commitCreditInvoice( quantity: number unit: string unit_price: number + discount_percent?: number | null line_total: number vat_rate?: number vat_amount?: number @@ -4587,6 +4608,8 @@ async function commitCreditInvoice( quantity: -Math.abs(item.quantity), unit: item.unit, unit_price: item.unit_price, + // Kreditfakturans face arithmetic must multiply out like the original's. + discount_percent: item.discount_percent ?? 0, line_total: -Math.abs(item.line_total), vat_rate: item.vat_rate ?? 0, vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), diff --git a/lib/pending-operations/schemas/update-invoice.ts b/lib/pending-operations/schemas/update-invoice.ts index 61c1e461..4bac1210 100644 --- a/lib/pending-operations/schemas/update-invoice.ts +++ b/lib/pending-operations/schemas/update-invoice.ts @@ -23,6 +23,8 @@ const InvoiceChangesSchema = z delivery_date: isoDate.nullable().optional(), your_reference: z.string().optional(), our_reference: z.string().optional(), + // Fakturamärkning: buyer-required marking, separate from your_reference. + invoice_marking: z.string().max(200).optional(), items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required').optional(), // Replaces the whole bag; {} clears all tags. default_dimensions: DimensionsBagSchema.optional(), diff --git a/messages/en.json b/messages/en.json index 878896ce..2b033409 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3813,6 +3813,7 @@ "chip_received": "Received {date}", "chip_delivery": "Delivery date {date}", "chip_your_reference": "Your reference: {reference}", + "chip_invoice_marking": "Marking: {marking}", "chip_stripe_auto": "Stripe link created on send", "chip_payment_link": "Payment link added", "chip_ore_off": "No öre rounding", @@ -3831,6 +3832,10 @@ "ready_create": "Ready to review: everything else has sensible defaults.", "ready_edit": "Ready to save.", "ready_self_billed": "Ready to register.", + "row_menu_add_discount": "Add discount", + "row_menu_remove_discount": "Remove discount", + "discount_label": "Discount", + "validation_discount_range": "The discount must be between 0 and 100 percent", "row_menu_set_account": "Set posting account", "row_menu_remove_account": "Remove posting account", "row_menu_set_dimensions": "Set cost centre/project", @@ -3899,6 +3904,8 @@ "your_reference_placeholder": "Customer contact person", "our_reference_label": "Our reference", "our_reference_placeholder": "Your name", + "invoice_marking_label": "Marking", + "invoice_marking_placeholder": "E.g. cost center or order no.", "payment_link_label": "Payment link (optional)", "payment_link_placeholder": "https://buy.stripe.com/…", "payment_link_hint": "Paste a payment link created for this specific invoice (e.g. in Stripe). Make sure the amount matches and preferably limit the link to a single payment. The link is shown as a button in the invoice email and as a QR code on the PDF.", @@ -4015,6 +4022,7 @@ "total": "Total", "your_reference": "Your reference:", "our_reference": "Our reference:", + "invoice_marking": "Marking:", "notes_prefix": "Note: {notes}" }, "invoice_bank_setup": { diff --git a/messages/sv.json b/messages/sv.json index 5c09e918..588ac569 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3813,6 +3813,7 @@ "chip_received": "Mottagen {date}", "chip_delivery": "Leveransdatum {date}", "chip_your_reference": "Er referens: {reference}", + "chip_invoice_marking": "Märkning: {marking}", "chip_stripe_auto": "Stripe-länk skapas vid utskick", "chip_payment_link": "Betalningslänk inlagd", "chip_ore_off": "Ingen öresavrundning", @@ -3831,6 +3832,10 @@ "ready_create": "Klar att granska: allt annat har smarta förval.", "ready_edit": "Klart att spara.", "ready_self_billed": "Klar att registrera.", + "row_menu_add_discount": "Lägg till rabatt", + "row_menu_remove_discount": "Ta bort rabatt", + "discount_label": "Rabatt", + "validation_discount_range": "Rabatten måste vara mellan 0 och 100 procent", "row_menu_set_account": "Ange bokföringskonto", "row_menu_remove_account": "Ta bort bokföringskonto", "row_menu_set_dimensions": "Ange kostnadsställe/projekt", @@ -3899,6 +3904,8 @@ "your_reference_placeholder": "Kontaktperson hos kund", "our_reference_label": "Vår referens", "our_reference_placeholder": "Ditt namn", + "invoice_marking_label": "Märkning", + "invoice_marking_placeholder": "T.ex. kostnadsställe eller ordernr", "payment_link_label": "Betalningslänk (valfritt)", "payment_link_placeholder": "https://buy.stripe.com/…", "payment_link_hint": "Klistra in en betalningslänk skapad för just denna faktura (t.ex. i Stripe). Kontrollera att beloppet stämmer och begränsa gärna länken till en betalning. Länken visas som en knapp i fakturamejlet och som QR-kod på PDF:en.", @@ -4015,6 +4022,7 @@ "total": "Totalt", "your_reference": "Er referens:", "our_reference": "Vår referens:", + "invoice_marking": "Märkning:", "notes_prefix": "Anteckning: {notes}" }, "invoice_bank_setup": { diff --git a/skills/accounted-api/references/invoices.md b/skills/accounted-api/references/invoices.md index 897ba401..c1e1a604 100644 --- a/skills/accounted-api/references/invoices.md +++ b/skills/accounted-api/references/invoices.md @@ -110,6 +110,7 @@ Request body: document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, + invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, @@ -124,7 +125,7 @@ Request body: external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", - items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] + items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] } ``` @@ -305,7 +306,7 @@ Request body: our_reference?: string | unknown, notes?: string | unknown, default_dimensions?: Record, - items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] + items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] } ``` @@ -776,7 +777,7 @@ Bulk-creation endpoint. Each invoice in the request array is validated and inser Request body: ```ts { - invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] }[], + invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] }[], all_or_nothing?: boolean } ``` diff --git a/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql b/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql new file mode 100644 index 00000000..cfa54299 --- /dev/null +++ b/supabase/migrations/20260831120000_invoice_line_discount_and_marking.sql @@ -0,0 +1,30 @@ +-- Per-line percentage discount (rabatt i procent per artikelrad) and a +-- fakturamärkning field separate from Er referens (your_reference). +-- +-- invoice_items.discount_percent: 0-100, default 0. The stored line_total is +-- always the NET amount (after discount); VAT is computed on the net, so the +-- bookkeeping generators need no change. Server code recomputes the discount +-- via lib/invoices/line-amounts.ts and never trusts a client-sent total. +-- +-- invoices.invoice_marking: the buyer-required marking (kostnadsstalle, +-- project code, PO label) printed on the invoice and mapped to Peppol BT-10 +-- BuyerReference when set. Distinct from your_reference, which stays the +-- contact person (Er referens). + +ALTER TABLE public.invoice_items + ADD COLUMN IF NOT EXISTS discount_percent NUMERIC NOT NULL DEFAULT 0; + +ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_discount_percent_check; +ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_discount_percent_check + CHECK (discount_percent >= 0 AND discount_percent <= 100); + +COMMENT ON COLUMN public.invoice_items.discount_percent IS + 'Percentage discount on the line (0-100). line_total and vat_amount are stored net of this discount.'; + +ALTER TABLE public.invoices + ADD COLUMN IF NOT EXISTS invoice_marking TEXT; + +COMMENT ON COLUMN public.invoices.invoice_marking IS + 'Fakturamarkning: buyer-required marking (cost center/project/PO), separate from your_reference (Er referens). Feeds Peppol BT-10 BuyerReference when set.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 226b1490..10dc51f4 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1267,6 +1267,11 @@ export interface Invoice { // Reference your_reference: string | null our_reference: string | null + // Fakturamärkning: buyer-required marking (cost center, project, PO label), + // separate from your_reference (Er referens = contact person). Printed on + // the PDF and mapped to Peppol BT-10 BuyerReference when set. Optional in + // TS for pre-migration fixtures. + invoice_marking?: string | null // Optional online payment link (pasted by the user, e.g. a Stripe Payment // Link). Rendered as a "Betala online" button in the invoice email and as a @@ -1431,7 +1436,12 @@ export interface InvoiceItem { // Price unit_price: number - // Calculated + // Percentage discount on the line (0-100). line_total and vat_amount are + // stored NET of this discount (lib/invoices/line-amounts.ts). Optional in + // TS for pre-migration fixtures; treat undefined the same as 0. + discount_percent?: number + + // Calculated (net of discount_percent) line_total: number // Per-line VAT @@ -1708,6 +1718,8 @@ export interface CreateInvoiceInput { document_type?: InvoiceDocumentType your_reference?: string our_reference?: string + /** Fakturamärkning: buyer-required marking, separate from your_reference. */ + invoice_marking?: string notes?: string /** Optional https link where the customer can pay online (e.g. a Stripe Payment Link). */ payment_link_url?: string @@ -1731,6 +1743,8 @@ export interface CreateInvoiceItemInput { quantity: number unit: string unit_price: number + /** Percentage discount on the line (0-100). Omitted/null = 0. */ + discount_percent?: number | null vat_rate?: number /** Source article (optional). Free-text lines omit it. */ article_id?: string | null