diff --git a/DECISIONS.md b/DECISIONS.md index f070531b..aea4f928 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1358,6 +1358,7 @@ 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 settings disclosure (PR #2033) keeps the cron unscheduled: re-adding the vercel.json entry would fail the crontab ratchet (INTENTIONALLY_UNSCHEDULED) and log daily 503s; the full re-enable checklist incl. idempotency prerequisites lives in lib/invoices/reminders-enabled.ts. [2026-08-30] delete_draft_invoice risk tier 'high' (not 'medium' like update_invoice): both outcomes are irreversible (hard delete removes the row; makulering permanently consumes the F-series number), so the op must never be auto-committed. [2026-08-30] v1 DELETE invoices/{id} returns INVOICE_DELETE_NOT_DRAFT as 409 via the status override (registry maps it to 400 for the cookie route): a state-machine refusal is a conflict on v1, aligned with INVOICE_UPDATE_NOT_DRAFT; web behavior left unchanged. [2026-08-30] book_skattekonto_row(s) tier 'medium' + scope 'transactions:write': rule-driven booking with no caller-supplied lines mirrors book_mileage_period (not create_voucher's 'high'); scope follows reconcile_residual (books an outside row). Commit service gates on SKATTEVERKET_ENABLED for HTTP-dispatcher parity, recoverable so the op stays pending. diff --git a/app/api/invoices/reminders/cron/__tests__/route.test.ts b/app/api/invoices/reminders/cron/__tests__/route.test.ts new file mode 100644 index 00000000..e51750b9 --- /dev/null +++ b/app/api/invoices/reminders/cron/__tests__/route.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for the invoice reminder cron gate: automatic reminder sending has + * been deliberately disabled since May 2026 (PR #583). The route and the + * invoice settings UI both read REMINDERS_SENDING_ENABLED, so these tests + * pin the contract: while the flag is off the route answers 503 and never + * touches the reminder processor; when the flag is flipped on, the route + * runs the original sending pipeline. Scheduling the route again (it has + * had no vercel.json cron entry since PR #559) and the pre-flip idempotency + * work are separate steps; see lib/invoices/reminders-enabled.ts. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const h = vi.hoisted(() => ({ + logInfo: vi.fn(), + logError: vi.fn(), + processOverdueReminders: vi.fn(), + isConfigured: vi.fn(), +})) + +vi.mock('@/lib/api/with-cron-context', () => ({ + withCronContext: + (_name: string, handler: (req: Request, ctx: unknown) => Promise) => + (req: Request) => + handler(req, { + log: { info: h.logInfo, error: h.logError, warn: vi.fn() }, + requestId: 'req_test', + }), +})) + +vi.mock('@/lib/invoices/reminder-processor', () => ({ + processOverdueReminders: h.processOverdueReminders, +})) + +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ isConfigured: h.isConfigured }), +})) + +import { GET, POST } from '../route' +import { REMINDERS_SENDING_ENABLED } from '@/lib/invoices/reminders-enabled' + +function cronRequest(): Request { + return new Request('http://localhost:3000/api/invoices/reminders/cron') +} + +describe('REMINDERS_SENDING_ENABLED flag', () => { + it('is off: re-enabling automatic reminder sending is a deliberate founder decision', () => { + expect(REMINDERS_SENDING_ENABLED).toBe(false) + }) +}) + +describe('GET /api/invoices/reminders/cron with sending disabled', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 503 and never invokes the reminder processor', async () => { + const res = await GET(cronRequest()) + const body = await res.json() + + expect(res.status).toBe(503) + expect(body).toEqual({ disabled: true }) + expect(h.processOverdueReminders).not.toHaveBeenCalled() + expect(h.isConfigured).not.toHaveBeenCalled() + expect(h.logInfo).toHaveBeenCalledWith( + 'invoice reminders feature is disabled; skipping run' + ) + }) + + it('exposes POST as the same gated handler (manual dashboard trigger)', async () => { + expect(POST).toBe(GET) + + const res = await POST(cronRequest()) + expect(res.status).toBe(503) + expect(h.processOverdueReminders).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/invoices/reminders/cron with sending enabled (flag flipped)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + async function importRouteWithFlagOn() { + vi.doMock('@/lib/invoices/reminders-enabled', () => ({ + REMINDERS_SENDING_ENABLED: true, + })) + return import('../route') + } + + it('runs the reminder pipeline and reports the summary', async () => { + h.isConfigured.mockReturnValue(true) + h.processOverdueReminders.mockResolvedValue({ + processed: 2, + sent: 1, + failed: 1, + results: [ + { + invoiceId: 'inv-1', + invoiceNumber: 'F-1001', + customerEmail: 'kund@testbrand.example', + reminderLevel: 1, + success: true, + }, + { + invoiceId: 'inv-2', + invoiceNumber: 'F-1002', + customerEmail: 'kund2@testbrand.example', + reminderLevel: 2, + success: false, + error: 'bounce', + }, + ], + }) + + const { GET: gatedGet } = await importRouteWithFlagOn() + const res = await gatedGet(cronRequest()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(h.processOverdueReminders).toHaveBeenCalledTimes(1) + expect(body).toEqual({ + success: true, + processed: 2, + sent: 1, + failed: 1, + results: [ + { invoiceNumber: 'F-1001', reminderLevel: 1, success: true }, + { invoiceNumber: 'F-1002', reminderLevel: 2, success: false, error: 'bounce' }, + ], + }) + }) + + it('still refuses to run when the email service is not configured', async () => { + h.isConfigured.mockReturnValue(false) + + const { GET: gatedGet } = await importRouteWithFlagOn() + const res = await gatedGet(cronRequest()) + + expect(res.status).toBeGreaterThanOrEqual(400) + expect(h.processOverdueReminders).not.toHaveBeenCalled() + expect(h.logError).toHaveBeenCalledWith( + 'email service not configured; skipping reminder run' + ) + }) +}) diff --git a/app/api/invoices/reminders/cron/route.ts b/app/api/invoices/reminders/cron/route.ts index bd047815..40624a2b 100644 --- a/app/api/invoices/reminders/cron/route.ts +++ b/app/api/invoices/reminders/cron/route.ts @@ -1,9 +1,55 @@ import { NextResponse } from 'next/server' +import { processOverdueReminders } from '@/lib/invoices/reminder-processor' +import { REMINDERS_SENDING_ENABLED } from '@/lib/invoices/reminders-enabled' +import { getEmailService } from '@/lib/email/service' import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +/** + * GET/POST /api/invoices/reminders/cron: sends overdue invoice reminders. + * Both verbs require the cron secret via withCronContext; POST mirrors GET + * so a secret-bearing operator can trigger a run outside the schedule. + * + * Gated behind REMINDERS_SENDING_ENABLED (off since May 2026, PR #583): + * while the flag is off this route answers 503 and nothing is sent, and + * the invoice settings UI reads the same flag to disclose that state. + * On hosted the route is also unscheduled (no vercel.json cron entry + * since PR #559), so the flag flip alone does not resume sending there; + * see lib/invoices/reminders-enabled.ts for the full re-enable checklist. + */ export const GET = withCronContext('cron.invoice_reminders', async (_request, ctx) => { - ctx.log.info('invoice reminders feature is disabled; skipping run') - return NextResponse.json({ disabled: true }, { status: 503 }) + if (!REMINDERS_SENDING_ENABLED) { + ctx.log.info('invoice reminders feature is disabled; skipping run') + return NextResponse.json({ disabled: true }, { status: 503 }) + } + + if (!getEmailService().isConfigured()) { + ctx.log.error('email service not configured; skipping reminder run') + return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', ctx.log, { + requestId: ctx.requestId, + }) + } + + const result = await processOverdueReminders() + + ctx.log.info('reminder cron summary', { + processed: result.processed, + sent: result.sent, + failed: result.failed, + }) + + return NextResponse.json({ + success: true, + processed: result.processed, + sent: result.sent, + failed: result.failed, + results: result.results.map((r) => ({ + invoiceNumber: r.invoiceNumber, + reminderLevel: r.reminderLevel, + success: r.success, + error: r.error, + })), + }) }) export const POST = GET diff --git a/components/settings/InvoiceSettingsForm.tsx b/components/settings/InvoiceSettingsForm.tsx index 11e1d253..44fb3e87 100644 --- a/components/settings/InvoiceSettingsForm.tsx +++ b/components/settings/InvoiceSettingsForm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import { Switch } from '@/components/ui/switch' +import { AttnLine } from '@/components/ui/attn-line' import { SettingsGroup, SettingsInput, @@ -10,6 +11,7 @@ import { SettingsRow, SettingsTextarea, } from '@/components/settings/SettingsRows' +import { REMINDERS_SENDING_ENABLED } from '@/lib/invoices/reminders-enabled' import type { CompanySettings } from '@/types' interface InvoiceSettingsFormProps { @@ -104,6 +106,9 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) { + {!REMINDERS_SENDING_ENABLED && ( + {t('sending_disabled_notice')} + )}