import { describe, it, expect } from 'vitest' import { generateInvoiceEmailHtml, generateInvoiceEmailText, generateInvoiceEmailSubject, } from '../invoice-templates' import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers' const company = makeCompanySettings({ company_name: 'Acme AB', bank_name: 'SEB', clearing_number: '5000', account_number: '1234567', iban: 'SE45 5000 0000 0583 9825 7466', bic: 'ESSESESS', org_number: '556677-8899', vat_number: 'SE556677889901', f_skatt: true, }) const invoice = makeInvoice({ invoice_number: '1042', invoice_date: '2026-05-22', due_date: '2026-06-21', currency: 'SEK', total: 12500, }) describe('invoice email templates', () => { describe('Swedish customer (default)', () => { const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) const data = { invoice, customer, company } it('uses Swedish chrome in HTML', () => { const html = generateInvoiceEmailHtml(data) expect(html).toContain('') expect(html).toContain('Faktura från Acme AB') expect(html).toContain('Att betala:') expect(html).toContain('Betalningsinformation') expect(html).toContain('Hej Erik,') expect(html).toContain('Med vänliga hälsningar,') expect(html).toContain('Innehar F-skattsedel') }) it('renders the total with explicit SEK code, not "kr"', () => { const html = generateInvoiceEmailHtml(data) // sv-SE digit grouping: "12 500,00 SEK" expect(html).toMatch(/12[\s\u00a0]500,00 SEK/) expect(html).not.toContain('kr') }) it('uses Swedish subject', () => { expect(generateInvoiceEmailSubject(data)).toBe('Faktura 1042 från Acme AB') }) it('uses Swedish plain text body', () => { const text = generateInvoiceEmailText(data) expect(text).toContain('Hej Erik,') expect(text).toContain('Att betala:') expect(text).toContain('Förfallodatum:') expect(text).not.toContain('kr') }) }) describe('English customer', () => { const customer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' }) const data = { invoice, customer, company } it('uses English chrome in HTML', () => { const html = generateInvoiceEmailHtml(data) expect(html).toContain('') expect(html).toContain('Invoice from Acme AB') expect(html).toContain('Total due:') expect(html).toContain('Payment information') expect(html).toContain('Hi Jane,') expect(html).toContain('Kind regards,') // F-skatt is statutory and stays Swedish in both locales. expect(html).toContain('Innehar F-skattsedel') }) it('renders the total with explicit SEK code in English digit grouping', () => { const html = generateInvoiceEmailHtml(data) // en-US digit grouping: "12,500.00 SEK" expect(html).toContain('12,500.00 SEK') expect(html).not.toContain('kr') }) it('uses English subject', () => { expect(generateInvoiceEmailSubject(data)).toBe('Invoice 1042 from Acme AB') }) it('uses English plain text body', () => { const text = generateInvoiceEmailText(data) expect(text).toContain('Hi Jane,') expect(text).toContain('Total due:') expect(text).toContain('Due date:') expect(text).toContain('Thank you for your business') expect(text).not.toContain('kr') }) }) describe('credit note', () => { const creditInvoice = makeInvoice({ invoice_number: '1043', invoice_date: '2026-05-22', due_date: '2026-05-22', currency: 'SEK', total: -5000, credited_invoice_id: 'inv-orig', }) it('translates the credit-note body in English', () => { const customer = makeCustomer({ language: 'en' }) const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company }) expect(html).toContain('Credit note') expect(html).toContain('Attached you will find a credit note') }) it('keeps the credit-note body in Swedish for sv customers', () => { const customer = makeCustomer({ language: 'sv' }) const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company }) expect(html).toContain('Kreditfaktura') expect(html).toContain('Bifogat hittar du en kreditfaktura') }) }) describe('non-SEK currency', () => { const eurInvoice = makeInvoice({ invoice_number: '1044', currency: 'EUR', total: 1000, }) it('writes EUR code with the chosen locale grouping', () => { const enCustomer = makeCustomer({ language: 'en' }) const enHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: enCustomer, company }) expect(enHtml).toContain('1,000.00 EUR') const svCustomer = makeCustomer({ language: 'sv' }) const svHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: svCustomer, company }) expect(svHtml).toMatch(/1[\s\u00a0]000,00 EUR/) }) }) describe('custom email texts (invoice_email_texts)', () => { const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' }) const fullOverrides = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { subject: 'Er faktura {fakturanummer} \u2013 {f\u00f6retag}', greeting: 'Hejsan {f\u00f6rnamn}!', body: 'H\u00e4r kommer m\u00e5nadens faktura.', signoff: 'Allt gott,', }, en: { subject: 'Your invoice {fakturanummer}', greeting: 'Hello {f\u00f6rnamn}!', body: "Please find this month's invoice attached.", signoff: 'Best,', }, }, }) it('renders sv overrides in the HTML variant, keeping structural parts', () => { const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: fullOverrides }) expect(html).toContain('Hejsan Erik!') expect(html).toContain('H\u00e4r kommer m\u00e5nadens faktura.') expect(html).toContain('Allt gott,') expect(html).not.toContain('Tack f\u00f6r ditt f\u00f6rtroende') expect(html).not.toContain('Med v\u00e4nliga h\u00e4lsningar,') // Structural parts and the footer question line stay generated expect(html).toContain('Betalningsinformation') expect(html).toContain('Har du fr\u00e5gor om fakturan?') }) it('renders sv overrides in the text variant', () => { const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: fullOverrides }) expect(text).toContain('Hejsan Erik!') expect(text).toContain('H\u00e4r kommer m\u00e5nadens faktura.') expect(text).toContain('Allt gott,') expect(text).not.toContain('Med v\u00e4nliga h\u00e4lsningar,') }) it('substitutes placeholders in the subject', () => { const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: fullOverrides }) expect(subject).toBe('Er faktura 1042 \u2013 Acme AB') }) it('uses the en overrides for English customers', () => { const subject = generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: fullOverrides }) expect(subject).toBe('Your invoice 1042') const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: fullOverrides }) expect(html).toContain('Hello Jane!') }) it('falls back per language: sv-only overrides leave English customers on stock texts', () => { const svOnly = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: svOnly }) expect(html).toContain('Hi Jane,') expect(html).toContain('Thank you for your business') expect(generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: svOnly })) .toBe('Invoice 1042 from Acme AB') }) it('falls back per field: only overridden fields change', () => { const bodyOnly = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: bodyOnly }) expect(html).toContain('H\u00e4r kommer fakturan.') expect(html).toContain('Hej Erik,') expect(html).toContain('Med v\u00e4nliga h\u00e4lsningar,') expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: bodyOnly })) .toBe('Faktura 1042 fr\u00e5n Acme AB') }) it('treats whitespace-only overrides as unset', () => { const blank = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: ' ', subject: '\n' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: blank }) expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende') expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: blank })) .toBe('Faktura 1042 fr\u00e5n Acme AB') }) it('substitutes all six placeholders with per-language formatting', () => { const allPlaceholders = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' }, en: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' }, }, }) const svText = generateInvoiceEmailText({ invoice, customer: svCustomer, company: allPlaceholders }) expect(svText).toContain('1042 Erik Andersson Erik Acme AB 2026-06-21') expect(svText).toMatch(/12[\s\u00a0]500,00 SEK/) const enText = generateInvoiceEmailText({ invoice, customer: enCustomer, company: allPlaceholders }) expect(enText).toContain('1042 Jane Doe Jane Acme AB 2026-06-21 12,500.00 SEK') }) it('leaves unknown placeholders literal', () => { const typo = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { subject: 'Faktura {fakturanumer}', body: 'Se {bilaga}' } }, }) expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: typo })) .toBe('Faktura {fakturanumer}') const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: typo }) expect(text).toContain('Se {bilaga}') }) it('is forgiving about placeholder case and spacing', () => { const spaced = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { greeting: 'Hej { F\u00f6rnamn }!' } }, }) const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: spaced }) expect(text).toContain('Hej Erik!') }) it('escapes HTML in custom texts but keeps the text variant verbatim', () => { const xss = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: ' & "quoted"' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: xss }) expect(html).not.toContain(' & "quoted"') }) it('escapes substituted placeholder values in the HTML variant', () => { const trickyCustomer = makeCustomer({ name: 'Bj\u00f6rk & S\u00f6ner ', language: 'sv' }) const withName = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: 'Till {kundnamn}.' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: trickyCustomer, company: withName }) expect(html).toContain('Till Bj\u00f6rk & S\u00f6ner <AB>.') const text = generateInvoiceEmailText({ invoice, customer: trickyCustomer, company: withName }) expect(text).toContain('Till Bj\u00f6rk & S\u00f6ner .') }) it('converts newlines in the body to
in HTML and keeps them in text', () => { const multiline = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: 'Rad 1\nRad 2' } }, }) const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: multiline }) expect(html).toContain('Rad 1
Rad 2') const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: multiline }) expect(text).toContain('Rad 1\nRad 2') }) it('flattens newlines in a custom subject (header injection)', () => { const inject = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { subject: 'Faktura {fakturanummer}\r\nBcc: attacker@example.com' }, }, }) const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: inject }) expect(subject).toBe('Faktura 1042 Bcc: attacker@example.com') expect(subject).not.toMatch(/[\r\n]/) }) it('does not re-substitute placeholder-like values (single pass)', () => { const weirdCustomer = makeCustomer({ name: '{belopp} AB', language: 'sv' }) const greetByName = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { greeting: 'Hej {kundnamn}!' } }, }) const text = generateInvoiceEmailText({ invoice, customer: weirdCustomer, company: greetByName }) expect(text).toContain('Hej {belopp} AB!') }) it('ignores overrides on credit notes', () => { const creditInvoice = makeInvoice({ invoice_number: '1043', due_date: '2026-05-22', currency: 'SEK', total: -5000, credited_invoice_id: 'inv-orig', }) const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides }) expect(html).toContain('Bifogat hittar du en kreditfaktura') expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.') expect(generateInvoiceEmailSubject({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides })) .toBe('Kreditfaktura 1043 fr\u00e5n Acme AB') }) it('ignores overrides on proforma invoices', () => { const proforma = makeInvoice({ invoice_number: '1044', document_type: 'proforma' }) const html = generateInvoiceEmailHtml({ invoice: proforma, customer: svCustomer, company: fullOverrides }) expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende') expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.') expect(generateInvoiceEmailSubject({ invoice: proforma, customer: svCustomer, company: fullOverrides })) .toBe('Proformafaktura 1044 fr\u00e5n Acme AB') }) it('ignores overrides on delivery notes', () => { const deliveryNote = makeInvoice({ invoice_number: '1045', document_type: 'delivery_note' }) const html = generateInvoiceEmailHtml({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides }) expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.') expect(generateInvoiceEmailSubject({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides })) .toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB') }) }) describe('payment link (payment_link_url)', () => { const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) const linkUrl = 'https://buy.stripe.com/test_abc123' it('renders a pay-online button in HTML and the URL in plain text when set', () => { const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl }) const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company }) expect(html).toContain(`href="${linkUrl}"`) expect(html).toContain('Betala online') const text = generateInvoiceEmailText({ invoice: linked, customer: svCustomer, company }) expect(text).toContain(`Betala online: ${linkUrl}`) }) it('uses the English label for English customers', () => { const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' }) const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl }) const html = generateInvoiceEmailHtml({ invoice: linked, customer: enCustomer, company }) expect(html).toContain('Pay online') expect(html).not.toContain('Betala online') }) it('omits the button when no link is set', () => { const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company }) expect(html).not.toContain('Betala online') const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company }) expect(text).not.toContain('Betala online') }) it('hides the button on credit notes even if a link is present on the row', () => { const creditNote = makeInvoice({ invoice_number: '1043', credited_invoice_id: 'inv-orig', total: -5000, payment_link_url: linkUrl, }) const html = generateInvoiceEmailHtml({ invoice: creditNote, customer: svCustomer, company }) expect(html).not.toContain('Betala online') }) it('escapes quote characters in the URL for the href attribute', () => { const sneaky = 'https://pay.example.se/x?a="onmouseover=alert(1)' const linked = makeInvoice({ invoice_number: '1042', payment_link_url: sneaky }) const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company }) expect(html).not.toContain('a="onmouseover') expect(html).toContain('"onmouseover=alert(1)') }) }) describe('öresavrundning: "Att betala" matches the PDF', () => { const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) it('rounds the SEK total to whole kronor when rounding is on (company default)', () => { const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 }) const html = generateInvoiceEmailHtml({ invoice: oreInvoice, customer: svCustomer, company }) expect(html).toMatch(/1[\s ]235,00 SEK/) expect(html).not.toContain('234,56') const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company }) expect(text).toMatch(/Att betala: 1[\s ]235,00 SEK/) }) it('keeps the exact öre when the per-invoice flag turns rounding off', () => { const exactInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, ore_rounding: false }) const html = generateInvoiceEmailHtml({ invoice: exactInvoice, customer: svCustomer, company }) expect(html).toMatch(/1[\s ]234,56 SEK/) expect(html).not.toMatch(/1[\s ]235,00 SEK/) }) it('does not round non-SEK invoices', () => { const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 }) const text = generateInvoiceEmailText({ invoice: eurInvoice, customer: svCustomer, company }) expect(text).toMatch(/1[\s ]234,56 EUR/) }) it('uses the matching EUR payment account in both email variants', () => { const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 }) const multiCurrencyCompany = makeCompanySettings({ bank_name: 'Legacy SEK Bank', clearing_number: '5037', account_number: '1231231', iban: 'SE0011111111111111111111', bic: 'NDEASESS', invoice_payment_accounts: { EUR: { bank_name: 'Mock ASPSP', clearing_number: null, account_number: null, bankgiro: null, plusgiro: null, swish: null, iban: 'SE4550000000058398257466', bic: 'ESSESESS', }, }, }) const data = { invoice: eurInvoice, customer: svCustomer, company: multiCurrencyCompany } const html = generateInvoiceEmailHtml(data) const text = generateInvoiceEmailText(data) for (const rendered of [html, text]) { expect(rendered).toContain('Mock ASPSP') expect(rendered).toContain('SE4550000000058398257466') expect(rendered).toContain('ESSESESS') expect(rendered).not.toContain('Legacy SEK Bank') expect(rendered).not.toContain('5037-1231231') expect(rendered).not.toContain('SE0011111111111111111111') expect(rendered).not.toContain('NDEASESS') } }) it('subtracts the ROT/RUT deduction so the email states what the customer owes', () => { const rotInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, deduction_total: 500 }) const html = generateInvoiceEmailHtml({ invoice: rotInvoice, customer: svCustomer, company }) expect(html).toContain('735,00 SEK') const text = generateInvoiceEmailText({ invoice: rotInvoice, customer: svCustomer, company }) expect(text).toContain('Att betala: 735,00 SEK') }) it('uses the rounded amount for the {belopp} placeholder', () => { const withBelopp = makeCompanySettings({ company_name: 'Acme AB', invoice_email_texts: { sv: { body: 'Summa: {belopp}' } }, }) const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 }) const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company: withBelopp }) expect(text).toMatch(/Summa: 1[\s ]235,00 SEK/) }) }) })