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({