feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1384,6 +1384,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
|
||||
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
|
||||
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
|
||||
[2026-08-31] Per-line discount stores NET line_total via computeLineNet (roundOre(gross) minus roundOre(gross*d/100)) rather than round(gross*(1-d/100)): the subtracted-rounded-discount form keeps gross = discount + net exact in ore arithmetic, which the Peppol BG-27 line allowance (Amount + LineExtensionAmount = BaseAmount) and the PDF discount column both need; undiscounted lines keep the legacy unrounded qty*price so existing invoices and the Peppol LINE_TOTAL check stay byte-identical. ROT/RUT deducts on the discounted net (the customer pays that). invoice_marking is deliberately NOT copied by copy-invoice (recipient/PO-specific, same rule as your_reference) and NOT added to recurring schedules (follow-up if requested).
|
||||
[2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread).
|
||||
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
|
||||
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
|
||||
|
||||
@@ -79,6 +79,8 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
reverse_charge_text: proforma.reverse_charge_text,
|
||||
your_reference: proforma.your_reference,
|
||||
our_reference: proforma.our_reference,
|
||||
// Buyer routing survives conversion (Peppol BT-10 may rely on it alone).
|
||||
invoice_marking: proforma.invoice_marking ?? null,
|
||||
notes: proforma.notes,
|
||||
document_type: 'invoice',
|
||||
converted_from_id: id,
|
||||
@@ -92,7 +94,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: getUserErrorMessage(invoiceError) }, { status: 500 })
|
||||
}
|
||||
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record<string, string> }) => ({
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; discount_percent?: number | null; line_total: number; dimensions?: Record<string, string> }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
@@ -100,6 +102,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
// The stored line_total is net of this; dropping it would make the
|
||||
// converted invoice fail the Peppol line check and lose the rebate on
|
||||
// the next builder pass.
|
||||
discount_percent: item.discount_percent ?? 0,
|
||||
line_total: item.line_total,
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -392,6 +392,8 @@ async function createCreditNote(
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
// Same buyer routing on the kreditfaktura as the original.
|
||||
invoice_marking: originalInvoice.invoice_marking ?? null,
|
||||
// Positive magnitude, unlike the negated amounts above: the DB has
|
||||
// CHECK (deduction_total >= 0), and every reader either recomputes the
|
||||
// ROT/RUT amount from the items or skips credit notes entirely.
|
||||
|
||||
@@ -39,7 +39,7 @@ const CreditNoteRequest = z.object({
|
||||
})
|
||||
|
||||
const ORIGINAL_INVOICE_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions'
|
||||
|
||||
// default_dimensions stays in this projection: the inserted credit-note row is
|
||||
// handed to createCreditNoteJournalEntry, which reads the bag off the row so
|
||||
@@ -48,7 +48,7 @@ const CREDIT_NOTE_RESPONSE_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, default_dimensions, created_at, updated_at'
|
||||
|
||||
const ORIGINAL_ITEMS_COLUMNS =
|
||||
'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, dimensions'
|
||||
'sort_order, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, dimensions'
|
||||
|
||||
const CreditNoteCreated = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -181,6 +181,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
discount_percent?: number | null
|
||||
line_total: number
|
||||
vat_rate?: number | null
|
||||
vat_amount?: number | null
|
||||
@@ -246,6 +247,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
reverse_charge_text: original.reverse_charge_text ?? null,
|
||||
your_reference: original.your_reference ?? null,
|
||||
our_reference: original.our_reference ?? null,
|
||||
// Same buyer routing on the kreditfaktura as the original.
|
||||
invoice_marking: original.invoice_marking ?? null,
|
||||
notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`,
|
||||
credited_invoice_id: originalId,
|
||||
status: 'sent' as const,
|
||||
@@ -261,6 +264,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
// Carried so the kreditfaktura's face arithmetic multiplies out and the
|
||||
// Rabatt column renders (ML 17 kap 24 §).
|
||||
discount_percent: item.discount_percent ?? 0,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -Math.abs(item.vat_amount ?? 0),
|
||||
|
||||
@@ -37,8 +37,9 @@ import {
|
||||
import { sortArticles } from '@/lib/articles/sort'
|
||||
import ArticleCombobox from '@/components/invoices/ArticleCombobox'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy } from 'lucide-react'
|
||||
import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy, Percent } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -259,6 +260,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
unit_price: z.number(),
|
||||
// Rabatt i procent per rad (⋮ menu). null = no discount.
|
||||
discount_percent: z
|
||||
.number()
|
||||
.min(0, t('validation_discount_range'))
|
||||
.max(100, t('validation_discount_range'))
|
||||
.nullable()
|
||||
.optional(),
|
||||
vat_rate: z.number().min(0).max(25),
|
||||
// Article linkage (artikelregister). Optional: free-text lines omit them.
|
||||
article_id: z.string().nullable().optional(),
|
||||
@@ -343,6 +351,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
invoice_marking: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// Optional online payment link (pasted from e.g. the Stripe dashboard).
|
||||
// https-only: mirrors the server-side CreateInvoiceSchema gate.
|
||||
@@ -453,6 +462,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
[activeAccounts],
|
||||
)
|
||||
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
|
||||
// Rabatt per rad: rows whose discount strip is open (⋮ menu), same
|
||||
// lifecycle as the account override above. A stored discount also opens it.
|
||||
const [discountRows, setDiscountRows] = useState<Set<number>>(new Set())
|
||||
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
|
||||
// render only when company_settings.dimensions_enabled: a UI-visibility
|
||||
// gate; a draft that already carries bags still round-trips untouched when
|
||||
@@ -529,6 +541,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType,
|
||||
your_reference: initial.your_reference ?? '',
|
||||
our_reference: initial.our_reference ?? '',
|
||||
invoice_marking: initial.invoice_marking ?? '',
|
||||
notes: initial.notes ?? '',
|
||||
payment_link_url: initial.payment_link_url ?? '',
|
||||
payment_link_auto: initial.payment_link_auto ?? true,
|
||||
@@ -543,6 +556,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null,
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
article_id: item.article_id ?? null,
|
||||
revenue_account: item.revenue_account ?? null,
|
||||
@@ -568,6 +582,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
document_type: 'invoice' as InvoiceDocumentType,
|
||||
your_reference: '',
|
||||
our_reference: copyInitial.our_reference,
|
||||
invoice_marking: '',
|
||||
notes: copyInitial.notes,
|
||||
payment_link_url: '',
|
||||
payment_link_auto: true,
|
||||
@@ -635,6 +650,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const watchReceivedDate = watch('received_date')
|
||||
const watchDeliveryDate = watch('delivery_date')
|
||||
const watchYourReference = watch('your_reference')
|
||||
const watchInvoiceMarking = watch('invoice_marking')
|
||||
const watchPaymentLinkUrl = watch('payment_link_url')
|
||||
const watchPaymentLinkAuto = watch('payment_link_auto')
|
||||
const watchPersonnummer = watch('deduction_personnummer')
|
||||
@@ -817,6 +833,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
discount_percent: null,
|
||||
vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0,
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
@@ -871,6 +888,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
discount_percent: null,
|
||||
vat_rate: 0,
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
@@ -1101,7 +1119,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
|
||||
const subtotal = watchItems.reduce((sum, item) => {
|
||||
return sum + (item.quantity || 0) * (item.unit_price || 0)
|
||||
return sum + computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent)
|
||||
}, 0)
|
||||
|
||||
const vatRules = selectedCustomer
|
||||
@@ -1134,7 +1152,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
let vatAmount = 0
|
||||
for (const item of watchItems) {
|
||||
const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0
|
||||
const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
|
||||
const lineTotal = computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent)
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatAmount += lineVat
|
||||
const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
|
||||
@@ -1226,6 +1244,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const amount = computeDeduction({
|
||||
unit_price: item.unit_price || 0,
|
||||
quantity: item.quantity || 0,
|
||||
discount_percent: item.discount_percent,
|
||||
deduction_type: item.deduction_type,
|
||||
// Same rate resolution as the VAT totals loop above: the deduction
|
||||
// base is the line total inkl. moms (HUSFL 6-9 §§).
|
||||
@@ -1307,6 +1326,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
}
|
||||
|
||||
// Open/close the per-line discount (⋮ menu). Closing clears the value so
|
||||
// the row books at full price again.
|
||||
function toggleDiscount(index: number) {
|
||||
const isOpen = discountRows.has(index) || hasLineDiscount(watchItems[index]?.discount_percent)
|
||||
if (isOpen) {
|
||||
setValue(`items.${index}.discount_percent`, null, { shouldDirty: true, shouldValidate: true })
|
||||
setDiscountRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(index)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setDiscountRows((prev) => new Set(prev).add(index))
|
||||
window.setTimeout(() => setFocus(`items.${index}.discount_percent`), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Open/close the optional per-item dimensions override (⋮ menu). Closing
|
||||
// clears the bag so the row falls back to the invoice's default_dimensions.
|
||||
function toggleItemDimensions(index: number) {
|
||||
@@ -1754,6 +1790,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
items: data.items,
|
||||
your_reference: data.your_reference,
|
||||
our_reference: data.our_reference,
|
||||
invoice_marking: data.invoice_marking,
|
||||
notes: data.notes,
|
||||
payment_link_url: data.payment_link_url,
|
||||
invoice_number: numberPreview,
|
||||
@@ -1882,6 +1919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
receivedDate: watchReceivedDate || '',
|
||||
deliveryDate: watchDeliveryDate || '',
|
||||
yourReference: watchYourReference || '',
|
||||
invoiceMarking: watchInvoiceMarking || '',
|
||||
paymentLink: paymentLinkMode,
|
||||
oreRounding,
|
||||
dims: hasDimensionValues(defaultDims) ? compactDims(defaultDims) : null,
|
||||
@@ -1904,6 +1942,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
return t('chip_delivery', { date: chip.date })
|
||||
case 'your_reference':
|
||||
return t('chip_your_reference', { reference: chip.reference })
|
||||
case 'invoice_marking':
|
||||
return t('chip_invoice_marking', { marking: chip.marking })
|
||||
case 'payment_link':
|
||||
return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link')
|
||||
case 'ore_off':
|
||||
@@ -2156,7 +2196,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
)
|
||||
}
|
||||
|
||||
const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0)
|
||||
const lineTotal = computeLineNet(
|
||||
item?.quantity || 0,
|
||||
item?.unit_price || 0,
|
||||
item?.discount_percent,
|
||||
)
|
||||
const rowErrors = errors.items?.[index]
|
||||
const rowErrorMsg =
|
||||
rowErrors?.description?.message ??
|
||||
@@ -2166,6 +2210,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const articleStripOpen = articlePickerRows.has(index)
|
||||
const accountStripOpen =
|
||||
isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account))
|
||||
// Not offered for a received självfaktura: the self-billed
|
||||
// endpoint's reduced item shape carries no discount, so a
|
||||
// previewed rebate would silently book gross.
|
||||
const discountStripOpen =
|
||||
!isSelfBilled &&
|
||||
(discountRows.has(index) || hasLineDiscount(item?.discount_percent))
|
||||
const dimensionStripOpen =
|
||||
dimensionsEnabled &&
|
||||
isInvoiceDoc &&
|
||||
@@ -2292,6 +2342,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<Package className="h-4 w-4" />
|
||||
{t('row_menu_pick_article')}
|
||||
</DropdownMenuItem>
|
||||
{!isSelfBilled && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => toggleDiscount(index)} className="py-2">
|
||||
<Percent className="h-4 w-4" />
|
||||
{discountStripOpen
|
||||
? t('row_menu_remove_discount')
|
||||
: t('row_menu_add_discount')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{isInvoiceDoc && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -2422,6 +2483,59 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rabatt strip: opened via the ⋮ menu; a stored
|
||||
discount keeps it open in edit mode. */}
|
||||
{discountStripOpen && (
|
||||
<div className="px-2 pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`invoice-line-discount-${index}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t('discount_label')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={`invoice-line-discount-${index}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
max={100}
|
||||
inputMode="decimal"
|
||||
placeholder="0"
|
||||
className="h-8 w-24 text-right tabular-nums"
|
||||
aria-label={t('discount_label')}
|
||||
aria-invalid={Boolean(rowErrors?.discount_percent) || undefined}
|
||||
{...register(`items.${index}.discount_percent`, {
|
||||
// valueAsNumber turns an emptied field into
|
||||
// NaN, which the schema rejects invisibly;
|
||||
// same pattern as labor_hours.
|
||||
setValueAs: (v) => {
|
||||
if (v === '' || v == null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
{hasLineDiscount(item?.discount_percent) && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
−{formatCurrency(
|
||||
roundOre((item?.quantity || 0) * (item?.unit_price || 0)) - lineTotal,
|
||||
watchCurrency,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{rowErrors?.discount_percent && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
{rowErrors.discount_percent.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ROT/RUT-avdrag strip: only when a deduction is
|
||||
active on this row (chosen via the ⋮ menu). */}
|
||||
{isInvoiceDoc && item?.deduction_type && (
|
||||
@@ -2482,6 +2596,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const amt = computeDeduction({
|
||||
unit_price: item?.unit_price || 0,
|
||||
quantity: item?.quantity || 0,
|
||||
discount_percent: item?.discount_percent,
|
||||
deduction_type: item?.deduction_type,
|
||||
vat_rate: vatRegistered
|
||||
? (item?.vat_rate ?? (vatRules?.rate || 25))
|
||||
@@ -2924,6 +3039,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Fakturamärkning: one buyer-required marking string
|
||||
(kostnadsställe/projekt/PO), separate from Er
|
||||
referens. Plain input, never comma-split. */}
|
||||
<div className={SETTINGS_ROW_CLASS}>
|
||||
<Label htmlFor="invoice_marking" className="text-[13px] font-normal">
|
||||
{t('invoice_marking_label')}
|
||||
</Label>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
id="invoice_marking"
|
||||
maxLength={200}
|
||||
placeholder={t('invoice_marking_placeholder')}
|
||||
className="h-8 text-[13px]"
|
||||
{...register('invoice_marking')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online payment link: manual paste or the Stripe auto
|
||||
toggle. Only real invoices; hidden unless the company
|
||||
@@ -3212,6 +3344,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
total={total}
|
||||
yourReference={pendingData?.your_reference}
|
||||
ourReference={pendingData?.our_reference}
|
||||
invoiceMarking={pendingData?.invoice_marking}
|
||||
notes={pendingData?.notes}
|
||||
numberPreview={numberPreview}
|
||||
oreRounding={oreRounding}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { isTextLikeLine } from '@/lib/invoices/display'
|
||||
import { computeLineNet } from '@/lib/invoices/line-amounts'
|
||||
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
|
||||
@@ -15,6 +16,8 @@ interface ReviewItem {
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
/** Line discount 0-100; amounts render net of it. */
|
||||
discount_percent?: number | null
|
||||
vat_rate?: number
|
||||
/** 'text' rows are free-text/blank lines: description only, no amounts. */
|
||||
line_type?: 'product' | 'text'
|
||||
@@ -38,6 +41,7 @@ interface InvoiceReviewContentProps {
|
||||
total: number
|
||||
yourReference?: string
|
||||
ourReference?: string
|
||||
invoiceMarking?: string
|
||||
notes?: string
|
||||
/** The invoice number that will be assigned on confirm. Null when unknown
|
||||
* (e.g. delivery notes use a different sequence) or unfetched. */
|
||||
@@ -63,6 +67,7 @@ export function InvoiceReviewContent({
|
||||
total,
|
||||
yourReference,
|
||||
ourReference,
|
||||
invoiceMarking,
|
||||
notes,
|
||||
numberPreview,
|
||||
oreRounding,
|
||||
@@ -84,7 +89,7 @@ export function InvoiceReviewContent({
|
||||
for (const item of items) {
|
||||
if (isTextLikeLine(item)) continue
|
||||
const rate = item.vat_rate ?? 0
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
@@ -182,12 +187,19 @@ export function InvoiceReviewContent({
|
||||
</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.unit_price, currency)}
|
||||
{(item.discount_percent ?? 0) > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
−{item.discount_percent}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
{formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -214,11 +226,14 @@ export function InvoiceReviewContent({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
|
||||
<span>
|
||||
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}
|
||||
{(item.discount_percent ?? 0) > 0 && <> −{item.discount_percent}%</>}
|
||||
</span>
|
||||
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
|
||||
</div>
|
||||
<p className="text-right font-medium">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
{formatCurrency(computeLineNet(item.quantity, item.unit_price, item.discount_percent), currency)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -260,7 +275,7 @@ export function InvoiceReviewContent({
|
||||
</div>
|
||||
|
||||
{/* References/notes */}
|
||||
{(yourReference || ourReference || notes) && (
|
||||
{(yourReference || ourReference || invoiceMarking || notes) && (
|
||||
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{yourReference && (
|
||||
<p>
|
||||
@@ -272,6 +287,11 @@ export function InvoiceReviewContent({
|
||||
<span>{t('our_reference')}</span> {ourReference}
|
||||
</p>
|
||||
)}
|
||||
{invoiceMarking && (
|
||||
<p>
|
||||
<span>{t('invoice_marking')}</span> {invoiceMarking}
|
||||
</p>
|
||||
)}
|
||||
{notes && <p>{t('notes_prefix', { notes })}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -165,6 +165,7 @@ function chipsInput(overrides: Partial<ForvalChipsInput> = {}): ForvalChipsInput
|
||||
receivedDate: '',
|
||||
deliveryDate: '',
|
||||
yourReference: '',
|
||||
invoiceMarking: '',
|
||||
paymentLink: null,
|
||||
oreRounding: true,
|
||||
dims: null,
|
||||
@@ -221,6 +222,7 @@ describe('deriveForvalChips', () => {
|
||||
currency: 'EUR',
|
||||
deliveryDate: '2026-08-20',
|
||||
yourReference: 'Anna',
|
||||
invoiceMarking: 'KST 4711',
|
||||
paymentLink: 'manual',
|
||||
oreRounding: false,
|
||||
dims: 'KS01 · P001',
|
||||
@@ -230,6 +232,7 @@ describe('deriveForvalChips', () => {
|
||||
expect(chips).toContainEqual({ kind: 'currency', currency: 'EUR' })
|
||||
expect(chips).toContainEqual({ kind: 'delivery', date: '2026-08-20' })
|
||||
expect(chips).toContainEqual({ kind: 'your_reference', reference: 'Anna' })
|
||||
expect(chips).toContainEqual({ kind: 'invoice_marking', marking: 'KST 4711' })
|
||||
expect(chips).toContainEqual({ kind: 'payment_link', mode: 'manual' })
|
||||
expect(chips).toContainEqual({ kind: 'dims', dims: 'KS01 · P001' })
|
||||
// ore_off is SEK-only: an EUR invoice has no öresavrundning to disable.
|
||||
@@ -249,6 +252,7 @@ describe('deriveForvalChips', () => {
|
||||
receivedDate: '2026-08-15',
|
||||
documentType: 'proforma',
|
||||
yourReference: 'x',
|
||||
invoiceMarking: 'x',
|
||||
paymentLink: 'auto',
|
||||
oreRounding: false,
|
||||
dims: 'KS01',
|
||||
|
||||
@@ -121,6 +121,7 @@ export type ForvalChip =
|
||||
| { kind: 'received'; date: string }
|
||||
| { kind: 'delivery'; date: string }
|
||||
| { kind: 'your_reference'; reference: string }
|
||||
| { kind: 'invoice_marking'; marking: string }
|
||||
| { kind: 'payment_link'; mode: 'auto' | 'manual' }
|
||||
| { kind: 'ore_off' }
|
||||
| { kind: 'dims'; dims: string }
|
||||
@@ -134,6 +135,7 @@ export interface ForvalChipsInput {
|
||||
receivedDate: string
|
||||
deliveryDate: string
|
||||
yourReference: string
|
||||
invoiceMarking: string
|
||||
paymentLink: 'auto' | 'manual' | null
|
||||
oreRounding: boolean
|
||||
/** Compact display of the invoice-level default dims, or null when none. */
|
||||
@@ -174,6 +176,9 @@ export function deriveForvalChips(input: ForvalChipsInput): ForvalChip[] {
|
||||
if (!input.isSelfBilled && input.yourReference.trim()) {
|
||||
chips.push({ kind: 'your_reference', reference: input.yourReference.trim() })
|
||||
}
|
||||
if (!input.isSelfBilled && input.invoiceMarking.trim()) {
|
||||
chips.push({ kind: 'invoice_marking', marking: input.invoiceMarking.trim() })
|
||||
}
|
||||
if (!input.isSelfBilled && input.paymentLink) {
|
||||
chips.push({ kind: 'payment_link', mode: input.paymentLink })
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ describe('gnubok_get_invoice: execute', () => {
|
||||
quantity: 2,
|
||||
unit: 'tim',
|
||||
unit_price: 1200,
|
||||
discount_percent: 0,
|
||||
line_total: 2400,
|
||||
vat_rate: 25,
|
||||
vat_amount: 600,
|
||||
|
||||
@@ -359,6 +359,7 @@ describe('gnubok_update_invoice: validation and staging', () => {
|
||||
expect(result.preview.current_items).toEqual(
|
||||
CURRENT_ROWS.map((row) => ({
|
||||
...row,
|
||||
discount_percent: 0,
|
||||
deduction_type: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
|
||||
@@ -59,6 +59,7 @@ import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
|
||||
import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules'
|
||||
import { computeLineNet } from '@/lib/invoices/line-amounts'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
@@ -303,6 +304,8 @@ type StagedInvoiceLineInput = {
|
||||
quantity: number
|
||||
unit?: string
|
||||
unit_price?: number
|
||||
/** Line discount 0-100 (rabatt i procent); totals are computed net of it. */
|
||||
discount_percent?: number
|
||||
vat_rate?: number
|
||||
article_id?: string
|
||||
revenue_account?: string | null
|
||||
@@ -370,10 +373,21 @@ function resolveInvoiceLineFromArticle(
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
discount_percent: 0,
|
||||
vat_rate: 0,
|
||||
}
|
||||
}
|
||||
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`)
|
||||
// Strict typeof: a host that skips inputSchema validation could send a
|
||||
// string, which JS comparisons would coerce past a bare range check while
|
||||
// hasLineDiscount (typeof === 'number') then ignores it in the totals.
|
||||
if (
|
||||
item.discount_percent != null &&
|
||||
(typeof item.discount_percent !== 'number' ||
|
||||
!(item.discount_percent >= 0 && item.discount_percent <= 100))
|
||||
) {
|
||||
throw new Error(`Item ${lineNo}: discount_percent must be a number between 0 and 100`)
|
||||
}
|
||||
if (item.article_id && !article) {
|
||||
throw new Error(`Item ${lineNo}: article ${item.article_id} not found in this company. Use gnubok_list_articles to find valid IDs.`)
|
||||
}
|
||||
@@ -6287,6 +6301,7 @@ export const tools: McpTool[] = [
|
||||
remaining_amount: { type: ['number', 'null'] },
|
||||
your_reference: { type: ['string', 'null'] },
|
||||
our_reference: { type: ['string', 'null'] },
|
||||
invoice_marking: { type: ['string', 'null'], description: 'Fakturamärkning (buyer marking), separate from your_reference' },
|
||||
notes: { type: ['string', 'null'] },
|
||||
default_dimensions: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' },
|
||||
@@ -6303,6 +6318,7 @@ export const tools: McpTool[] = [
|
||||
quantity: { type: 'number' },
|
||||
unit: { type: 'string' },
|
||||
unit_price: { type: 'number' },
|
||||
discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' },
|
||||
line_total: { type: 'number' },
|
||||
vat_rate: { type: 'number' },
|
||||
vat_amount: { type: 'number' },
|
||||
@@ -6345,7 +6361,7 @@ export const tools: McpTool[] = [
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
|
||||
'id, invoice_number, status, document_type, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)',
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
@@ -6362,6 +6378,7 @@ export const tools: McpTool[] = [
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
discount_percent: number | null
|
||||
line_total: number
|
||||
vat_rate: number
|
||||
vat_amount: number | null
|
||||
@@ -6390,6 +6407,7 @@ export const tools: McpTool[] = [
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
unit_price: row.unit_price,
|
||||
discount_percent: row.discount_percent ?? 0,
|
||||
line_total: row.line_total,
|
||||
vat_rate: row.vat_rate,
|
||||
vat_amount: row.vat_amount ?? 0,
|
||||
@@ -6427,6 +6445,7 @@ export const tools: McpTool[] = [
|
||||
remaining_amount: invoice.remaining_amount ?? null,
|
||||
your_reference: invoice.your_reference ?? null,
|
||||
our_reference: invoice.our_reference ?? null,
|
||||
invoice_marking: invoice.invoice_marking ?? null,
|
||||
notes: invoice.notes ?? null,
|
||||
default_dimensions: (invoice.default_dimensions as Record<string, string> | null) ?? {},
|
||||
editable_draft: isEditableInvoiceDraft(invoice),
|
||||
@@ -6456,6 +6475,7 @@ export const tools: McpTool[] = [
|
||||
quantity: { type: 'number' },
|
||||
unit: { type: 'string', description: 'st, tim, dag, mån' },
|
||||
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
|
||||
discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); line total and VAT computed net of it' },
|
||||
vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
|
||||
article_id: {
|
||||
type: 'string',
|
||||
@@ -6483,6 +6503,7 @@ export const tools: McpTool[] = [
|
||||
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
|
||||
our_reference: { type: 'string' },
|
||||
your_reference: { type: 'string' },
|
||||
invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference; feeds Peppol BuyerReference.' },
|
||||
notes: { type: 'string' },
|
||||
payment_link_url: {
|
||||
type: 'string',
|
||||
@@ -6603,8 +6624,11 @@ export const tools: McpTool[] = [
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// Calculate per-item VAT
|
||||
const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0)
|
||||
// Calculate per-item VAT (line totals net of any per-line discount)
|
||||
const subtotal = items.reduce(
|
||||
(s, item) => s + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
|
||||
0,
|
||||
)
|
||||
let vatAmount = 0
|
||||
for (const item of items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
@@ -6614,7 +6638,7 @@ export const tools: McpTool[] = [
|
||||
`Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}`
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
|
||||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
}
|
||||
const total = subtotal + vatAmount
|
||||
@@ -6641,6 +6665,7 @@ export const tools: McpTool[] = [
|
||||
currency,
|
||||
our_reference: (args.our_reference as string) || null,
|
||||
your_reference: (args.your_reference as string) || null,
|
||||
invoice_marking: (args.invoice_marking as string) || null,
|
||||
notes: (args.notes as string) || null,
|
||||
payment_link_url: paymentLinkUrl,
|
||||
},
|
||||
@@ -6649,7 +6674,7 @@ export const tools: McpTool[] = [
|
||||
customer_type: customer.customer_type,
|
||||
items: stagedItems.map(item => ({
|
||||
...item,
|
||||
line_total: item.quantity * item.unit_price,
|
||||
line_total: computeLineNet(item.quantity, item.unit_price, item.discount_percent),
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
})),
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
@@ -17174,6 +17199,7 @@ export const tools: McpTool[] = [
|
||||
delivery_date: { type: ['string', 'null'], description: 'YYYY-MM-DD; null clears the delivery date.' },
|
||||
your_reference: { type: 'string' },
|
||||
our_reference: { type: 'string' },
|
||||
invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference.' },
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@@ -17183,6 +17209,7 @@ export const tools: McpTool[] = [
|
||||
quantity: { type: 'number' },
|
||||
unit: { type: 'string', description: 'st, tim, dag, mån' },
|
||||
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
|
||||
discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); pass back to keep it, totals computed net of it.' },
|
||||
vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' },
|
||||
article_id: {
|
||||
type: 'string',
|
||||
@@ -17259,7 +17286,7 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
|
||||
const headerChanges: Record<string, unknown> = {}
|
||||
for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference']) {
|
||||
for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference', 'invoice_marking']) {
|
||||
if (args[key] !== undefined) headerChanges[key] = args[key]
|
||||
}
|
||||
if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) {
|
||||
@@ -17364,7 +17391,7 @@ export const tools: McpTool[] = [
|
||||
`Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}`
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineTotal = computeLineNet(item.quantity, item.unit_price, item.discount_percent)
|
||||
subtotal += lineTotal
|
||||
vatAmount += roundOre(lineTotal * itemRate / 100)
|
||||
}
|
||||
@@ -17379,6 +17406,7 @@ export const tools: McpTool[] = [
|
||||
deductionLines.map((item) => ({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
discount_percent: item.discount_percent ?? 0,
|
||||
deduction_type: item.deduction_type ?? null,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
labor_hours: item.labor_hours ?? null,
|
||||
@@ -17421,7 +17449,7 @@ export const tools: McpTool[] = [
|
||||
// the property columns are not needed for the preview.
|
||||
const { data: currentRows, error: currentError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('line_type, description, quantity, unit, unit_price, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end')
|
||||
.select('line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end')
|
||||
.eq('invoice_id', invoice.id)
|
||||
.order('sort_order', { ascending: true })
|
||||
if (currentError) throw dbError(currentError)
|
||||
@@ -17431,6 +17459,7 @@ export const tools: McpTool[] = [
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
unit_price: row.unit_price,
|
||||
discount_percent: row.discount_percent ?? 0,
|
||||
line_total: row.line_total,
|
||||
vat_rate: row.vat_rate,
|
||||
revenue_account: row.revenue_account ?? null,
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
export const INVOICE_FULL_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, ore_rounding, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, payment_link_url, stripe_payment_link_id, payment_link_auto, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
|
||||
/**
|
||||
* Projection for the v1 PDF download route. Narrower than INVOICE_FULL_COLUMNS
|
||||
@@ -31,8 +31,8 @@ export const INVOICE_FULL_COLUMNS =
|
||||
export const INVOICE_PDF_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, document_type, ' +
|
||||
'currency, subtotal, vat_amount, total, ore_rounding, vat_treatment, vat_rate, moms_ruta, ' +
|
||||
'reverse_charge_text, your_reference, our_reference, notes, credited_invoice_id, ' +
|
||||
'reverse_charge_text, your_reference, our_reference, invoice_marking, notes, credited_invoice_id, ' +
|
||||
'paid_amount, remaining_amount, deduction_total, deduction_personnummer_last4'
|
||||
|
||||
export const INVOICE_ITEM_FULL_COLUMNS =
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
|
||||
@@ -1316,6 +1316,44 @@ describe('createInvoiceJournalEntry: ROT/RUT-avdrag', () => {
|
||||
expect(totalDebit).toBe(12500)
|
||||
})
|
||||
|
||||
it('discounted ROT line: 1513 books the NET-based deduction, matching the stored deduction_total', async () => {
|
||||
// 10 000 kr labor, 20% rabatt → net 8 000 + 25% VAT 2 000 = 10 000.
|
||||
// ROT = 30% of the NET inkl.-moms labor = 30% of 10 000 = 3 000, the same
|
||||
// figure build-invoice-write stores on deduction_total and the payout
|
||||
// request claims. Booking the gross (3 750) would strand 750 kr on 1513
|
||||
// and push kundfordringar (1510) negative when the customer pays 7 000.
|
||||
const invoice = makeInvoice({
|
||||
subtotal: 8000,
|
||||
vat_amount: 2000,
|
||||
total: 10000,
|
||||
vat_treatment: 'standard_25',
|
||||
deduction_total: 3000,
|
||||
items: [
|
||||
makeItem({
|
||||
quantity: 1,
|
||||
unit_price: 10000,
|
||||
discount_percent: 20,
|
||||
line_total: 8000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 2000,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 3000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const debit1513 = input.lines.find((l) => l.account_number === '1513')
|
||||
expect(debit1513?.debit_amount).toBe(3000)
|
||||
const debit1510 = input.lines.find((l) => l.account_number === '1510')
|
||||
expect(debit1510?.debit_amount).toBe(7000)
|
||||
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('mixed invoice: ROT line + non-deduction line, per-item handling', async () => {
|
||||
// ROT line 10 000 (deduction 30% of 12 500 inkl. moms = 3 750) +
|
||||
// non-deduction materials line 4 000.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -245,6 +245,9 @@ function buildSendLines(
|
||||
const deduction = computeDeduction({
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.quantity,
|
||||
// Net of any line discount: must match the stored deduction_total or
|
||||
// the proposed 1513/1510 split cannot clear.
|
||||
discount_percent: item.discount_percent ?? 0,
|
||||
deduction_type: item.deduction_type,
|
||||
vat_rate: item.vat_rate,
|
||||
})
|
||||
|
||||
@@ -52,4 +52,19 @@ describe('buildCreditNoteItem', () => {
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('carries discount_percent so the kreditfaktura face arithmetic multiplies out', () => {
|
||||
// Original: 2 x 1000 with 10% rabatt → net 1800. The credit row must keep
|
||||
// the discount, or -2 x 1000 next to Summa -1800 prints with no visible
|
||||
// prisnedsättning (ML 17 kap 24 §) and violates the stored net invariant.
|
||||
const result = buildCreditNoteItem('credit-1', item({ discount_percent: 10, line_total: 1800, vat_amount: 450 }))
|
||||
expect(result).toMatchObject({
|
||||
quantity: -2,
|
||||
discount_percent: 10,
|
||||
line_total: -1800,
|
||||
vat_amount: -450,
|
||||
})
|
||||
// Legacy rows without the column default to 0.
|
||||
expect(buildCreditNoteItem('credit-1', item()).discount_percent).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase, makeCustomer } from '@/tests/helpers'
|
||||
import { computeLineAmounts, computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts'
|
||||
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
|
||||
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
||||
|
||||
describe('computeLineAmounts', () => {
|
||||
it('passes qty * price through untouched when no discount applies', () => {
|
||||
// Legacy parity: existing invoices store the unrounded product.
|
||||
expect(computeLineAmounts(3, 33.333)).toEqual({
|
||||
gross: 3 * 33.333,
|
||||
discount: 0,
|
||||
net: 3 * 33.333,
|
||||
})
|
||||
expect(computeLineAmounts(2, 100, null)).toEqual({ gross: 200, discount: 0, net: 200 })
|
||||
expect(computeLineAmounts(2, 100, 0)).toEqual({ gross: 200, discount: 0, net: 200 })
|
||||
})
|
||||
|
||||
it('computes discount and net in exact ore arithmetic', () => {
|
||||
expect(computeLineAmounts(2, 100, 10)).toEqual({ gross: 200, discount: 20, net: 180 })
|
||||
// 1 * 99.99 at 33%: gross 99.99, discount round(32.9967) = 33.00, net 66.99.
|
||||
expect(computeLineAmounts(1, 99.99, 33)).toEqual({ gross: 99.99, discount: 33, net: 66.99 })
|
||||
// gross - discount is always exact: net + discount reconstructs gross.
|
||||
const amounts = computeLineAmounts(7, 123.45, 12.5)
|
||||
expect(amounts.net + amounts.discount).toBeCloseTo(amounts.gross, 10)
|
||||
})
|
||||
|
||||
it('handles a 100% discount as a zero net line', () => {
|
||||
expect(computeLineAmounts(4, 250, 100)).toEqual({ gross: 1000, discount: 1000, net: 0 })
|
||||
expect(computeLineNet(4, 250, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('hasLineDiscount treats null/undefined/0 as no discount', () => {
|
||||
expect(hasLineDiscount(undefined)).toBe(false)
|
||||
expect(hasLineDiscount(null)).toBe(false)
|
||||
expect(hasLineDiscount(0)).toBe(false)
|
||||
expect(hasLineDiscount(0.5)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDeduction with a line discount', () => {
|
||||
it('deducts on the net line total (what the customer pays)', () => {
|
||||
// 10 tim * 1000 = 10 000, 10% rabatt -> 9 000 net, incl VAT 11 250,
|
||||
// ROT 30% = 3 375 (vs 3 750 undiscounted).
|
||||
expect(
|
||||
computeDeduction({
|
||||
unit_price: 1000,
|
||||
quantity: 10,
|
||||
discount_percent: 10,
|
||||
deduction_type: 'rot',
|
||||
vat_rate: 25,
|
||||
}),
|
||||
).toBe(3375)
|
||||
expect(
|
||||
computeDeduction({ unit_price: 1000, quantity: 10, deduction_type: 'rot', vat_rate: 25 }),
|
||||
).toBe(3750)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInvoiceWriteData with per-line discount and invoice_marking', () => {
|
||||
const baseHeader = {
|
||||
customer_id: 'customer-1',
|
||||
invoice_date: '2026-06-15',
|
||||
due_date: '2026-07-15',
|
||||
currency: 'SEK' as const,
|
||||
}
|
||||
|
||||
it('stores net line totals, VAT on the net, and the discount on the row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
const customer = makeCustomer({ customer_type: 'swedish_business' })
|
||||
const result = await buildInvoiceWriteData({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
companyId: 'company-1',
|
||||
customer,
|
||||
documentType: 'invoice',
|
||||
input: {
|
||||
...baseHeader,
|
||||
items: [
|
||||
{ description: 'Konsult', quantity: 10, unit: 'tim', unit_price: 1000, vat_rate: 25, discount_percent: 10 },
|
||||
{ description: 'Resa', quantity: 1, unit: 'st', unit_price: 500, vat_rate: 25 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
// 10 000 - 10% = 9 000 net + 500 undiscounted.
|
||||
expect(result.invoiceFields.subtotal).toBe(9500)
|
||||
expect(result.invoiceFields.vat_amount).toBe(2375)
|
||||
expect(result.invoiceFields.total).toBe(11875)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
discount_percent: 10,
|
||||
line_total: 9000,
|
||||
vat_amount: 2250,
|
||||
unit_price: 1000,
|
||||
})
|
||||
expect(result.items[1]).toMatchObject({ discount_percent: 0, line_total: 500 })
|
||||
})
|
||||
|
||||
it('maps invoice_marking to a concrete trimmed value, null when absent or blank', async () => {
|
||||
const customer = makeCustomer({ customer_type: 'swedish_business' })
|
||||
const items = [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 100, vat_rate: 25 }]
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
const withMarking = await buildInvoiceWriteData({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
companyId: 'company-1',
|
||||
customer,
|
||||
documentType: 'invoice',
|
||||
input: { ...baseHeader, invoice_marking: ' KST 4711 ', items },
|
||||
})
|
||||
expect(withMarking.ok).toBe(true)
|
||||
if (!withMarking.ok) return
|
||||
expect(withMarking.invoiceFields.invoice_marking).toBe('KST 4711')
|
||||
|
||||
// Absent/blank input must produce an explicit null (supabase-js drops
|
||||
// undefined keys, and a draft edit that cleared the field relies on NULL
|
||||
// actually being written).
|
||||
const { supabase: supabase2, enqueue: enqueue2 } = createQueuedMockSupabase()
|
||||
enqueue2({ data: { vat_registered: true }, error: null })
|
||||
const withoutMarking = await buildInvoiceWriteData({
|
||||
supabase: supabase2 as unknown as SupabaseClient,
|
||||
companyId: 'company-1',
|
||||
customer,
|
||||
documentType: 'invoice',
|
||||
input: { ...baseHeader, invoice_marking: ' ', items },
|
||||
})
|
||||
expect(withoutMarking.ok).toBe(true)
|
||||
if (!withoutMarking.ok) return
|
||||
expect(withoutMarking.invoiceFields.invoice_marking).toBeNull()
|
||||
})
|
||||
|
||||
it('computes the ROT deduction on the discounted line total', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
const customer = makeCustomer({ customer_type: 'individual' })
|
||||
const result = await buildInvoiceWriteData({
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
companyId: 'company-1',
|
||||
customer,
|
||||
documentType: 'invoice',
|
||||
input: {
|
||||
...baseHeader,
|
||||
deduction_personnummer: '199001019802',
|
||||
deduction_housing_designation: 'Testbrand 1:1',
|
||||
items: [
|
||||
{
|
||||
description: 'Renovering arbete',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
vat_rate: 25,
|
||||
discount_percent: 10,
|
||||
deduction_type: 'rot',
|
||||
work_type: 'BYGG',
|
||||
labor_hours: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
// Net 9 000 excl VAT -> 11 250 incl VAT -> ROT 30% = 3 375.
|
||||
expect(result.items[0].deduction_amount).toBe(3375)
|
||||
expect(result.invoiceFields.deduction_total).toBe(3375)
|
||||
})
|
||||
})
|
||||
@@ -259,6 +259,71 @@ describe('generatePeppolBisBillingInvoice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers invoice_marking over your_reference for BT-10 BuyerReference', () => {
|
||||
const input = makeValidInput()
|
||||
input.invoice = makeInvoice({ ...input.invoice, invoice_marking: 'KST 4711' })
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:BuyerReference>KST 4711</cbc:BuyerReference>')
|
||||
})
|
||||
|
||||
it('accepts a marking-only invoice (no your_reference) as buyer reference', () => {
|
||||
const input = makeValidInput()
|
||||
input.invoice = makeInvoice({
|
||||
...input.invoice,
|
||||
your_reference: null,
|
||||
invoice_marking: 'PO-2026-17',
|
||||
})
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:BuyerReference>PO-2026-17</cbc:BuyerReference>')
|
||||
})
|
||||
|
||||
it('renders a per-line discount as a BG-27 allowance with net LineExtensionAmount', () => {
|
||||
const input = makeValidInput()
|
||||
// 2 × 100 = 200 gross, 10% discount = 20, net 180, VAT 25% on net = 45.
|
||||
input.items = [
|
||||
makeItem({ discount_percent: 10, line_total: 180, vat_amount: 45 }),
|
||||
]
|
||||
input.invoice = makeInvoice({
|
||||
...input.invoice,
|
||||
subtotal: 180,
|
||||
vat_amount: 45,
|
||||
total: 225,
|
||||
remaining_amount: 225,
|
||||
})
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.xml).toContain('<cbc:LineExtensionAmount currencyID="SEK">180.00</cbc:LineExtensionAmount>')
|
||||
expect(result.xml).toContain('<cbc:ChargeIndicator>false</cbc:ChargeIndicator>')
|
||||
expect(result.xml).toContain('<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>')
|
||||
expect(result.xml).toContain('<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>')
|
||||
expect(result.xml).toContain('<cbc:Amount currencyID="SEK">20.00</cbc:Amount>')
|
||||
expect(result.xml).toContain('<cbc:BaseAmount currencyID="SEK">200.00</cbc:BaseAmount>')
|
||||
// The undiscounted unit price stays in cac:Price (BT-146).
|
||||
expect(result.xml).toContain('<cbc:PriceAmount currencyID="SEK">100</cbc:PriceAmount>')
|
||||
})
|
||||
|
||||
it('rejects a discounted line whose stored total is not net of the discount', () => {
|
||||
const input = makeValidInput()
|
||||
input.items = [makeItem({ discount_percent: 10, line_total: 200, vat_amount: 50 })]
|
||||
|
||||
const result = generatePeppolBisBillingInvoice(input)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.issues.map(({ code }) => code)).toContain('LINE_TOTAL_MISMATCH')
|
||||
})
|
||||
|
||||
it('rejects credit notes and self-billed invoices in the generation layer', () => {
|
||||
for (const invoice of [
|
||||
makeInvoice({
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeItem(overrides: Partial<InvoiceWriteItemRow> = {}): InvoiceWriteIte
|
||||
quantity: 1,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
discount_percent: 0,
|
||||
line_total: 1000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -55,6 +55,7 @@ const LABELS = {
|
||||
deliveryDate: 'Leveransdatum:',
|
||||
yourReference: 'Er referens:',
|
||||
ourReference: 'Vår referens:',
|
||||
invoiceMarking: 'Märkning:',
|
||||
// Customer box
|
||||
custNo: 'Kundnr:',
|
||||
orgNo: 'Org.nr:',
|
||||
@@ -64,6 +65,7 @@ const LABELS = {
|
||||
colQty: 'Antal',
|
||||
colUnit: 'Enhet',
|
||||
colUnitPrice: 'à-pris',
|
||||
colDiscount: 'Rabatt',
|
||||
colVat: 'Moms',
|
||||
colTotal: 'Summa',
|
||||
// Totals
|
||||
@@ -136,6 +138,7 @@ const LABELS = {
|
||||
deliveryDate: 'Delivery date:',
|
||||
yourReference: 'Your reference:',
|
||||
ourReference: 'Our reference:',
|
||||
invoiceMarking: 'Buyer reference:',
|
||||
custNo: 'Customer no.:',
|
||||
orgNo: 'Reg. no.:',
|
||||
vat: 'VAT:',
|
||||
@@ -143,6 +146,7 @@ const LABELS = {
|
||||
colQty: 'Qty',
|
||||
colUnit: 'Unit',
|
||||
colUnitPrice: 'Unit price',
|
||||
colDiscount: 'Discount',
|
||||
colVat: 'VAT',
|
||||
colTotal: 'Amount',
|
||||
subtotal: 'Subtotal:',
|
||||
@@ -367,6 +371,10 @@ function createStyles(branding?: InvoiceBranding) {
|
||||
flex: 1.5,
|
||||
textAlign: 'right',
|
||||
},
|
||||
colDiscount: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
colVat: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
@@ -785,6 +793,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
? new Set(billableItems.map((item) => item.vat_rate))
|
||||
: new Set<number>()
|
||||
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
|
||||
// Rabatt column only when some line actually carries a discount: the
|
||||
// stored line_total is already net, so the column documents the reduction
|
||||
// (ML 17 kap 24 § p.10: prisnedsättning ska framgå av fakturan).
|
||||
const showDiscountColumn = billableItems.some((item) => (item.discount_percent ?? 0) > 0)
|
||||
|
||||
// Calculate per-rate VAT breakdown for totals
|
||||
const vatByRate = hasPerLineVat
|
||||
@@ -950,6 +962,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Fakturamärkning: one buyer-required marking string, never
|
||||
comma-split (a PO/cost-center label may contain commas). */}
|
||||
{invoice.invoice_marking && (
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={styles.label}>{L.invoiceMarking}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
|
||||
<Text style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
|
||||
{invoice.invoice_marking.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Customer */}
|
||||
@@ -1003,6 +1027,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{!isDeliveryNote && (
|
||||
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showDiscountColumn && (
|
||||
<Text style={[styles.colDiscount, styles.tableHeaderText]}>{L.colDiscount}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
|
||||
)}
|
||||
@@ -1029,6 +1056,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{!isDeliveryNote && (
|
||||
<Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showDiscountColumn && (
|
||||
<Text style={styles.colDiscount}>
|
||||
{(item.discount_percent ?? 0) > 0 ? `${item.discount_percent}%` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{!isDeliveryNote && showVatColumn && (
|
||||
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
validatePlusgiroNumber,
|
||||
} from '@/lib/bankgiro/luhn'
|
||||
import { isSaneDateString, normalizeOrgNumber } from '@/lib/invariants'
|
||||
import { computeLineAmounts, hasLineDiscount } from '@/lib/invoices/line-amounts'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { equalOre, roundOre } from '@/lib/money'
|
||||
import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types'
|
||||
@@ -270,11 +271,13 @@ function prepareInvoice(input: PeppolInvoiceInput):
|
||||
'Invoice, due, and delivery dates must be valid dates.',
|
||||
))
|
||||
}
|
||||
if (!hasText(invoice.your_reference)) {
|
||||
// BT-10 BuyerReference: fakturamärkning wins when set (SFTI convention:
|
||||
// the buyer's routing/marking string), else Er referens.
|
||||
if (!hasText(invoice.invoice_marking) && !hasText(invoice.your_reference)) {
|
||||
issues.push(validationIssue(
|
||||
'BUYER_REFERENCE_REQUIRED', 'invoice.your_reference',
|
||||
'Er referens krävs för Peppol när inköpsordernummer saknas.',
|
||||
'Buyer reference is required for Peppol when no purchase order reference is available.',
|
||||
'Märkning eller Er referens krävs för Peppol när inköpsordernummer saknas.',
|
||||
'A marking or buyer reference is required for Peppol when no purchase order reference is available.',
|
||||
))
|
||||
}
|
||||
if ((invoice.deduction_total ?? 0) !== 0) {
|
||||
@@ -396,11 +399,22 @@ function prepareInvoice(input: PeppolInvoiceInput):
|
||||
`The VAT rate on invoice line ${index + 1} must be 6, 12, or 25 percent.`,
|
||||
))
|
||||
}
|
||||
if (!equalMoney(item.line_total, roundMoney(item.quantity * item.unit_price))) {
|
||||
// Net of any line discount: line_total must equal (qty × price) − rabatt,
|
||||
// the same exact öre arithmetic the write path stores
|
||||
// (lib/invoices/line-amounts.ts) and the BG-27 allowance below renders.
|
||||
const expectedAmounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
|
||||
if (!equalMoney(item.line_total, roundMoney(expectedAmounts.net))) {
|
||||
issues.push(validationIssue(
|
||||
'LINE_TOTAL_MISMATCH', `${lineField}.line_total`,
|
||||
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris.`,
|
||||
`The amount on invoice line ${index + 1} does not equal quantity times price.`,
|
||||
`Beloppet på fakturarad ${index + 1} stämmer inte med antal gånger pris minus rabatt.`,
|
||||
`The amount on invoice line ${index + 1} does not equal quantity times price less discount.`,
|
||||
))
|
||||
}
|
||||
if (item.discount_percent !== undefined && (item.discount_percent < 0 || item.discount_percent > 100)) {
|
||||
issues.push(validationIssue(
|
||||
'LINE_DISCOUNT_INVALID', `${lineField}.discount_percent`,
|
||||
`Rabatten på fakturarad ${index + 1} måste vara mellan 0 och 100 procent.`,
|
||||
`The discount on invoice line ${index + 1} must be between 0 and 100 percent.`,
|
||||
))
|
||||
}
|
||||
if (!equalMoney(item.vat_amount, roundMoney(item.line_total * item.vat_rate / 100))) {
|
||||
@@ -552,11 +566,29 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
|
||||
' </cac:TaxCategory>',
|
||||
' </cac:TaxSubtotal>',
|
||||
])
|
||||
const invoiceLines = prepared.productItems.flatMap((item, index) => [
|
||||
const invoiceLines = prepared.productItems.flatMap((item, index) => {
|
||||
// Line discount as a BG-27 allowance: LineExtensionAmount stays the net
|
||||
// line_total and the allowance documents base − amount = net exactly
|
||||
// (the amounts come from the same öre arithmetic as the stored total).
|
||||
const amounts = computeLineAmounts(item.quantity, item.unit_price, item.discount_percent)
|
||||
const allowance = hasLineDiscount(item.discount_percent)
|
||||
? [
|
||||
' <cac:AllowanceCharge>',
|
||||
' <cbc:ChargeIndicator>false</cbc:ChargeIndicator>',
|
||||
' <cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>',
|
||||
' <cbc:AllowanceChargeReason>Rabatt</cbc:AllowanceChargeReason>',
|
||||
` <cbc:MultiplierFactorNumeric>${formatDecimal(item.discount_percent as number)}</cbc:MultiplierFactorNumeric>`,
|
||||
` <cbc:Amount currencyID="SEK">${formatMoney(amounts.discount)}</cbc:Amount>`,
|
||||
` <cbc:BaseAmount currencyID="SEK">${formatMoney(amounts.gross)}</cbc:BaseAmount>`,
|
||||
' </cac:AllowanceCharge>',
|
||||
]
|
||||
: []
|
||||
return [
|
||||
' <cac:InvoiceLine>',
|
||||
` <cbc:ID>${index + 1}</cbc:ID>`,
|
||||
` <cbc:InvoicedQuantity unitCode="${UNIT_CODES[item.unit]}">${formatDecimal(item.quantity)}</cbc:InvoicedQuantity>`,
|
||||
` <cbc:LineExtensionAmount currencyID="SEK">${formatMoney(item.line_total)}</cbc:LineExtensionAmount>`,
|
||||
...allowance,
|
||||
' <cac:Item>',
|
||||
` <cbc:Name>${escapeXml(item.description.trim())}</cbc:Name>`,
|
||||
' <cac:ClassifiedTaxCategory>',
|
||||
@@ -569,7 +601,8 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
|
||||
` <cbc:PriceAmount currencyID="SEK">${formatDecimal(item.unit_price)}</cbc:PriceAmount>`,
|
||||
' </cac:Price>',
|
||||
' </cac:InvoiceLine>',
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -584,7 +617,7 @@ function renderInvoiceXml(input: PeppolInvoiceInput, prepared: PreparedInvoice):
|
||||
' <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>',
|
||||
invoice.notes ? ` <cbc:Note>${escapeXml(invoice.notes)}</cbc:Note>` : null,
|
||||
' <cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>',
|
||||
` <cbc:BuyerReference>${escapeXml(invoice.your_reference?.trim() ?? '')}</cbc:BuyerReference>`,
|
||||
` <cbc:BuyerReference>${escapeXml((invoice.invoice_marking?.trim() || invoice.your_reference?.trim()) ?? '')}</cbc:BuyerReference>`,
|
||||
renderParty('AccountingSupplierParty', prepared.supplier, company.f_skatt),
|
||||
renderParty('AccountingCustomerParty', prepared.buyer, false),
|
||||
invoice.delivery_date
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,7 @@ import {
|
||||
type InvoiceWriteInput,
|
||||
type InvoiceWriteItemInput,
|
||||
} from '@/lib/invoices/build-invoice-write'
|
||||
import { computeLineNet } from '@/lib/invoices/line-amounts'
|
||||
import { deleteDraftInvoice } from '@/lib/invoices/delete-draft-invoice'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items'
|
||||
@@ -1631,6 +1632,7 @@ async function commitCreateInvoice(
|
||||
const customerId = params.customer_id as string
|
||||
const items = params.items as Array<{
|
||||
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
|
||||
discount_percent?: number | null
|
||||
article_id?: string | null; revenue_account?: string | null
|
||||
line_type?: 'product' | 'text'
|
||||
dimensions?: Record<string, string>
|
||||
@@ -1674,7 +1676,12 @@ async function commitCreateInvoice(
|
||||
const notVatRegistered = vatSettings?.vat_registered === false
|
||||
if (notVatRegistered) for (const item of items) item.vat_rate = 0
|
||||
|
||||
const subtotal = billableItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
// Line totals net of any per-line discount, same math as the web path
|
||||
// (lib/invoices/line-amounts.ts).
|
||||
const subtotal = billableItems.reduce(
|
||||
(sum, item) => sum + computeLineNet(item.quantity, item.unit_price, item.discount_percent),
|
||||
0,
|
||||
)
|
||||
|
||||
let vatAmount = 0
|
||||
for (const item of billableItems) {
|
||||
@@ -1682,7 +1689,14 @@ async function commitCreateInvoice(
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
// Strict typeof: staged params are JSON a tampered client could shape;
|
||||
// a string would coerce past a bare range check but be ignored by the
|
||||
// number-typed totals math, then land in the NUMERIC column anyway.
|
||||
const discountPercent = item.discount_percent ?? 0
|
||||
if (typeof discountPercent !== 'number' || !(discountPercent >= 0 && discountPercent <= 100)) {
|
||||
return { error: 'Rabatten per rad måste vara mellan 0 och 100 procent', status: 400 }
|
||||
}
|
||||
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
|
||||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
}
|
||||
|
||||
@@ -1824,6 +1838,7 @@ async function commitCreateInvoice(
|
||||
reverse_charge_text: notVatRegistered ? null : (vatRules.reverseChargeText || null),
|
||||
our_reference: (params.our_reference as string) || null,
|
||||
your_reference: (params.your_reference as string) || null,
|
||||
invoice_marking: (params.invoice_marking as string) || null,
|
||||
notes: (params.notes as string) || null,
|
||||
payment_link_url: paymentLinkUrl,
|
||||
default_dimensions: defaultDimensions ?? {},
|
||||
@@ -1846,6 +1861,7 @@ async function commitCreateInvoice(
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
discount_percent: 0,
|
||||
line_total: 0,
|
||||
vat_rate: 0,
|
||||
vat_amount: 0,
|
||||
@@ -1855,7 +1871,8 @@ async function commitCreateInvoice(
|
||||
}
|
||||
}
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const discountPercent = item.discount_percent ?? 0
|
||||
const lineTotal = computeLineNet(item.quantity, item.unit_price, discountPercent)
|
||||
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
@@ -1865,6 +1882,7 @@ async function commitCreateInvoice(
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
discount_percent: discountPercent,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
@@ -1942,7 +1960,7 @@ async function commitUpdateInvoice(
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
|
||||
'id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id, customer_id, document_type, invoice_date, due_date, delivery_date, currency, your_reference, our_reference, invoice_marking, notes, payment_link_url, payment_link_auto, ore_rounding, default_dimensions, deduction_personnummer_encrypted, deduction_personnummer_last4',
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
@@ -2003,7 +2021,7 @@ async function commitUpdateInvoice(
|
||||
const { data: itemRows, error: itemsFetchError } = await supabase
|
||||
.from('invoice_items')
|
||||
.select(
|
||||
'line_type, description, quantity, unit, unit_price, vat_rate, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions',
|
||||
'line_type, description, quantity, unit, unit_price, discount_percent, vat_rate, article_id, revenue_account, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions',
|
||||
)
|
||||
.eq('invoice_id', invoiceId)
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -2028,6 +2046,7 @@ async function commitUpdateInvoice(
|
||||
currency: existing.currency as Currency,
|
||||
your_reference: changes.your_reference ?? existing.your_reference ?? undefined,
|
||||
our_reference: changes.our_reference ?? existing.our_reference ?? undefined,
|
||||
invoice_marking: changes.invoice_marking ?? existing.invoice_marking ?? undefined,
|
||||
notes: changes.notes ?? existing.notes ?? undefined,
|
||||
// Not editable through this operation: fed back so the builder echoes the
|
||||
// stored values instead of clearing them.
|
||||
@@ -4553,6 +4572,7 @@ async function commitCreditInvoice(
|
||||
reverse_charge_text: original.reverse_charge_text,
|
||||
your_reference: original.your_reference,
|
||||
our_reference: original.our_reference,
|
||||
invoice_marking: original.invoice_marking ?? null,
|
||||
notes: reason || `Krediterar faktura ${original.invoice_number}`,
|
||||
credited_invoice_id: id,
|
||||
// Dimensions PR7: copy so the reversal nets against the same cells.
|
||||
@@ -4573,6 +4593,7 @@ async function commitCreditInvoice(
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
discount_percent?: number | null
|
||||
line_total: number
|
||||
vat_rate?: number
|
||||
vat_amount?: number
|
||||
@@ -4587,6 +4608,8 @@ async function commitCreditInvoice(
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
// Kreditfakturans face arithmetic must multiply out like the original's.
|
||||
discount_percent: item.discount_percent ?? 0,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 0,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
|
||||
@@ -23,6 +23,8 @@ const InvoiceChangesSchema = z
|
||||
delivery_date: isoDate.nullable().optional(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
// Fakturamärkning: buyer-required marking, separate from your_reference.
|
||||
invoice_marking: z.string().max(200).optional(),
|
||||
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required').optional(),
|
||||
// Replaces the whole bag; {} clears all tags.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -110,6 +110,7 @@ Request body:
|
||||
document_type?: "invoice" | "proforma" | "delivery_note",
|
||||
your_reference?: string,
|
||||
our_reference?: string,
|
||||
invoice_marking?: string,
|
||||
notes?: string,
|
||||
payment_link_url?: string | "",
|
||||
payment_link_auto?: boolean,
|
||||
@@ -124,7 +125,7 @@ Request body:
|
||||
external_invoice_number?: string | "",
|
||||
self_billing_agreement_ref?: string,
|
||||
received_date?: string | "",
|
||||
items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -305,7 +306,7 @@ Request body:
|
||||
our_reference?: string | unknown,
|
||||
notes?: string | unknown,
|
||||
default_dimensions?: Record<string, string>,
|
||||
items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -776,7 +777,7 @@ Bulk-creation endpoint. Each invoice in the request array is validated and inser
|
||||
Request body:
|
||||
```ts
|
||||
{
|
||||
invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record<string, string>, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[] }[],
|
||||
invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, invoice_marking?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record<string, string>, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, discount_percent?: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[] }[],
|
||||
all_or_nothing?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Per-line percentage discount (rabatt i procent per artikelrad) and a
|
||||
-- fakturamärkning field separate from Er referens (your_reference).
|
||||
--
|
||||
-- invoice_items.discount_percent: 0-100, default 0. The stored line_total is
|
||||
-- always the NET amount (after discount); VAT is computed on the net, so the
|
||||
-- bookkeeping generators need no change. Server code recomputes the discount
|
||||
-- via lib/invoices/line-amounts.ts and never trusts a client-sent total.
|
||||
--
|
||||
-- invoices.invoice_marking: the buyer-required marking (kostnadsstalle,
|
||||
-- project code, PO label) printed on the invoice and mapped to Peppol BT-10
|
||||
-- BuyerReference when set. Distinct from your_reference, which stays the
|
||||
-- contact person (Er referens).
|
||||
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS discount_percent NUMERIC NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_discount_percent_check;
|
||||
ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_discount_percent_check
|
||||
CHECK (discount_percent >= 0 AND discount_percent <= 100);
|
||||
|
||||
COMMENT ON COLUMN public.invoice_items.discount_percent IS
|
||||
'Percentage discount on the line (0-100). line_total and vat_amount are stored net of this discount.';
|
||||
|
||||
ALTER TABLE public.invoices
|
||||
ADD COLUMN IF NOT EXISTS invoice_marking TEXT;
|
||||
|
||||
COMMENT ON COLUMN public.invoices.invoice_marking IS
|
||||
'Fakturamarkning: buyer-required marking (cost center/project/PO), separate from your_reference (Er referens). Feeds Peppol BT-10 BuyerReference when set.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+15
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user