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:
co-authored by
Claude Fable 5
parent
bf3104dd21
commit
1f6dd778e5
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user