fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN (#1806)

* 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:<currency> in the result, instead of going
out with no (or the wrong) payment details.

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

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-23 01:17:27 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bf3104dd21
commit 1f6dd778e5
5 changed files with 278 additions and 2 deletions
+47
View File
@@ -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<ProcessRemindersResult>
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.