Files
accounted/lib/email/reminder-templates.ts
T
MattssonandClaude Fable 5 3cf2e10740 feat(reminders): per-company reminder text overrides with per-field reset (#2038)
* feat(reminders): per-company reminder text overrides with per-field reset

Add company_settings.reminder_text_overrides (JSONB, migration
20260830100000): optional subject/body per reminder level, storing only
diffs from the defaults. Reminder templates now express their defaults as
placeholder patterns and render stock and override mails through one
substitution pipeline (placeholders, HTML escaping, subject sanitizing),
so the settings prefill is exactly the sent mail. The level 3 default is
strengthened into an explicit inkassovarning (8 days, handover to
inkasso, costs per lag (1981:739)); text only, no fee or interest math
changes. New ReminderEmailTextsSettings editor (per-level tabs, effective
value prefilled, per-field reset, placeholder legend) mounted in the
invoicing settings, strings in sv + en, and reminder_text_overrides added
to UpdateSettingsSchema with schema and template tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* chore(migrations): bump reminder_text_overrides to 20260830120000

Main gained 20260830101500_seed_agent_atom_bodies after this branch cut
its version, so the file moves to a fresh later timestamp to keep
remote migration history append-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(reminders): serialize override saves and fix Swedish hint grammar

CodeRabbit review: queue the whole-object PUTs in
ReminderEmailTextsSettings so an older in-flight snapshot cannot replace
a newer edit, and start the level 3 hint with "Den slutliga
paminnelsen". The NOT VALID suggestion on the migration CHECK is
declined: company_settings is one row per company, migration files run
in a single transaction, and the invoice_email_texts precedent shipped
the identical constraint shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:06:00 +02:00

625 lines
24 KiB
TypeScript

import type { Invoice, Customer, CompanySettings, ReminderTextOverrides } from '@/types'
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } 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<string, string> {
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: getCompanyPrimaryName(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<string, string>,
): 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 -> <p> blocks; single newlines -> <br>.
// 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, '<br>')
return `<p style="margin: 0 0 15px 0;${emphasis}">${html}</p>`
})
.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 `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${config.title} - Faktura ${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; background-color: #f9fafb;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: ${headerColor}15; color: ${headerColor}; padding: 8px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
${config.title}
</div>
</div>
<!-- Title -->
<h1 style="margin: 0 0 20px 0; font-size: 22px; font-weight: 600; color: #111; text-align: center;">
Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan
</h1>
<!-- Greeting and Message -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
</p>
${reminderBodyToHtml(resolveReminderBody(data), reminderLevel)}
</div>
<!-- Invoice Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px; border-left: 4px solid ${headerColor};">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturanummer:</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;">Fakturadatum:</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;">Förfallodatum:</td>
<td style="padding: 8px 0; text-align: right; color: #dc2626; font-weight: 500;">
${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; color: #666; font-size: 14px;">Ursprungligt belopp:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}</td>
</tr>
${hasInterest ? `
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">
Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar):
</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(interestAmount, invoice.currency)}</td>
</tr>
` : ''}
${hasFee ? `
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Påminnelseavgift:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderFee, REMINDER_FEE_CURRENCY)}</td>
</tr>
` : ''}
${hasSurcharges ? `
<tr>
<td colspan="2" style="padding: 12px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${headerColor};">
${formatReminderTotalDue(amounts)}
</td>
</tr>
${feeInOtherCurrency ? `
<tr>
<td colspan="2" style="padding: 10px 0 0 0; color: #666; font-size: 12px; line-height: 1.5;">
${REMINDER_FEE_CURRENCY_NOTE}
</td>
</tr>
` : ''}
</table>
</div>
<!-- Payment Details -->
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
</h2>
<table style="width: 100%; border-collapse: collapse;">
${company.bank_name ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">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;">Kontonummer:</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;">IBAN:</td>
<td style="padding: 6px 0;">${company.iban}</td>
</tr>
` : ''}
${company.bic ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">BIC/SWIFT:</td>
<td style="padding: 6px 0;">${company.bic}</td>
</tr>
` : ''}
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
</table>
</div>
<!-- Action Buttons -->
<div style="text-align: center; margin-bottom: 30px;">
<p style="margin: 0 0 20px 0; color: #666; font-size: 14px;">
Har du redan betalat eller har frågor om fakturan?
</p>
<a href="${actionUrl}" style="display: inline-block; background: ${buttonColor}; color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 500; font-size: 14px;">
Hantera faktura
</a>
</div>
<!-- Footer -->
<div style="padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor? Svara direkt på detta mejl så hjälper vi dig.
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${getCompanyPrimaryName(company)}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: ${company.org_number}
${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}
</p>
` : ''}
</div>
</div>
<!-- Unsubscribe note -->
<p style="text-align: center; margin-top: 20px; color: #999; font-size: 12px;">
Detta är ett automatiskt meddelande angående en obetald faktura.
</p>
</div>
</body>
</html>
`
}
/**
* 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
}