Files
accounted/lib/email/invoice-templates.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

582 lines
25 KiB
TypeScript

import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatDate, getCompanyDisplayName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text'
type EmailLang = 'sv' | 'en'
// Customer-facing labels. Statutory chapter references stay intact in both
// locales. lib/utils.ts formatCurrency() keeps the Swedish "kr" symbol for
// in-app financial UI per the accounting standard; here we want the ISO code
// so a non-Swedish recipient understands the unit.
const LABELS = {
sv: {
docInvoice: 'Faktura',
docCreditNote: 'Kreditfaktura',
docProforma: 'Proformafaktura',
docDeliveryNote: 'Följesedel',
htmlLang: 'sv',
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
documentNumber: (doc: string) => `${doc}nummer:`,
documentDate: (doc: string) => `${doc}datum:`,
dueDate: 'Förfallodatum:',
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
toPay: 'Att betala:',
payOnline: 'Betala online',
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
account: 'Kontonummer:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
message: 'Meddelande:',
questions: 'Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.',
sincerely: 'Med vänliga hälsningar,',
orgNo: 'Org.nr:',
vat: 'VAT:',
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`,
// Payment confirmation (#1693)
confirmationSubject: (num: string, sender: string) => `Betalningsbekräftelse för faktura ${num} från ${sender}`,
confirmationHeading: (sender: string) => `Betalningsbekräftelse från ${sender}`,
confirmationBody: (num: string) => `Tack för din betalning! Vi bekräftar att faktura ${num} är betald i sin helhet. Bifogat hittar du en betalningsbekräftelse.`,
confirmationPaidOn: 'Betald:',
confirmationPaidAmount: 'Betalt belopp:',
confirmationQuestions: 'Har du frågor om betalningen? Svara direkt på detta mejl så hjälper vi dig.',
},
en: {
docInvoice: 'Invoice',
docCreditNote: 'Credit note',
docProforma: 'Proforma invoice',
docDeliveryNote: 'Delivery note',
htmlLang: 'en',
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
documentNumber: (doc: string) => `${doc} number:`,
documentDate: (doc: string) => `${doc} date:`,
dueDate: 'Due date:',
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
toPay: 'Total due:',
payOnline: 'Pay online',
paymentHeading: 'Payment information',
bank: 'Bank:',
account: 'Account number:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
message: 'Reference:',
questions: 'Questions about the invoice? Reply directly to this email and we will help you.',
sincerely: 'Kind regards,',
orgNo: 'Reg. no.:',
vat: 'VAT:',
// Statutory Swedish phrase: kept verbatim in both locales. F-skatt is a
// Swedish tax-authority designation; translating it has no legal standing.
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc} summary:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`,
confirmationSubject: (num: string, sender: string) => `Payment confirmation for invoice ${num} from ${sender}`,
confirmationHeading: (sender: string) => `Payment confirmation from ${sender}`,
confirmationBody: (num: string) => `Thank you for your payment. We confirm that invoice ${num} has been paid in full. A payment confirmation is attached.`,
confirmationPaidOn: 'Paid on:',
confirmationPaidAmount: 'Amount paid:',
confirmationQuestions: 'Questions about the payment? Reply directly to this email and we will help you.',
},
} as const
// Placeholder keys available in company-editable email texts
// (company_settings.invoice_email_texts). Rendered as a legend in the
// settings UI; kept here rather than in messages/*.json because ICU message
// syntax treats literal braces as interpolation.
export const INVOICE_EMAIL_PLACEHOLDER_KEYS = [
'fakturanummer',
'kundnamn',
'förnamn',
'företag',
'förfallodatum',
'belopp',
] as const
// Display strings for the settings UI's input placeholder attributes.
// subject and greeting are functions in LABELS, so their pattern form is
// hand-written here; body/signoff reference LABELS directly so they cannot
// drift from the actual defaults.
export const INVOICE_EMAIL_DEFAULT_TEXTS = {
sv: {
subject: 'Faktura {fakturanummer} från {företag}',
greeting: 'Hej {förnamn},',
body: LABELS.sv.bodyInvoice,
signoff: LABELS.sv.sincerely,
},
en: {
subject: 'Invoice {fakturanummer} from {företag}',
greeting: 'Hi {förnamn},',
body: LABELS.en.bodyInvoice,
signoff: LABELS.en.sincerely,
},
} as const
function resolveLang(customer: Customer): EmailLang {
return customer.language === 'en' ? 'en' : 'sv'
}
// Custom texts apply ONLY to standard invoices. Credit notes, proforma and
// delivery notes always use the stock texts: a custom "Tack för ditt
// förtroende..." body or "Faktura..." subject would be wrong on those.
function isStandardInvoice(invoice: Invoice): boolean {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
return docType === 'invoice' && !invoice.credited_invoice_id
}
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
const L = LABELS[lang]
if (invoice.credited_invoice_id) return L.docCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return L.docProforma
if (docType === 'delivery_note') return L.docDeliveryNote
return L.docInvoice
}
// Currency for the customer-facing total: explicit ISO code so a non-Swedish
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
// English locale for digit grouping when the email is in English so the comma
// thousands separator matches reader expectation.
function formatCurrencyForCustomer(amount: number, currency: string, lang: EmailLang): string {
const formatted = new Intl.NumberFormat(lang === 'en' ? 'en-US' : 'sv-SE', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
return `${formatted} ${currency}`
}
export interface InvoiceEmailData {
invoice: Invoice
customer: Customer
company: CompanySettings
}
function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record<string, string> {
const { invoice, customer, company } = data
const fullName = (customer.name || '').trim()
return {
fakturanummer: invoice.invoice_number ?? '',
kundnamn: fullName,
förnamn: fullName ? fullName.split(' ')[0] : '',
företag: getCompanyDisplayName(company),
förfallodatum: formatDate(invoice.due_date),
belopp: formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang),
}
}
interface ResolvedCustomTexts {
subject?: string
greeting?: string
body?: string
signoff?: string
}
// Resolves the company's custom email texts for one language. Per-field
// fallback: missing / non-string / whitespace-only values return undefined
// and the caller uses the stock text. Returns RAW substituted strings:
// escaping is the caller's job per output variant (HTML vs text vs subject).
// Defensive typeof checks: rows can be written outside Zod (scripts, SQL).
function resolveCustomTexts(data: InvoiceEmailData, lang: EmailLang): ResolvedCustomTexts {
if (!isStandardInvoice(data.invoice)) return {}
const texts = data.company.invoice_email_texts
const langTexts = texts && typeof texts === 'object' ? texts[lang] : undefined
if (!langTexts || typeof langTexts !== 'object') return {}
const values = buildPlaceholderValues(data, lang)
const pick = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? applyPlaceholders(v.trim(), values) : undefined
return {
subject: pick(langTexts.subject),
greeting: pick(langTexts.greeting),
body: pick(langTexts.body),
signoff: pick(langTexts.signoff),
}
}
// Minimal hex validator: guards against branding values that bypass the
// settings UI and could inject CSS via crafted strings. Anything malformed
// falls back to the legacy default.
function safeBrandingColor(value: string | null | undefined, fallback: string): string {
if (!value) return fallback
return /^#[0-9A-Fa-f]{6}$/.test(value) ? value : fallback
}
/**
* Generate HTML email for sending an invoice
*/
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
// Primary color drives the heading accent and the highlighted total. The
// accent is sanitized to a strict hex pattern: anything else falls back
// to the legacy dark neutral. Credit notes intentionally use the success
// green for the total regardless of branding, because the customer's brain
// is wired to expect "money coming back = green".
const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111')
return `
<!DOCTYPE html>
<html lang="${L.htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${documentType} ${invoice.invoice_number}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<!-- Header -->
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
${L.documentFrom(documentType, getCompanyDisplayName(company))}
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.documentNumber(documentType)} ${invoice.invoice_number}
</p>
</div>
<!-- Greeting -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
${custom.greeting !== undefined ? userTextToHtml(custom.greeting) : L.greeting(firstName)}
</p>
<p style="margin: 0;">
${custom.body !== undefined ? userTextToHtml(custom.body) : (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}
</p>
</div>
<!-- Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(documentType)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(documentType)}</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
${formatDate(invoice.due_date)}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
</td>
</tr>
</table>
</div>
<!-- Pay-online button: only when the user pasted a payment link on this
invoice. The URL is schema-validated (https-only) but still escaped
for the attribute context: a URL may legally contain quotes. -->
${!hidePayment && invoice.payment_link_url ? `
<div style="margin-bottom: 30px; text-align: center;">
<a href="${escapeHtml(invoice.payment_link_url)}" style="display: inline-block; background: ${primaryColor}; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-size: 16px; font-weight: 600;">
${L.payOnline}
</a>
</div>
` : ''}
<!-- Payment Details -->
${!hidePayment ? `
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: ${primaryColor};">
${L.paymentHeading}
</h2>
<table style="width: 100%; border-collapse: collapse;">
${company.bank_name ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">${L.bank}</td>
<td style="padding: 6px 0;">${company.bank_name}</td>
</tr>
` : ''}
${company.clearing_number && company.account_number ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.account}</td>
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
</tr>
` : ''}
${company.iban ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.iban}</td>
<td style="padding: 6px 0;">${company.iban}</td>
</tr>
` : ''}
${company.bic ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.bic}</td>
<td style="padding: 6px 0;">${company.bic}</td>
</tr>
` : ''}
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.message}</td>
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
</table>
</div>
` : ''}
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
${L.questions}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
<strong style="color: ${primaryColor};">${getCompanyDisplayName(company)}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
${L.orgNo} ${company.org_number}
${company.vat_number ? ` | ${L.vat} ${company.vat_number}` : ''}
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
</p>
` : ''}
</div>
</div>
</body>
</html>
`
}
/**
* Generate plain text email for sending an invoice
*/
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
let text = `${L.documentFrom(documentType, getCompanyDisplayName(company))}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
text += `${custom.body ?? (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}\n\n`
text += `${L.documentSummary(documentType)}\n`
text += `---\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
text += `---\n\n`
if (!hidePayment) {
text += `${L.paymentHeading}:\n`
if (invoice.payment_link_url) text += `${L.payOnline}: ${invoice.payment_link_url}\n`
if (company.bank_name) text += `${L.bank} ${company.bank_name}\n`
if (company.clearing_number && company.account_number) {
text += `${L.account} ${company.clearing_number}-${company.account_number}\n`
}
if (company.iban) text += `${L.iban} ${company.iban}\n`
if (company.bic) text += `${L.bic} ${company.bic}\n`
text += `${L.message} ${invoice.invoice_number}\n\n`
}
text += `${L.questions}\n\n`
text += `${custom.signoff ?? L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
if (company.org_number) {
text += `\n${L.orgNo} ${company.org_number}`
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
if (company.f_skatt) text += ` | ${L.fSkatt}`
text += `\n`
}
return text
}
/**
* Generate email subject for an invoice
*/
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
// Sanitization runs after substitution, so a pathological placeholder
// value containing a newline is also flattened to a single header line.
const custom = resolveCustomTexts(data, lang)
if (custom.subject !== undefined) return sanitizeSubjectLine(custom.subject)
const documentType = getDocumentLabel(invoice, lang)
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyDisplayName(company))
}
// ---------------------------------------------------------------------------
// Payment confirmation (#1693)
//
// Sent on request for a paid faktura, with the BETALD re-render attached. It
// is not an invoice send: no custom invoice texts apply, no payment details
// are listed (nothing is due), and nothing here touches delivery history.
// ---------------------------------------------------------------------------
function paidAmountForCustomer(invoice: Invoice, company: CompanySettings): number {
// What the customer actually paid; the deduction-aware amount to pay is only
// the fallback for legacy rows marked paid before paid_amount was recorded.
return invoice.paid_amount ?? getAmountToPay(invoice, company).toPay
}
function paidDateForCustomer(invoice: Invoice): string | null {
return invoice.paid_at ? formatDate(invoice.paid_at) : null
}
export function generatePaymentConfirmationEmailSubject(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const L = LABELS[resolveLang(customer)]
return sanitizeSubjectLine(
L.confirmationSubject(invoice.invoice_number ?? '', getCompanyDisplayName(company)),
)
}
export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111')
const paidDate = paidDateForCustomer(invoice)
const paidAmount = formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang)
const invoiceNumber = escapeHtml(invoice.invoice_number ?? '')
return `
<!DOCTYPE html>
<html lang="${L.htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(L.confirmationSubject(invoice.invoice_number ?? '', getCompanyDisplayName(company)))}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
${escapeHtml(L.confirmationHeading(getCompanyDisplayName(company)))}
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.documentNumber(L.docInvoice)} ${invoiceNumber}
</p>
</div>
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">${escapeHtml(L.greeting(firstName))}</p>
<p style="margin: 0;">${escapeHtml(L.confirmationBody(invoice.invoice_number ?? ''))}</p>
</div>
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(L.docInvoice)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoiceNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(L.docInvoice)}</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
${paidDate ? `
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.confirmationPaidOn}</td>
<td style="padding: 8px 0; text-align: right;">${paidDate}</td>
</tr>
` : ''}
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.confirmationPaidAmount}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: #059669;">
${paidAmount}
</td>
</tr>
</table>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">${L.confirmationQuestions}</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.sincerely}<br>
<strong style="color: ${primaryColor};">${escapeHtml(getCompanyDisplayName(company))}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
${L.orgNo} ${escapeHtml(company.org_number)}
${company.vat_number ? ` | ${L.vat} ${escapeHtml(company.vat_number)}` : ''}
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
</p>
` : ''}
</div>
</div>
</body>
</html>
`
}
export function generatePaymentConfirmationEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const paidDate = paidDateForCustomer(invoice)
const number = invoice.invoice_number ?? ''
let text = `${L.confirmationHeading(getCompanyDisplayName(company))}\n`
text += `${L.documentNumber(L.docInvoice)} ${number}\n\n`
text += `${L.greeting(firstName)}\n\n`
text += `${L.confirmationBody(number)}\n\n`
text += `---\n`
text += `${L.documentNumber(L.docInvoice)} ${number}\n`
text += `${L.documentDate(L.docInvoice)} ${formatDate(invoice.invoice_date)}\n`
if (paidDate) text += `${L.confirmationPaidOn} ${paidDate}\n`
text += `${L.confirmationPaidAmount} ${formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang)}\n`
text += `---\n\n`
text += `${L.confirmationQuestions}\n\n`
text += `${L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
if (company.org_number) {
text += `\n${L.orgNo} ${company.org_number}`
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
if (company.f_skatt) text += ` | ${L.fSkatt}`
text += `\n`
}
return text
}