import type { Invoice, Customer, CompanySettings, ReminderTextOverrides } from '@/types' import { formatCurrency, formatDate, getCompanyDisplayName } from '@/lib/utils' import { getAmountToPay } from '@/lib/invoices/rounding' import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts' import { applyPlaceholders, escapeHtml, sanitizeSubjectLine } from './user-text' /** * What the customer was asked to pay on the original invoice: the öre-rounded * "Att betala" minus any ROT/RUT-avdrag (fakturamodellen: the deduction is a * claim on Skatteverket, never on the customer). Reminders, dröjsmålsränta and * the amount-to-pay line must all start from this figure, not invoice.total, * or a privatperson is dunned for the SKV share and charged interest on it. */ export function reminderPrincipal(invoice: Invoice, company: CompanySettings): number { return getAmountToPay(invoice, company).toPay } export interface ReminderEmailData { invoice: Invoice customer: Customer company: CompanySettings reminderLevel: 1 | 2 | 3 daysOverdue: number actionUrl: string // URL for customer to mark as paid or dispute // Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739). // When the reminder is generated by the cron, these are computed and persisted // on the invoice_reminders row and then passed in here. // // interestAmount is a share of the invoice total, so it carries the INVOICE // currency. reminderFee is a fixed statutory krona amount and always carries // SEK (see REMINDER_FEE_CURRENCY). The two are deliberately not summed here: // the amount to pay is derived per currency by calculateReminderAmounts(). interestAmount: number interestRate: number // annual rate as decimal (0.105 = 10.5%) interestFromDate: string interestDays: number reminderFee: number // always SEK, never the invoice currency } /** * The lagstadgad påminnelseavgift is a Swedish-krona statute: Lag (1981:739) om * ersättning för inkassokostnader m.m. 2 §, with the amount fixed at 60 kr by * förordning (1981:1057) 1 §. There is no conversion rule for a foreign-currency * claim, and Accounted books the fee 1510/3990 in SEK regardless of the invoice * currency (lib/bookkeeping/reminder-fee-entries.ts). * * So the fee is always quoted in SEK. Rendering "60" with the invoice's currency * code would demand roughly ten times the statutory amount from a EUR customer * and would not match the receivable recorded in the books. */ export const REMINDER_FEE_CURRENCY = 'SEK' export interface ReminderAmounts { /** Invoice currency (uppercased ISO code). Carries the invoice total + interest. */ currency: string /** invoice.total + dröjsmålsränta, plus the fee only when the invoice is itself in SEK. */ totalDue: number /** * The påminnelseavgift in SEK when the invoice is in another currency, so it * has to be demanded alongside rather than inside `totalDue`. 0 when the fee * is already folded into `totalDue`. */ feeDueSeparately: number } /** * Split the amount to pay per currency. Never returns a single scalar that mixes * a foreign invoice currency with the SEK statutory fee. */ export function calculateReminderAmounts(input: { invoiceTotal: number interestAmount: number reminderFee: number currency?: string | null }): ReminderAmounts { const currency = (input.currency || REMINDER_FEE_CURRENCY).toUpperCase() const feeSharesInvoiceCurrency = currency === REMINDER_FEE_CURRENCY const fee = input.reminderFee > 0 ? Math.round(input.reminderFee * 100) / 100 : 0 const totalDue = Math.round( (input.invoiceTotal + input.interestAmount + (feeSharesInvoiceCurrency ? fee : 0)) * 100, ) / 100 return { currency, totalDue, feeDueSeparately: feeSharesInvoiceCurrency ? 0 : fee, } } /** * Render the amount to pay. When the statutory fee is in a different currency * than the invoice it is appended as its own amount ("1 010,00 € + 60 kr") * instead of being folded into a misleading single figure. */ export function formatReminderTotalDue(amounts: ReminderAmounts): string { const base = formatCurrency(amounts.totalDue, amounts.currency) if (amounts.feeDueSeparately <= 0) { return base } return `${base} + ${formatCurrency(amounts.feeDueSeparately, REMINDER_FEE_CURRENCY)}` } /** Shown whenever a SEK fee rides along with a foreign-currency invoice. */ export const REMINDER_FEE_CURRENCY_NOTE = 'Påminnelseavgiften är lagstadgad (Lag 1981:739) och anges i svenska kronor. ' + 'Den räknas inte om till fakturans valuta utan betalas som ett separat belopp i SEK.' // Reminder level configurations const REMINDER_CONFIG = { 1: { title: 'Vänlig påminnelse', tone: 'friendly', daysAfterDue: 15 }, 2: { title: 'Andra påminnelsen', tone: 'firm', daysAfterDue: 30 }, 3: { title: 'Slutlig påminnelse', tone: 'urgent', daysAfterDue: 45 } } as const export type ReminderLevelKey = 'level_1' | 'level_2' | 'level_3' function levelKey(reminderLevel: 1 | 2 | 3): ReminderLevelKey { return `level_${reminderLevel}` as ReminderLevelKey } // Placeholder keys available in company-editable reminder texts // (company_settings.reminder_text_overrides). 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 REMINDER_EMAIL_PLACEHOLDER_KEYS = [ 'fakturanummer', 'kundnamn', 'förnamn', 'företag', 'fakturadatum', 'förfallodatum', 'belopp', 'dagar', ] as const /** * Default subject/body per reminder level, in placeholder-pattern form. * Single source of truth: the stock mail renders these same patterns through * the same substitution pipeline as company overrides, so the settings-UI * prefill is exactly what goes out and cannot drift from the send path. * * Level 3 is deliberately an inkassovarning (final notice before collection), * as Swedish practice expects: it names the 8-day window and that the claim * is handed to inkasso with added statutory costs (lag 1981:739). TEXT only: * fee and interest math live in the reminder processor and are unaffected by * anything here or in company overrides. * * The stock subject additionally gets ' (inkl. dröjsmålsränta)' appended when * surcharges apply (see generateReminderEmailSubject); an override replaces * the whole line and gets no automatic suffix. */ export const REMINDER_EMAIL_DEFAULT_TEXTS: Record< ReminderLevelKey, { subject: string; body: string } > = { level_1: { subject: 'Vänlig påminnelse: Faktura {fakturanummer} - {belopp}', body: 'Vi vill påminna dig om att faktura {fakturanummer} förföll till betalning den {förfallodatum}. ' + 'Om du redan har betalat kan du bortse från denna påminnelse.', }, level_2: { subject: 'Andra påminnelsen: Faktura {fakturanummer} - {belopp}', body: 'Trots vår tidigare påminnelse har vi ännu inte mottagit betalning för faktura {fakturanummer} ' + 'som förföll den {förfallodatum}.\n\n' + 'Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.', }, level_3: { subject: 'Slutlig påminnelse: Faktura {fakturanummer} - {belopp}', body: 'Detta är en slutlig påminnelse och inkassovarning gällande faktura {fakturanummer}.\n\n' + 'Fakturan förföll till betalning den {förfallodatum} och vi har trots tidigare påminnelser ' + 'ännu inte mottagit din betalning.\n\n' + 'Om full betalning inte har kommit oss tillhanda inom 8 dagar från detta meddelande ' + 'överlämnas fordran till inkasso, vilket medför ytterligare kostnader för dig enligt ' + 'lag (1981:739) om ersättning för inkassokostnader m.m.', }, } // Values for the fixed placeholder set. {belopp} is the amount to pay incl. // surcharges via formatReminderTotalDue, so a foreign-currency invoice with a // SEK fee renders the two-amount form here too, never one mixed scalar. function buildReminderPlaceholderValues(data: ReminderEmailData): Record { const { invoice, customer, company, daysOverdue, interestAmount, reminderFee } = data const fullName = (customer.name || '').trim() const amounts = calculateReminderAmounts({ invoiceTotal: reminderPrincipal(invoice, company), interestAmount, reminderFee, currency: invoice.currency, }) return { fakturanummer: invoice.invoice_number ?? '', kundnamn: fullName, förnamn: fullName ? fullName.split(' ')[0] : '', företag: getCompanyDisplayName(company), fakturadatum: formatDate(invoice.invoice_date), förfallodatum: formatDate(invoice.due_date), belopp: formatReminderTotalDue(amounts), dagar: String(daysOverdue), } } interface ResolvedReminderTexts { subject?: string body?: string } // Resolves the company's custom reminder texts for one level. Per-field // fallback: missing / non-string / whitespace-only values return undefined // and the caller renders the default pattern instead. 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 resolveReminderTexts( company: CompanySettings, reminderLevel: 1 | 2 | 3, values: Record, ): ResolvedReminderTexts { const overrides: ReminderTextOverrides | null | undefined = company.reminder_text_overrides const levelTexts = overrides && typeof overrides === 'object' ? overrides[levelKey(reminderLevel)] : undefined if (!levelTexts || typeof levelTexts !== 'object') return {} const pick = (v: unknown): string | undefined => typeof v === 'string' && v.trim() !== '' ? applyPlaceholders(v.trim(), values) : undefined return { subject: pick(levelTexts.subject), body: pick(levelTexts.body), } } // Effective body for one level: override if present, else the default // pattern, both substituted through the same pipeline. function resolveReminderBody(data: ReminderEmailData): string { const values = buildReminderPlaceholderValues(data) const custom = resolveReminderTexts(data.company, data.reminderLevel, values) return ( custom.body ?? applyPlaceholders(REMINDER_EMAIL_DEFAULT_TEXTS[levelKey(data.reminderLevel)].body, values) ) } // Blank-line separated paragraphs ->

