import type { Invoice, Customer, CompanySettings } from '@/types' import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' 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 /** * Generate HTML email for payment reminder */ export function generateReminderEmailHtml(data: ReminderEmailData): string { const { invoice, customer, company, reminderLevel, daysOverdue, actionUrl, interestAmount, interestRate, interestDays, reminderFee, } = data 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: invoice.total, 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]}` : ''},

${reminderLevel === 1 ? `

Vi vill påminna dig om att faktura ${invoice.invoice_number} förföll till betalning den ${formatDate(invoice.due_date)}. Om du redan har betalat kan du bortse från denna påminnelse.

` : reminderLevel === 2 ? `

Trots vår tidigare påminnelse har vi ännu inte mottagit betalning för faktura ${invoice.invoice_number} som förföll den ${formatDate(invoice.due_date)}.

Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.

` : `

Detta är vår slutliga påminnelse gällande faktura ${invoice.invoice_number}.

Fakturan förföll till betalning den ${formatDate(invoice.due_date)} och vi har ännu inte mottagit betalning trots tidigare påminnelser.

Om betalning inte inkommer inom 7 dagar kommer ärendet att överlämnas för vidare hantering.

`}
${hasInterest ? ` ` : ''} ${hasFee ? ` ` : ''} ${hasSurcharges ? ` ` : ''} ${feeInOtherCurrency ? ` ` : ''}
Fakturanummer: ${invoice.invoice_number}
Fakturadatum: ${formatDate(invoice.invoice_date)}
Förfallodatum: ${formatDate(invoice.due_date)}
Ursprungligt belopp: ${formatCurrency(invoice.total, 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,
${getCompanyPrimaryName(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, company, reminderLevel, daysOverdue, actionUrl, interestAmount, interestRate, interestDays, reminderFee, } = data 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: invoice.total, 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` if (reminderLevel === 1) { text += `Vi vill påminna dig om att faktura ${invoice.invoice_number} förföll till betalning den ${formatDate(invoice.due_date)}.\n` text += `Om du redan har betalat kan du bortse från denna påminnelse.\n\n` } else if (reminderLevel === 2) { text += `Trots vår tidigare påminnelse har vi ännu inte mottagit betalning för faktura ${invoice.invoice_number} som förföll den ${formatDate(invoice.due_date)}.\n\n` text += `Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.\n\n` } else { text += `DETTA ÄR VÅR SLUTLIGA PÅMINNELSE\n\n` text += `Fakturan förföll till betalning den ${formatDate(invoice.due_date)} och vi har ännu inte mottagit betalning trots tidigare påminnelser.\n\n` text += `Om betalning inte inkommer inom 7 dagar kommer ärendet att överlämnas för vidare hantering.\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(invoice.total, 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 { invoice, reminderLevel, interestAmount, reminderFee } = data const config = REMINDER_CONFIG[reminderLevel] const hasSurcharges = interestAmount > 0 || reminderFee > 0 // 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 amounts = calculateReminderAmounts({ invoiceTotal: invoice.total, interestAmount, reminderFee, currency: invoice.currency, }) const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : '' return `${config.title}: Faktura ${invoice.invoice_number} - ${formatReminderTotalDue(amounts)}${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 }