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>
This commit is contained in:
Mattsson
2026-08-30 16:06:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e313bfa8ec
commit 3cf2e10740
13 changed files with 672 additions and 55 deletions
+67
View File
@@ -1856,6 +1856,73 @@ describe('UpdateSettingsSchema', () => {
})
})
describe('reminder_text_overrides', () => {
it('accepts a valid nested partial', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: { level_2: { body: 'Betala nu, tack.' } },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.reminder_text_overrides).toEqual({
level_2: { body: 'Betala nu, tack.' },
})
}
})
it('accepts all three levels with subject and body', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: {
level_1: { subject: 'Påminnelse: {fakturanummer}', body: 'Vänligen betala.' },
level_2: { subject: 'Andra påminnelsen', body: 'Betala omgående.' },
level_3: { subject: 'Inkassovarning', body: 'Sista påminnelsen innan inkasso.' },
},
})
expect(result.success).toBe(true)
})
it('accepts null to clear all overrides', () => {
const result = UpdateSettingsSchema.safeParse({ reminder_text_overrides: null })
expect(result.success).toBe(true)
if (result.success) expect(result.data.reminder_text_overrides).toBeNull()
})
it('rejects body over 2000 characters', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: { level_1: { body: 'x'.repeat(2001) } },
})
expect(result.success).toBe(false)
})
it('rejects subject over 200 characters', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: { level_1: { subject: 'x'.repeat(201) } },
})
expect(result.success).toBe(false)
})
it('rejects a non-string field value', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: { level_1: { subject: 123 } },
})
expect(result.success).toBe(false)
})
it('strips unknown keys inside a level object', () => {
const result = UpdateSettingsSchema.safeParse({
reminder_text_overrides: { level_1: { body: 'Hej', subjct: 'typo' } },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.reminder_text_overrides).toEqual({ level_1: { body: 'Hej' } })
}
})
it('rejects a bare string as the column value', () => {
const result = UpdateSettingsSchema.safeParse({ reminder_text_overrides: 'Betala!' })
expect(result.success).toBe(false)
})
})
describe('default_voucher_series_per_source_type', () => {
it('accepts a partial map that omits source types (regression: Zod 4 enum-keyed z.record is exhaustive)', () => {
const result = UpdateSettingsSchema.safeParse({
+19
View File
@@ -2028,6 +2028,21 @@ export const InvoiceEmailTextsSchema = z.object({
en: InvoiceEmailTextsLangSchema.optional(),
})
// Editable reminder email texts per reminder level. Same conventions as
// InvoiceEmailTextsSchema: empty strings pass and are treated as unset by
// the template resolver; the UI prunes empties and stores only diffs from
// the defaults. Subject is a mail header: CR/LF are stripped at render time.
const ReminderTextOverrideLevelSchema = z.object({
subject: z.string().max(200, 'Ämnesraden får vara max 200 tecken').optional(),
body: z.string().max(2000, 'Brödtexten får vara max 2000 tecken').optional(),
})
export const ReminderTextOverridesSchema = z.object({
level_1: ReminderTextOverrideLevelSchema.optional(),
level_2: ReminderTextOverrideLevelSchema.optional(),
level_3: ReminderTextOverrideLevelSchema.optional(),
})
const InvoiceIbanSchema = z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/, 'Ogiltigt IBAN'))
@@ -2225,6 +2240,10 @@ export const UpdateSettingsSchema = z.object({
reminder_days_level_1: z.number().int().min(1).max(365).optional(),
reminder_days_level_2: z.number().int().min(1).max(365).optional(),
reminder_days_level_3: z.number().int().min(1).max(365).optional(),
// Editable reminder email texts: { level_1?: {...}, ... }; null clears all
// overrides. Text only: fee and interest math are computed by the reminder
// processor and never configurable here (Lag 1981:739 caps the fee at 60 kr).
reminder_text_overrides: ReminderTextOverridesSchema.nullable().optional(),
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
reminder_fee_enabled: z.boolean().optional(),
reminder_fee_amount: z
@@ -235,12 +235,7 @@ exports[`reminder mail (template class: reminder) > unbranded: canonical action
Hej Erik,
</p>
<p style="margin: 0 0 15px 0;">
Vi vill påminna dig om att faktura F-1001 förföll till betalning den 2026-06-01.
Om du redan har betalat kan du bortse från denna påminnelse.
</p>
<p style="margin: 0 0 15px 0;">Vi vill påminna dig om att faktura F-1001 förföll till betalning den 2026-06-01. Om du redan har betalat kan du bortse från denna påminnelse.</p>
</div>
<!-- Invoice Summary Box -->
@@ -331,6 +331,7 @@ describe('payment details follow the invoice currency', () => {
}
})
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',
@@ -345,3 +346,123 @@ describe('payment details follow the invoice currency', () => {
}
})
})
describe('reminder email texts: per-company overrides', () => {
const zeroSurcharges = {
interestAmount: 0,
interestRate: 0,
interestFromDate: '2026-05-02',
interestDays: 0,
reminderFee: 0,
}
const withOverrides = (overrides: CompanySettings['reminder_text_overrides']) => ({
...baseData,
...zeroSurcharges,
company: makeCompanySettings({ company_name: 'Acme AB', reminder_text_overrides: overrides }),
})
it('renders the stock texts when no overrides are stored', () => {
const data = withOverrides(null)
const text = generateReminderEmailText(data)
const html = generateReminderEmailHtml(data)
const subject = generateReminderEmailSubject(data)
expect(text).toContain(
'Vi vill påminna dig om att faktura F2026010 förföll till betalning den 2026-05-01.',
)
expect(html).toContain('Om du redan har betalat kan du bortse från denna påminnelse.')
expect(subject).toBe(
`Vänlig påminnelse: Faktura F2026010 - ${formatCurrency(10_000, 'SEK')}`,
)
})
it('the stock final level is an inkassovarning, never fee math', () => {
const data = { ...withOverrides(null), reminderLevel: 3 as const }
const text = generateReminderEmailText(data)
const html = generateReminderEmailHtml(data)
for (const out of [text, html]) {
expect(out).toContain('inkassovarning')
expect(out).toContain('överlämnas fordran till inkasso')
expect(out).toContain('lag (1981:739)')
expect(out).toContain('inom 8 dagar')
// Text only: no fee amount is promised or invented by the template.
expect(out).not.toContain('450')
}
// The opening inkassovarning paragraph keeps the red emphasis.
expect(html).toContain('color: #dc2626; font-weight: 500;')
})
it('a body override wins in HTML and text; the subject stays stock', () => {
const data = withOverrides({ level_1: { body: 'Vår helt egna påminnelsetext.' } })
const text = generateReminderEmailText(data)
const html = generateReminderEmailHtml(data)
for (const out of [text, html]) {
expect(out).toContain('Vår helt egna påminnelsetext.')
expect(out).not.toContain('Vi vill påminna dig om att faktura')
}
expect(generateReminderEmailSubject(data)).toContain('Vänlig påminnelse: Faktura F2026010')
})
it('a subject override wins and owns the whole line: no automatic suffix', () => {
const data = {
...withOverrides({ level_1: { subject: 'Obetald faktura {fakturanummer}' } }),
interestAmount: 86.3,
interestRate: 0.105,
interestDays: 30,
reminderFee: 60,
}
const subject = generateReminderEmailSubject(data)
expect(subject).toBe('Obetald faktura F2026010')
expect(subject).not.toContain('inkl. dröjsmålsränta')
// The body is untouched by a subject-only override.
expect(generateReminderEmailText(data)).toContain('Vi vill påminna dig om att faktura')
})
it('substitutes the documented placeholders in overrides', () => {
const data = withOverrides({
level_1: {
body: 'Faktura {fakturanummer} till {kundnamn} ({förnamn}) från {företag} förföll {förfallodatum} ({dagar} dagar sedan). Att betala: {belopp}. Skickad {fakturadatum}.',
},
})
const text = generateReminderEmailText(data)
expect(text).toContain(
`Faktura F2026010 till Erik Andersson (Erik) från Acme AB förföll 2026-05-01 (25 dagar sedan). Att betala: ${formatCurrency(10_000, 'SEK')}. Skickad 2026-04-15.`,
)
})
it('leaves unknown placeholders as literal text', () => {
const data = withOverrides({ level_1: { body: 'Hej {okänd} värld' } })
expect(generateReminderEmailText(data)).toContain('Hej {okänd} värld')
})
it('falls back to the default for whitespace-only override fields', () => {
const data = withOverrides({ level_1: { subject: ' ', body: '\n' } })
expect(generateReminderEmailSubject(data)).toContain('Vänlig påminnelse: Faktura F2026010')
expect(generateReminderEmailText(data)).toContain('Vi vill påminna dig om att faktura')
})
it('escapes override content in HTML and keeps paragraph breaks', () => {
const data = withOverrides({
level_1: { body: 'Första stycket <script>alert(1)</script>\n\nAndra stycket\nmed radbrytning' },
})
const html = generateReminderEmailHtml(data)
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
expect(html).toContain('Andra stycket<br>med radbrytning')
// Two paragraphs -> two <p> blocks.
expect(html.match(/Första stycket/g)).toHaveLength(1)
expect(html).toContain('<p style="margin: 0 0 15px 0;">Andra stycket<br>med radbrytning</p>')
})
it('applies an override only to its own level', () => {
const overrides = { level_2: { body: 'Egen text för nivå två.' } }
const level1 = generateReminderEmailText(withOverrides(overrides))
const level2 = generateReminderEmailText({
...withOverrides(overrides),
reminderLevel: 2 as const,
})
expect(level1).not.toContain('Egen text för nivå två.')
expect(level1).toContain('Vi vill påminna dig')
expect(level2).toContain('Egen text för nivå två.')
expect(level2).not.toContain('Trots vår tidigare påminnelse')
})
})
+165 -49
View File
@@ -1,7 +1,8 @@
import type { Invoice, Customer, CompanySettings } from '@/types'
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
@@ -125,6 +126,151 @@ const REMINDER_CONFIG = {
}
} 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
*/
@@ -192,31 +338,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
</p>
${reminderLevel === 1 ? `
<p style="margin: 0 0 15px 0;">
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.
</p>
` : reminderLevel === 2 ? `
<p style="margin: 0 0 15px 0;">
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)}.
</p>
<p style="margin: 0 0 15px 0;">
Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.
</p>
` : `
<p style="margin: 0 0 15px 0; color: #dc2626; font-weight: 500;">
Detta är vår slutliga påminnelse gällande faktura ${invoice.invoice_number}.
</p>
<p style="margin: 0 0 15px 0;">
Fakturan förföll till betalning den ${formatDate(invoice.due_date)} och vi har ännu inte mottagit betalning
trots tidigare påminnelser.
</p>
<p style="margin: 0 0 15px 0;">
Om betalning inte inkommer inom 7 dagar kommer ärendet att överlämnas för vidare hantering.
</p>
`}
${reminderBodyToHtml(resolveReminderBody(data), reminderLevel)}
</div>
<!-- Invoice Summary Box -->
@@ -391,17 +513,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
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 += `${resolveReminderBody(data)}\n\n`
text += `Fakturasammanfattning:\n`
text += `-`.repeat(30) + `\n`
@@ -455,21 +567,25 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
* the email.
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
const { invoice, company, reminderLevel, interestAmount, reminderFee } = data
const config = REMINDER_CONFIG[reminderLevel]
const { company, reminderLevel, interestAmount, reminderFee } = data
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: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
})
const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : ''
// {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)
}
return `${config.title}: Faktura ${invoice.invoice_number} - ${formatReminderTotalDue(amounts)}${suffix}`
const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : ''
const stock = applyPlaceholders(
REMINDER_EMAIL_DEFAULT_TEXTS[levelKey(reminderLevel)].subject,
values,
)
return sanitizeSubjectLine(`${stock}${suffix}`)
}
/**