From 1f6dd778e5e4f080693e307306448a7b20f58d17 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:17:27 +0200 Subject: [PATCH] fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN (#1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN Invoice PDF and invoice email resolve payment details by invoice currency (invoice_payment_accounts, #1116), but the overdue-reminder templates still read the legacy company_settings fields, so a EUR reminder printed the SEK account's IBAN. A customer has paid to the wrong account from one. Both reminder generators now go through companyWithInvoicePaymentAccount, and sendReminder applies the same gate as invoice send: no usable account for the invoice currency means the reminder is skipped with INVOICE_PAYMENT_ACCOUNT_MISSING: in the result, instead of going out with no (or the wrong) payment details. Co-Authored-By: Claude Fable 5 * fix(invoices): run the reminder payment-account gate before the fee entry and reminder row Skeptic refutation (3/3): the gate lived in sendReminder, after processOverdueReminders had already posted the 60 kr påminnelseavgift verifikat and inserted the invoice_reminders row, so a skipped EUR reminder booked a fee for an email never sent and burned the level for good. The gate now runs in the loop before any write; sendReminder keeps it only as a backstop for direct callers. New processor test asserts no fee entry, no row and no email for the skipped case, and all three once an account exists. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../__tests__/reminder-templates.test.ts | 51 +++++++ lib/email/reminder-templates.ts | 9 +- .../reminder-processor-payment-gate.test.ts | 142 ++++++++++++++++++ .../__tests__/reminder-processor.test.ts | 31 ++++ lib/invoices/reminder-processor.ts | 47 ++++++ 5 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 lib/invoices/__tests__/reminder-processor-payment-gate.test.ts diff --git a/lib/email/__tests__/reminder-templates.test.ts b/lib/email/__tests__/reminder-templates.test.ts index 98b46bf0..4fc502d4 100644 --- a/lib/email/__tests__/reminder-templates.test.ts +++ b/lib/email/__tests__/reminder-templates.test.ts @@ -10,6 +10,7 @@ import { } from '../reminder-templates' import { formatCurrency } from '@/lib/utils' import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers' +import type { CompanySettings } from '@/types' const company = makeCompanySettings({ company_name: 'Acme AB' }) const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se' }) @@ -294,3 +295,53 @@ describe('reminder email templates: ROT/RUT-avdrag (fakturamodellen)', () => { expect(amounts.totalDue).toBe(8_822.5) }) }) + +describe('payment details follow the invoice currency', () => { + const multiCurrencyCompany = makeCompanySettings({ + company_name: 'Acme AB', + bank_name: 'Svenska Banken', + iban: 'SE4550000000058398257466', + bic: 'ESSESESS', + invoice_payment_accounts: { + SEK: { bank_name: 'Svenska Banken', iban: 'SE4550000000058398257466', bic: 'ESSESESS' }, + EUR: { bank_name: 'Deutsche Bank', iban: 'DE89370400440532013000', bic: 'DEUTDEFF' }, + } as CompanySettings['invoice_payment_accounts'], + }) + + it('prints the EUR account on a EUR reminder, never the SEK IBAN', () => { + const data = { ...baseData, company: multiCurrencyCompany, invoice: eurInvoice } + const html = generateReminderEmailHtml(data) + const text = generateReminderEmailText(data) + for (const out of [html, text]) { + expect(out).toContain('DE89370400440532013000') + expect(out).toContain('DEUTDEFF') + expect(out).toContain('Deutsche Bank') + expect(out).not.toContain('SE4550000000058398257466') + expect(out).not.toContain('ESSESESS') + } + }) + + it('keeps the SEK account on a SEK reminder', () => { + const data = { ...baseData, company: multiCurrencyCompany, invoice } + const html = generateReminderEmailHtml(data) + const text = generateReminderEmailText(data) + for (const out of [html, text]) { + expect(out).toContain('SE4550000000058398257466') + expect(out).not.toContain('DE89370400440532013000') + } + }) + + it('never falls back to the legacy SEK fields for a EUR reminder when no EUR account exists', () => { + const sekOnly = makeCompanySettings({ + company_name: 'Acme AB', + bank_name: 'Svenska Banken', + iban: 'SE4550000000058398257466', + bic: 'ESSESESS', + }) + const data = { ...baseData, company: sekOnly, invoice: eurInvoice } + for (const out of [generateReminderEmailHtml(data), generateReminderEmailText(data)]) { + expect(out).not.toContain('SE4550000000058398257466') + expect(out).not.toContain('Svenska Banken') + } + }) +}) diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts index a31faa96..86c185e9 100644 --- a/lib/email/reminder-templates.ts +++ b/lib/email/reminder-templates.ts @@ -1,6 +1,7 @@ import type { Invoice, Customer, CompanySettings } from '@/types' import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils' import { getAmountToPay } from '@/lib/invoices/rounding' +import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts' /** * What the customer was asked to pay on the original invoice: the öre-rounded @@ -131,7 +132,6 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string { const { invoice, customer, - company, reminderLevel, daysOverdue, actionUrl, @@ -140,6 +140,9 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string { 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, @@ -357,7 +360,6 @@ export function generateReminderEmailText(data: ReminderEmailData): string { const { invoice, customer, - company, reminderLevel, daysOverdue, actionUrl, @@ -366,6 +368,9 @@ export function generateReminderEmailText(data: ReminderEmailData): string { 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, diff --git a/lib/invoices/__tests__/reminder-processor-payment-gate.test.ts b/lib/invoices/__tests__/reminder-processor-payment-gate.test.ts new file mode 100644 index 00000000..853cb00c --- /dev/null +++ b/lib/invoices/__tests__/reminder-processor-payment-gate.test.ts @@ -0,0 +1,142 @@ +/** + * processOverdueReminders: the payment-account gate runs BEFORE any write. + * + * A reminder that cannot go out (no usable payment account for the invoice + * currency) must leave no trace: no reminder-fee journal entry, no + * invoice_reminders row (which would burn the level), no email. Once an + * account exists, the same invoice books the fee, inserts the row and sends. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers' + +const { mockSendEmail, mockCreateReminderFeeEntry, state } = vi.hoisted(() => ({ + mockSendEmail: vi.fn(), + mockCreateReminderFeeEntry: vi.fn(), + state: { + invoices: [] as unknown[], + company: null as unknown, + inserts: [] as Array<{ table: string; payload: unknown }>, + }, +})) + +vi.mock('@supabase/ssr', () => { + // Table-aware chain: `then` resolves the list for the table, `single` + // resolves the single-row shape, `insert` is recorded. + const buildChain = (table: string): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve({ + data: table === 'invoices' ? state.invoices : [], + error: null, + count: null, + }) + } + if (prop === 'single') { + return async () => { + if (table === 'company_settings') return { data: state.company, error: null } + if (table === 'invoices') return { data: { status: 'sent', credit_notes: [] }, error: null } + if (table === 'invoice_reminders') return { data: { action_token: 'tok-1' }, error: null } + return { data: null, error: null } + } + } + if (prop === 'insert') { + return (payload: unknown) => { + state.inserts.push({ table, payload }) + return buildChain(table) + } + } + return () => buildChain(table) + }, + }, + ) + return { + createServerClient: vi.fn(() => ({ + from: vi.fn((table: string) => buildChain(table)), + rpc: vi.fn(() => buildChain('rpc')), + })), + } +}) + +vi.mock('@/lib/email/invoice-sender', () => ({ + resolveInvoiceSender: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ sendEmail: mockSendEmail }), +})) +vi.mock('@/lib/bookkeeping/reminder-fee-entries', () => ({ + createReminderFeeEntry: mockCreateReminderFeeEntry, +})) + +import { processOverdueReminders } from '../reminder-processor' + +function overdueEurInvoice() { + const due = new Date() + due.setDate(due.getDate() - 20) + return { + ...makeInvoice({ + id: 'inv-eur', + invoice_number: 'F2026099', + currency: 'EUR', + total: 1_000, + status: 'sent', + due_date: due.toISOString().split('T')[0], + }), + customer: makeCustomer({ email: 'kund@example.se' }), + credit_notes: [], + } +} + +describe('processOverdueReminders: payment-account gate before writes', () => { + beforeEach(() => { + vi.clearAllMocks() + state.invoices = [overdueEurInvoice()] + state.inserts.length = 0 + mockSendEmail.mockResolvedValue({ success: true }) + mockCreateReminderFeeEntry.mockResolvedValue({ journal_entry_id: 'je-fee' }) + }) + + it('books nothing and inserts nothing when the company has no account for the invoice currency', async () => { + state.company = makeCompanySettings({ + bankgiro: '123-4567', + iban: 'SE4550000000058398257466', + reminder_fee_enabled: true, + reminder_fee_amount: 60, + } as never) + + const result = await processOverdueReminders() + + expect(result.processed).toBe(1) + expect(result.failed).toBe(1) + expect(result.results[0]).toMatchObject({ + invoiceId: 'inv-eur', + reminderLevel: 1, + success: false, + error: 'INVOICE_PAYMENT_ACCOUNT_MISSING:EUR', + }) + expect(mockCreateReminderFeeEntry).not.toHaveBeenCalled() + expect(state.inserts.filter((i) => i.table === 'invoice_reminders')).toHaveLength(0) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('books the fee, inserts the row and sends once a EUR account is configured', async () => { + state.company = makeCompanySettings({ + reminder_fee_enabled: true, + reminder_fee_amount: 60, + invoice_payment_accounts: { EUR: { iban: 'DE89370400440532013000', bic: 'DEUTDEFF' } }, + } as never) + + const result = await processOverdueReminders() + + expect(result.sent).toBe(1) + expect(mockCreateReminderFeeEntry).toHaveBeenCalledTimes(1) + expect(state.inserts.filter((i) => i.table === 'invoice_reminders')).toHaveLength(1) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + const html = (mockSendEmail.mock.calls[0][0] as { html: string }).html + expect(html).toContain('DE89370400440532013000') + expect(html).not.toContain('SE4550000000058398257466') + }) +}) diff --git a/lib/invoices/__tests__/reminder-processor.test.ts b/lib/invoices/__tests__/reminder-processor.test.ts index 5e833ae8..a55e6153 100644 --- a/lib/invoices/__tests__/reminder-processor.test.ts +++ b/lib/invoices/__tests__/reminder-processor.test.ts @@ -42,7 +42,9 @@ import { processOverdueReminders, determineReminderLevel, calculateDaysOverdue, + sendReminder, } from '../reminder-processor' +import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers' import { getReminderDaysConfig } from '@/lib/email/reminder-templates' describe('determineReminderLevel', () => { @@ -173,3 +175,32 @@ describe('processOverdueReminders: credit-note filter', () => { expect(inStatus?.args[1]).toContain('overdue') }) }) + +describe('sendReminder payment-account gate', () => { + const customer = makeCustomer({ email: 'kund@example.se' }) + const surcharges = { interestAmount: 0, interestRate: 0.1, interestDays: 0, reminderFee: 60 } + + it('skips a EUR reminder when the company has no EUR payment account (no SEK fallback)', async () => { + const sekOnly = makeCompanySettings({ bankgiro: '123-4567', iban: 'SE4550000000058398257466' }) + const invoice = { ...makeInvoice({ currency: 'EUR', total: 500 }), customer } + const result = await sendReminder(invoice, sekOnly, 1, 'tok', surcharges) + expect(result.success).toBe(false) + expect(result.error).toBe('INVOICE_PAYMENT_ACCOUNT_MISSING:EUR') + }) + + it('sends a SEK reminder on legacy SEK details', async () => { + const sekOnly = makeCompanySettings({ bankgiro: '123-4567' }) + const invoice = { ...makeInvoice({ currency: 'SEK', total: 500 }), customer } + const result = await sendReminder(invoice, sekOnly, 1, 'tok', surcharges) + expect(result.success).toBe(true) + }) + + it('sends a EUR reminder once a EUR account is configured', async () => { + const company = makeCompanySettings({ + invoice_payment_accounts: { EUR: { iban: 'DE89370400440532013000', bic: 'DEUTDEFF' } } as never, + }) + const invoice = { ...makeInvoice({ currency: 'EUR', total: 500 }), customer } + const result = await sendReminder(invoice, company, 1, 'tok', surcharges) + expect(result.success).toBe(true) + }) +}) diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index 847e2cdd..53aa5154 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -10,6 +10,10 @@ import { type ReminderDaysConfig, } from '@/lib/email/reminder-templates' import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest' +import { + hasUsableInvoicePaymentAccount, + resolveInvoicePaymentAccount, +} from '@/lib/invoices/payment-accounts' import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries' import { createLogger } from '@/lib/logger' import type { Invoice, Customer, CompanySettings } from '@/types' @@ -119,6 +123,20 @@ export async function sendReminder( return { success: false, error: 'Customer has no email' } } + // Backstop for direct callers: processOverdueReminders applies this same + // gate BEFORE booking the fee and inserting the reminder row. A reminder + // with no payment account for the invoice currency would print nothing to + // pay to, or (before this gate) the SEK account's IBAN on a EUR invoice. + const currency = invoice.currency + if (!hasUsableInvoicePaymentAccount(resolveInvoicePaymentAccount(company, currency), currency)) { + log.warn('Skipping reminder: no payment account configured for invoice currency', { + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + currency, + }) + return { success: false, error: `INVOICE_PAYMENT_ACCOUNT_MISSING:${currency}` } + } + const daysOverdue = calculateDaysOverdue(invoice.due_date) // Build action URL (public page for customer response) @@ -267,6 +285,35 @@ export async function processOverdueReminders(): Promise continue } + // Payment-account gate, BEFORE any write: the fee journal entry and the + // invoice_reminders row below must not exist for a reminder that never + // goes out (that would book a 60 kr fee and burn the level for an email + // the customer never got). Same rule as invoice send: no usable account + // for the invoice currency means no reminder until the user configures + // one under Inställningar; the level stays open and fires next run. + const invoiceCurrency = invoice.currency + if ( + !hasUsableInvoicePaymentAccount( + resolveInvoicePaymentAccount(company as CompanySettings, invoiceCurrency), + invoiceCurrency, + ) + ) { + log.warn('Skipping reminder: no payment account configured for invoice currency', { + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + currency: invoiceCurrency, + }) + results.push({ + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + customerEmail: customer.email, + reminderLevel, + success: false, + error: `INVOICE_PAYMENT_ACCOUNT_MISSING:${invoiceCurrency}`, + }) + continue + } + // Race-window guard: re-check invoice status immediately before sending. // The cron runs at 08:00; a payment match arriving during the run shouldn't // produce a reminder for an already-paid invoice.