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
parent e313bfa8ec
commit 3cf2e10740
13 changed files with 672 additions and 55 deletions
+1
View File
@@ -1357,4 +1357,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload).
[2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason.
[2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff.
[2026-08-30] Reminder text overrides (company_settings.reminder_text_overrides, level_1..3 x subject/body): the defaults are expressed as placeholder patterns (REMINDER_EMAIL_DEFAULT_TEXTS) and BOTH the stock mail and overrides render through the same substitution pipeline (applyPlaceholders + escape per output variant), so the settings-UI prefill is byte-for-byte the mail that goes out and cannot drift; this differs from the invoice_email_texts precedent, whose hand-written pattern forms can drift from the coded defaults. The level-3 default body is now an explicit inkassovarning (8 days, fordran till inkasso, costs per lag (1981:739)) but the level TITLE stays 'Slutlig paminnelse': the title is reused as the level name in settings labels and subject prefix, and renaming it everywhere is wording churn beyond the ask. An overridden subject owns the whole line (no automatic ' (inkl. drojsmalsranta)' suffix; {belopp} already includes surcharges), the stock subject keeps the suffix byte-identically. No pg test for the migration: a declarative CHECK (jsonb_typeof object) identical in shape to invoice_email_texts (20260703091000), which also shipped without one. The v1 REST/MCP update_company_settings surface was NOT extended: it is a curated field set with staged operations and its own placeholder refinement, a separate parity slice. typecheck/antipattern baselines deliberately not ratcheted in this diff: both one-count drops predate the branch (main drift), gates only fail on increase.
[2026-08-30] PR #2021 round 2 (#546): the relayed Peppol buyer restriction now says the customer's org number must not be a personnummer (prepareParty('buyer') in lib/invoices/peppol-bis-billing.ts refuses it with BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED, so an enskild firma CUSTOMER is refused, not only an enskild firma sender), Step 4 of the invoicing-rules workflow points at the Peppol section so a top-down reader never reaches the external-provider fallback first, the mark-sent recovery is scoped to the still-draft invoice in every text (INVOICE_MARK_SENT_REPAIR_REQUIRED leaves the invoice sent with the verifikat posted and a second mark-sent returns 409; the reviewer's proposed repair tool gnubok_link_invoice_to_voucher is the PAYMENT link and requires status sent/overdue/partially_paid, so no tool is named and the repair is left to support), and the verifikat parenthetical says "under faktureringsmetoden" (kontantmetod and defer_invoice_booking companies get none at issue). The guard test now also pins the two v1 route descriptions by reading the route source (apiskill:check only detects generated-vs-source drift, not a truth regression). The atom bump was seeded as a THIRD append-only migration (20260830101500, atom v9) rather than consolidating to one: the Supabase preview branch for the PR (xxnqggttsefleehmarjo) has applied both 20260829000100 and 20260829010000 per its schema_migrations, so deleting either would leave a remote with versions absent from the repo, the orphan class the migration rule forbids; all three seeds are idempotent upserts with the version guard, so prod applying them in sequence ends at v9. The generator's max-plus-one name (20260829010001) was renamed to 20260830101500 for the same reason as round 1 (newer than every file on origin/main and every sibling worktree; skills:check hashes content, the pg replay test globs the seed).
@@ -0,0 +1,222 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import {
REMINDER_EMAIL_DEFAULT_TEXTS,
REMINDER_EMAIL_PLACEHOLDER_KEYS,
type ReminderLevelKey,
} from '@/lib/email/reminder-templates'
import type { CompanySettings, ReminderTextOverride, ReminderTextOverrides } from '@/types'
interface ReminderEmailTextsSettingsProps {
settings: CompanySettings
onUpdate: (updates: Partial<CompanySettings>) => void
}
type Field = keyof ReminderTextOverride
const LEVELS: ReminderLevelKey[] = ['level_1', 'level_2', 'level_3']
const FIELD_CONFIG: Array<{ field: Field; labelKey: string; multiline?: boolean }> = [
{ field: 'subject', labelKey: 'subject_label' },
{ field: 'body', labelKey: 'body_label', multiline: true },
]
// The editor always shows the EFFECTIVE text (override or standard), never an
// empty field: users see and edit the mail that actually goes out.
type DisplayTexts = Record<ReminderLevelKey, Record<Field, string>>
function buildDisplay(stored: ReminderTextOverrides | null | undefined): DisplayTexts {
const result = {} as DisplayTexts
for (const level of LEVELS) {
result[level] = {} as Record<Field, string>
for (const { field } of FIELD_CONFIG) {
const value = stored?.[level]?.[field]
result[level][field] =
typeof value === 'string' && value.trim() !== ''
? value
: REMINDER_EMAIL_DEFAULT_TEXTS[level][field]
}
}
return result
}
// Cleared fields have no meaning of their own: snap them back to standard.
function normalize(display: DisplayTexts): DisplayTexts {
const result = {} as DisplayTexts
for (const level of LEVELS) {
result[level] = {} as Record<Field, string>
for (const { field } of FIELD_CONFIG) {
const value = display[level][field]
result[level][field] =
value.trim() === '' ? REMINDER_EMAIL_DEFAULT_TEXTS[level][field] : value
}
}
return result
}
// Store only changes: a field equal to the standard text is NOT an override,
// so future improvements to the standard wording reach every company that
// hasn't customized. Empty result -> null (column reads "all defaults").
function toOverrides(display: DisplayTexts): ReminderTextOverrides | null {
const result: ReminderTextOverrides = {}
for (const level of LEVELS) {
const levelOverrides: ReminderTextOverride = {}
for (const { field } of FIELD_CONFIG) {
const value = display[level][field].trim()
if (value !== '' && value !== REMINDER_EMAIL_DEFAULT_TEXTS[level][field]) {
levelOverrides[field] = value
}
}
if (Object.keys(levelOverrides).length > 0) result[level] = levelOverrides
}
return Object.keys(result).length > 0 ? result : null
}
export function ReminderEmailTextsSettings({ settings, onUpdate }: ReminderEmailTextsSettingsProps) {
const t = useTranslations('settings_reminder_texts')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [level, setLevel] = useState<ReminderLevelKey>('level_1')
const [texts, setTexts] = useState<DisplayTexts>(() =>
buildDisplay(settings.reminder_text_overrides),
)
// Serialized last-persisted overrides: skips no-op PUTs on blur without
// edits. toOverrides() builds keys in a fixed order, so comparison is stable.
const lastSavedRef = useRef<string>(
JSON.stringify(toOverrides(buildDisplay(settings.reminder_text_overrides))),
)
const setField = (level: ReminderLevelKey, field: Field, value: string) => {
setTexts((prev) => ({ ...prev, [level]: { ...prev[level], [field]: value } }))
}
// Serializes the whole-object saves below: a blur and a reset can otherwise
// race, and the older snapshot would replace the newer JSONB value.
const saveQueueRef = useRef<Promise<void>>(Promise.resolve())
// Whole-object save: a JSONB column update replaces the stored value, and
// the inactive levels' fields are unmounted (conditional render below),
// so per-field PATCHes can't work. Writes are queued so they reach the
// server in submission order.
const persist = useCallback((display: DisplayTexts) => {
const overrides = toOverrides(display)
const serialized = JSON.stringify(overrides)
saveQueueRef.current = saveQueueRef.current.then(async () => {
if (serialized === lastSavedRef.current) return
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_text_overrides: overrides }),
})
if (!response.ok) throw new Error()
lastSavedRef.current = serialized
onUpdate({ reminder_text_overrides: overrides })
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
})
return saveQueueRef.current
}, [onUpdate, toast, t])
const handleBlur = () => {
const normalized = normalize(texts)
setTexts(normalized)
void persist(normalized)
}
const resetField = (level: ReminderLevelKey, field: Field) => {
const next = {
...texts,
[level]: { ...texts[level], [field]: REMINDER_EMAIL_DEFAULT_TEXTS[level][field] },
}
setTexts(next)
void persist(next)
}
return (
<SettingsGroup
label={t('heading')}
help={
<div className="space-y-2">
<p>{t('description')}</p>
{/* Legend is rendered from code, not messages/*.json: ICU message
syntax treats literal braces as interpolation. */}
<p>
{t('placeholders_help')}{' '}
{REMINDER_EMAIL_PLACEHOLDER_KEYS.map((key) => (
<code key={key} className="mr-1 rounded-sm bg-muted px-1 text-xs">{`{${key}}`}</code>
))}
</p>
<p>{t('amounts_note')}</p>
</div>
}
>
<div className="flex flex-wrap items-center gap-3 border-b border-border px-1 py-3">
<SettingsSeg
value={level}
onChange={setLevel}
options={[
{ value: 'level_1', label: t('tab_level_1') },
{ value: 'level_2', label: t('tab_level_2') },
{ value: 'level_3', label: t('tab_level_3') },
]}
aria-label={t('heading')}
/>
{level === 'level_3' && <SettingsRowNote>{t('level_3_hint')}</SettingsRowNote>}
</div>
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
const id = `reminder-email-${field}-${level}`
const modified =
texts[level][field].trim() !== REMINDER_EMAIL_DEFAULT_TEXTS[level][field]
const common = {
id,
value: texts[level][field],
onBlur: handleBlur,
disabled: !canWrite,
}
return (
<SettingsRow key={`${level}-${field}`} label={t(labelKey)} htmlFor={id} align="baseline">
{multiline ? (
<SettingsTextarea
{...common}
rows={6}
onChange={(e) => setField(level, field, e.target.value)}
/>
) : (
<SettingsInput
{...common}
onChange={(e) => setField(level, field, e.target.value)}
/>
)}
{modified && canWrite && (
<SettingsRowEnd>
<button
type="button"
onClick={() => resetField(level, field)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{t('reset_label')}
</button>
</SettingsRowEnd>
)}
</SettingsRow>
)
})}
</SettingsGroup>
)
}
@@ -6,6 +6,7 @@ import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePayment
import { PeppolReceiveSettings } from '@/components/settings/PeppolReceiveSettings'
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
import { ReminderEmailTextsSettings } from '@/components/settings/ReminderEmailTextsSettings'
import { InvoiceEmailRecipientsSettings } from '@/components/settings/InvoiceEmailRecipientsSettings'
import { InvoiceSenderDomainSettings } from '@/components/settings/InvoiceSenderDomainSettings'
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
@@ -80,6 +81,9 @@ export function InvoicingSettingsContent() {
{/* Invoice email texts: autosaves on blur */}
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
{/* Reminder email texts per level: autosaves on blur */}
<ReminderEmailTextsSettings settings={settings} onUpdate={updateSettings} />
</div>
)
}
+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}`)
}
/**
+14
View File
@@ -2371,6 +2371,20 @@
"firstname_note": "The first-name placeholder uses the first word of the customer's name.",
"toast_save_failed": "Could not save"
},
"settings_reminder_texts": {
"heading": "Reminder texts",
"description": "The texts in the reminder emails, per reminder level. Edit freely: only your changes are saved.",
"reset_label": "Reset to standard",
"tab_level_1": "Reminder 1",
"tab_level_2": "Reminder 2",
"tab_level_3": "Final reminder",
"level_3_hint": "The final reminder is a debt collection warning: the last notice before the claim can be handed to collection.",
"subject_label": "Subject line",
"body_label": "Body text",
"placeholders_help": "Available placeholders (unknown ones are left as text):",
"amounts_note": "Amounts, late payment interest and the reminder fee are always calculated automatically and are not controlled by the texts.",
"toast_save_failed": "Could not save"
},
"settings_invoice_email_recipients": {
"heading": "Invoice email recipients",
"description": "These recipients are added automatically to every invoice email. You can add more recipients when sending an individual invoice.",
+14
View File
@@ -2371,6 +2371,20 @@
"firstname_note": "Platshållaren förnamn använder första ordet i kundens namn.",
"toast_save_failed": "Kunde inte spara"
},
"settings_reminder_texts": {
"heading": "Påminnelsetexter",
"description": "Texterna i påminnelsemejlen, per påminnelsenivå. Ändra fritt: bara dina ändringar sparas.",
"reset_label": "Återställ till standard",
"tab_level_1": "Påminnelse 1",
"tab_level_2": "Påminnelse 2",
"tab_level_3": "Slutlig påminnelse",
"level_3_hint": "Den slutliga påminnelsen är en inkassovarning: sista meddelandet innan fordran kan lämnas till inkasso.",
"subject_label": "Ämnesrad",
"body_label": "Brödtext",
"placeholders_help": "Tillgängliga platshållare (okända lämnas kvar som text):",
"amounts_note": "Belopp, dröjsmålsränta och påminnelseavgift beräknas alltid automatiskt och styrs inte av texterna.",
"toast_save_failed": "Kunde inte spara"
},
"settings_invoice_email_recipients": {
"heading": "Mottagare vid fakturautskick",
"description": "Dessa mottagare läggs automatiskt till på varje fakturamejl. Du kan komplettera listan när en enskild faktura skickas.",
@@ -0,0 +1,23 @@
-- Per-company editable reminder email texts per reminder level (1-3).
-- NULL column / missing keys / whitespace-only values fall back to the
-- defaults in lib/email/reminder-templates.ts (REMINDER_EMAIL_DEFAULT_TEXTS);
-- only diffs from the defaults are stored (prefill-override convention).
-- TEXT only: reminder fee and interest math live in the reminder processor
-- and are not configurable here (Lag 1981:739 caps the paminnelseavgift at
-- 60 kr; no per-step fee logic exists or is added).
-- Length limits are enforced by UpdateSettingsSchema (the only write path);
-- mirrors the invoice_email_texts precedent (20260703091000).
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS reminder_text_overrides JSONB NULL;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_reminder_text_overrides_object;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_reminder_text_overrides_object
CHECK (reminder_text_overrides IS NULL OR jsonb_typeof(reminder_text_overrides) = 'object');
COMMENT ON COLUMN public.company_settings.reminder_text_overrides IS
'Overrides for reminder emails: { level_1?: { subject?, body? }, level_2?: {...}, level_3?: {...} }. Placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {fakturadatum} {förfallodatum} {belopp} {dagar} are substituted at send time by lib/email/reminder-templates.ts. NULL / missing / whitespace-only fields fall back to the hardcoded defaults. Text only; never affects fee or interest math.';
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -617,6 +617,7 @@ export function makeCompanySettings(
invoice_email_texts: null,
invoice_payment_links_enabled: false,
send_invoice_reminders: true,
reminder_text_overrides: null,
reminder_days_level_1: 15,
reminder_days_level_2: 30,
reminder_days_level_3: 45,
+20
View File
@@ -366,6 +366,24 @@ export interface InvoiceEmailTexts {
en?: InvoiceEmailTextOverrides
}
// Editable reminder email texts per reminder level (Swedish only, matching
// the reminder templates). Missing / whitespace-only fields fall back to the
// defaults in lib/email/reminder-templates.ts (REMINDER_EMAIL_DEFAULT_TEXTS).
// Supports the fixed placeholder set {fakturanummer} {kundnamn} {förnamn}
// {företag} {fakturadatum} {förfallodatum} {belopp} {dagar}. TEXT only:
// reminder fee and interest math are unaffected (Lag 1981:739 caps the
// påminnelseavgift at 60 kr; the 450 kr förseningsersättning is out of scope).
export interface ReminderTextOverride {
subject?: string
body?: string
}
export interface ReminderTextOverrides {
level_1?: ReminderTextOverride
level_2?: ReminderTextOverride
level_3?: ReminderTextOverride
}
export type InvoiceFontFamily =
| 'Helvetica'
| 'Times-Roman'
@@ -544,6 +562,8 @@ export interface CompanySettings {
reminder_days_level_1: number
reminder_days_level_2: number
reminder_days_level_3: number
// Editable reminder email texts per level. null = all defaults.
reminder_text_overrides: ReminderTextOverrides | null
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
reminder_fee_enabled: boolean