blocks; single newlines ->
. // Escape FIRST so user-authored overrides cannot inject markup. The final // level's opening paragraph keeps the red emphasis regardless of override: // the inkassovarning must not look like a routine note. function reminderBodyToHtml(body: string, reminderLevel: 1 | 2 | 3): string { return body .split(/(?:\r?\n){2,}/) .map((paragraph, index) => { const emphasis = reminderLevel === 3 && index === 0 ? ' color: #dc2626; font-weight: 500;' : '' const html = escapeHtml(paragraph).replace(/\r\n|\r|\n/g, '
') return `

${html}

` }) .join('\n ') } /** * Generate HTML email for payment reminder */ export function generateReminderEmailHtml(data: ReminderEmailData): string { const { invoice, customer, reminderLevel, daysOverdue, actionUrl, interestAmount, interestRate, interestDays, reminderFee, } = data // Payment details follow the invoice currency, same as the invoice email // and PDF: a EUR reminder must never print the SEK account's IBAN. const company = companyWithInvoicePaymentAccount(data.company, invoice.currency) const config = REMINDER_CONFIG[reminderLevel] const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', { minimumFractionDigits: 0, maximumFractionDigits: 2, }) const hasInterest = interestAmount > 0 const hasFee = reminderFee > 0 const hasSurcharges = hasInterest || hasFee const amounts = calculateReminderAmounts({ invoiceTotal: reminderPrincipal(invoice, company), interestAmount, reminderFee, currency: invoice.currency, }) const feeInOtherCurrency = amounts.feeDueSeparately > 0 // Different styling based on urgency const headerColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb' const buttonColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb' return ` ${config.title} - Faktura ${invoice.invoice_number}
${config.title}

Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan

Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},

