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:
co-authored by
Claude Fable 5
parent
84c8e1ce59
commit
f216a60bf8
+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(),
|
||||
|
||||
Reference in New Issue
Block a user