diff --git a/DECISIONS.md b/DECISIONS.md
index 84b4d0fa..ca753548 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1357,4 +1357,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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).
diff --git a/components/settings/ReminderEmailTextsSettings.tsx b/components/settings/ReminderEmailTextsSettings.tsx
new file mode 100644
index 00000000..f2ac53d5
--- /dev/null
+++ b/components/settings/ReminderEmailTextsSettings.tsx
@@ -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) => 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>
+
+function buildDisplay(stored: ReminderTextOverrides | null | undefined): DisplayTexts {
+ const result = {} as DisplayTexts
+ for (const level of LEVELS) {
+ result[level] = {} as Record
+ 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
+ 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('level_1')
+ const [texts, setTexts] = useState(() =>
+ 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(
+ 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.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 (
+
+
{t('description')}
+ {/* Legend is rendered from code, not messages/*.json: ICU message
+ syntax treats literal braces as interpolation. */}
+