${reminderBodyToHtml(resolveReminderBody(data), reminderLevel)}
${hasInterest ? ` ` : ''} ${hasFee ? ` ` : ''} ${hasSurcharges ? ` ` : ''} ${feeInOtherCurrency ? ` ` : ''}
Fakturanummer: ${invoice.invoice_number}
Fakturadatum: ${formatDate(invoice.invoice_date)}
Förfallodatum: ${formatDate(invoice.due_date)}
Ursprungligt belopp: ${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}
Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}
Påminnelseavgift: ${formatCurrency(reminderFee, REMINDER_FEE_CURRENCY)}
Att betala: ${formatReminderTotalDue(amounts)}
${REMINDER_FEE_CURRENCY_NOTE}

Betalningsinformation

${company.bank_name ? ` ` : ''} ${company.clearing_number && company.account_number ? ` ` : ''} ${company.iban ? ` ` : ''} ${company.bic ? ` ` : ''}
Bank: ${company.bank_name}
Kontonummer: ${company.clearing_number}-${company.account_number}
IBAN: ${company.iban}
BIC/SWIFT: ${company.bic}
Meddelande: ${invoice.invoice_number}

Har du redan betalat eller har frågor om fakturan?

Hantera faktura

Har du frågor? Svara direkt på detta mejl så hjälper vi dig.

Med vänliga hälsningar,
${getCompanyDisplayName(company)}

${company.org_number ? `

Org.nr: ${company.org_number} ${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}

` : ''}

Detta är ett automatiskt meddelande angående en obetald faktura.

