import { describe, expect, it } from 'vitest' import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers' import type { InvoiceItem } from '@/types' import { generatePeppolBisBillingInvoice, PEPPOL_BIS_BILLING_CUSTOMIZATION_ID, PEPPOL_BIS_BILLING_PROFILE_ID, } from '../peppol-bis-billing' function makeItem(overrides: Partial = {}): InvoiceItem { return { id: 'item-1', invoice_id: 'invoice-1', sort_order: 0, line_type: 'product', description: 'Rådgivning', quantity: 2, unit: 'tim', unit_price: 100, line_total: 200, vat_rate: 25, vat_amount: 50, ...overrides, } } function makeValidInput() { const customer = makeCustomer({ name: 'Kund & Partner AB', org_number: '556677-8899', vat_number: 'SE556677889901', }) const company = makeCompanySettings({ company_name: 'Säljare AB', entity_type: 'aktiebolag', org_number: '556016-0680', vat_number: 'SE556016068001', bankgiro: '991-2346', ore_rounding: true, }) const items = [ makeItem(), makeItem({ id: 'item-2', sort_order: 1, description: 'Lunch', quantity: 1, unit: 'st', unit_price: 100, line_total: 100, vat_rate: 12, vat_amount: 12, }), ] const invoice = makeInvoice({ id: 'invoice-1', invoice_number: 'F-2026-42', invoice_date: '2026-08-13', due_date: '2026-09-12', delivery_date: '2026-08-12', status: 'sent', subtotal: 300, vat_amount: 62, total: 362, remaining_amount: 362, vat_treatment: 'standard_25', your_reference: 'REF & 42', notes: 'Tack ', }) return { invoice, customer, company, items } } describe('generatePeppolBisBillingInvoice', () => { it('generates a Swedish Peppol BIS Billing 3 invoice with reconciled VAT groups', () => { const result = generatePeppolBisBillingInvoice(makeValidInput()) expect(result.ok).toBe(true) if (!result.ok) return expect(result.filename).toBe('peppol-invoice-F-2026-42.xml') expect(result.xml).toContain(`${PEPPOL_BIS_BILLING_CUSTOMIZATION_ID}`) expect(result.xml).toContain(`${PEPPOL_BIS_BILLING_PROFILE_ID}`) expect(result.xml).toContain('5560160680') expect(result.xml).toContain('5566778899') expect(result.xml).toContain('Godkänd för F-skatt') expect(result.xml).toContain('30') expect(result.xml).toContain('SE:BANKGIRO') expect(result.xml).toContain('100.00') expect(result.xml).toContain('200.00') expect(result.xml).toContain('62.00') expect(result.xml).toContain('362.00') expect(result.xml).toContain('2') }) it('escapes user-controlled XML values without damaging Swedish text', () => { const result = generatePeppolBisBillingInvoice(makeValidInput()) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('Säljare <Sverige> AB') expect(result.xml).toContain('Kund & Partner AB') expect(result.xml).toContain('REF & 42') expect(result.xml).toContain('Tack <igen>') }) it('expresses öresavrundning as PayableRoundingAmount', () => { const input = makeValidInput() input.items = [makeItem({ quantity: 1, unit_price: 80.4, line_total: 80.4, vat_amount: 20.1 })] input.invoice = makeInvoice({ ...input.invoice, subtotal: 80.4, vat_amount: 20.1, total: 100.5, remaining_amount: 101, }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('100.50') expect(result.xml).toContain('0.50') expect(result.xml).toContain('101.00') }) it('rejects invoice data outside the supported profile', () => { const input = makeValidInput() input.invoice = makeInvoice({ ...input.invoice, invoice_number: null, currency: 'EUR', your_reference: null, deduction_total: 10, }) input.customer = makeCustomer({ ...input.customer, customer_type: 'individual' }) input.company = makeCompanySettings({ ...input.company, bankgiro: null }) input.items = [makeItem({ unit: 'paket', vat_rate: 0, vat_amount: 0 })] const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toEqual(expect.arrayContaining([ 'INVOICE_NUMBER_REQUIRED', 'CURRENCY_UNSUPPORTED', 'BUYER_REFERENCE_REQUIRED', 'DEDUCTION_UNSUPPORTED', 'BUYER_TYPE_UNSUPPORTED', 'PAYMENT_ACCOUNT_REQUIRED', 'UNIT_UNSUPPORTED', 'VAT_RATE_UNSUPPORTED', ])) }) it('rejects VAT rounding that conflicts with EN 16931 category rounding', () => { const input = makeValidInput() input.items = [ makeItem({ id: 'item-1', quantity: 1, unit_price: 0.01, line_total: 0.01, vat_amount: 0 }), makeItem({ id: 'item-2', sort_order: 1, quantity: 1, unit_price: 0.01, line_total: 0.01, vat_amount: 0 }), ] input.invoice = makeInvoice({ ...input.invoice, subtotal: 0.02, vat_amount: 0, total: 0.02, remaining_amount: 0.02, ore_rounding: false, }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain('VAT_ROUNDING_MISMATCH') }) it('rejects invoice totals that do not reconcile to the emitted lines', () => { const input = makeValidInput() input.invoice = makeInvoice({ ...input.invoice, total: 999 }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain('INVOICE_TOTALS_MISMATCH') }) it('requires the seller registered VAT identifier instead of deriving one', () => { const input = makeValidInput() input.company = makeCompanySettings({ ...input.company, vat_number: null }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain('SUPPLIER_VAT_NUMBER_REQUIRED') }) it('validates a Swedish VAT identifier without assuming it is derived from the org number', () => { const input = makeValidInput() input.company = makeCompanySettings({ ...input.company, vat_number: 'SE123456789012' }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('SE123456789012') }) it('rejects a malformed Swedish seller VAT identifier', () => { const input = makeValidInput() input.company = makeCompanySettings({ ...input.company, vat_number: 'SE5560160680' }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain('SUPPLIER_VAT_NUMBER_INVALID') }) it('accepts the 12-digit organization-number form used by Swedish systems', () => { const input = makeValidInput() input.company = makeCompanySettings({ ...input.company, org_number: '165560160680' }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('5560160680') }) it('rejects a sole trader until a non-personal GLN participant ID can be configured', () => { const input = makeValidInput() input.company = makeCompanySettings({ ...input.company, entity_type: 'enskild_firma', org_number: '800101-1231', }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toEqual(expect.arrayContaining([ 'SUPPLIER_ENTITY_TYPE_UNSUPPORTED', 'SUPPLIER_PARTICIPANT_IDENTIFIER_UNSUPPORTED', ])) }) it('rejects a personnummer-only buyer instead of labeling it as scheme 0007', () => { const input = makeValidInput() input.customer = makeCustomer({ ...input.customer, org_number: '800101-1231' }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain( 'BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED', ) }) it('prefers invoice_marking over your_reference for BT-10 BuyerReference', () => { const input = makeValidInput() input.invoice = makeInvoice({ ...input.invoice, invoice_marking: 'KST 4711' }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('KST 4711') }) it('accepts a marking-only invoice (no your_reference) as buyer reference', () => { const input = makeValidInput() input.invoice = makeInvoice({ ...input.invoice, your_reference: null, invoice_marking: 'PO-2026-17', }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('PO-2026-17') }) it('renders a per-line discount as a BG-27 allowance with net LineExtensionAmount', () => { const input = makeValidInput() // 2 × 100 = 200 gross, 10% discount = 20, net 180, VAT 25% on net = 45. input.items = [ makeItem({ discount_percent: 10, line_total: 180, vat_amount: 45 }), ] input.invoice = makeInvoice({ ...input.invoice, subtotal: 180, vat_amount: 45, total: 225, remaining_amount: 225, }) const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(true) if (!result.ok) return expect(result.xml).toContain('180.00') expect(result.xml).toContain('false') expect(result.xml).toContain('95') expect(result.xml).toContain('10') expect(result.xml).toContain('20.00') expect(result.xml).toContain('200.00') // The undiscounted unit price stays in cac:Price (BT-146). expect(result.xml).toContain('100') }) it('rejects a discounted line whose stored total is not net of the discount', () => { const input = makeValidInput() input.items = [makeItem({ discount_percent: 10, line_total: 200, vat_amount: 50 })] const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) return expect(result.issues.map(({ code }) => code)).toContain('LINE_TOTAL_MISMATCH') }) it('rejects credit notes and self-billed invoices in the generation layer', () => { for (const invoice of [ makeInvoice({ ...makeValidInput().invoice, credited_invoice_id: '22222222-2222-4222-8222-222222222222', }), makeInvoice({ ...makeValidInput().invoice, is_self_billed: true }), ]) { const input = makeValidInput() input.invoice = invoice const result = generatePeppolBisBillingInvoice(input) expect(result.ok).toBe(false) if (result.ok) continue expect(result.issues.map(({ code }) => code)).toContain('DOCUMENT_TYPE_UNSUPPORTED') } }) })