` } /** * Generate plain text email for payment reminder */ export function generateReminderEmailText(data: ReminderEmailData): string { const { invoice, customer, reminderLevel, daysOverdue, actionUrl, interestAmount, interestRate, interestDays, reminderFee, } = data // Payment details follow the invoice currency, same as the invoice email // and PDF: a EUR reminder must never print the SEK account's IBAN. const company = companyWithInvoicePaymentAccount(data.company, invoice.currency) const config = REMINDER_CONFIG[reminderLevel] const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', { minimumFractionDigits: 0, maximumFractionDigits: 2, }) const hasInterest = interestAmount > 0 const hasFee = reminderFee > 0 const amounts = calculateReminderAmounts({ invoiceTotal: reminderPrincipal(invoice, company), interestAmount, reminderFee, currency: invoice.currency, }) let text = `${config.title.toUpperCase()}\n` text += `Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan\n` text += `=`.repeat(50) + `\n\n` text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n` text += `${resolveReminderBody(data)}\n\n` text += `Fakturasammanfattning:\n` text += `-`.repeat(30) + `\n` text += `Fakturanummer: ${invoice.invoice_number}\n` text += `Fakturadatum: ${formatDate(invoice.invoice_date)}\n` text += `Förfallodatum: ${formatDate(invoice.due_date)}\n` text += `Ursprungligt belopp: ${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}\n` if (hasInterest) { text += `Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}\n` } if (hasFee) { text += `Påminnelseavgift: ${formatCurrency(reminderFee, REMINDER_FEE_CURRENCY)}\n` } text += `Att betala: ${formatReminderTotalDue(amounts)}\n` text += `-`.repeat(30) + `\n` if (amounts.feeDueSeparately > 0) { text += `${REMINDER_FEE_CURRENCY_NOTE}\n` } text += `\n` text += `Betalningsinformation:\n` if (company.bank_name) text += `Bank: ${company.bank_name}\n` if (company.clearing_number && company.account_number) { text += `Kontonummer: ${company.clearing_number}-${company.account_number}\n` } if (company.iban) text += `IBAN: ${company.iban}\n` if (company.bic) text += `BIC/SWIFT: ${company.bic}\n` text += `Meddelande: ${invoice.invoice_number}\n\n` text += `Har du redan betalat eller har frågor om fakturan?\n` text += `Hantera faktura: ${actionUrl}\n\n` text += `Har du frågor? Svara direkt på detta mejl så hjälper vi dig.\n\n` text += `Med vänliga hälsningar,\n` text += `${getCompanyDisplayName(company)}\n` if (company.org_number) { text += `\nOrg.nr: ${company.org_number}` if (company.vat_number) text += ` | VAT: ${company.vat_number}` text += `\n` } return text } /** * Generate email subject for payment reminder. * * When dröjsmålsränta or påminnelseavgift apply we surface them in the * subject so the customer sees the true amount to pay before opening * the email. */ export function generateReminderEmailSubject(data: ReminderEmailData): string { const { company, reminderLevel, interestAmount, reminderFee } = data const hasSurcharges = interestAmount > 0 || reminderFee > 0 // {belopp} comes from formatReminderTotalDue: a SEK fee on a foreign-currency // invoice renders as "1 010,00 € + 60 kr", two amounts in two currencies, // never one mixed scalar. const values = buildReminderPlaceholderValues(data) const custom = resolveReminderTexts(company, reminderLevel, values) if (custom.subject !== undefined) { // An override owns the whole line: no automatic surcharge suffix. return sanitizeSubjectLine(custom.subject) } const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : '' const stock = applyPlaceholders( REMINDER_EMAIL_DEFAULT_TEXTS[levelKey(reminderLevel)].subject, values, ) return sanitizeSubjectLine(`${stock}${suffix}`) } /** * Get the number of days after due date for each reminder level */ export type ReminderDaysConfig = Record<1 | 2 | 3, number> type ReminderDaysSettings = Partial< Pick< CompanySettings, 'reminder_days_level_1' | 'reminder_days_level_2' | 'reminder_days_level_3' > > export function getReminderDaysConfig( settings?: ReminderDaysSettings | null, ): ReminderDaysConfig { const defaults: ReminderDaysConfig = { 1: REMINDER_CONFIG[1].daysAfterDue, 2: REMINDER_CONFIG[2].daysAfterDue, 3: REMINDER_CONFIG[3].daysAfterDue, } const configured: ReminderDaysConfig = { 1: settings?.reminder_days_level_1 ?? defaults[1], 2: settings?.reminder_days_level_2 ?? defaults[2], 3: settings?.reminder_days_level_3 ?? defaults[3], } const valid = Object.values(configured).every((days) => Number.isInteger(days) && days >= 1 && days <= 365) && configured[1] < configured[2] && configured[2] < configured[3] return valid ? configured : defaults }