fix(reminders): settings UI discloses that automatic sending is disabled (#2033)
* fix(reminders): settings UI discloses that automatic sending is disabled The invoice reminder cron has answered 503 since May 2026 (PR #583), so no automatic reminders are sent, but the settings UI still let users configure reminder day levels as if sending worked. Introduce REMINDERS_SENDING_ENABLED (lib/invoices/reminders-enabled.ts) as the single shared flag read by both sides: the cron route uses it as its 503 gate (with the original sending pipeline restored behind it, so re-enabling later is one flag flip), and the invoice settings form shows an attn notice while the flag is off. Schedule settings stay editable; notice strings added to both sv and en locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * docs(reminders): correct the re-enable contract after skeptic review The flag docblock, route docblock, and test header claimed flipping REMINDERS_SENDING_ENABLED alone resumes sending. False on hosted: the route has had no vercel.json cron entry since PR #559 and the crontab ratchet pins it in INTENTIONALLY_UNSCHEDULED, and POST requires the cron secret so no dashboard can trigger it. Rewrite the claims into the real re-enable checklist and record the pre-flip prerequisites surfaced by review: invoice_reminders lacks a unique (invoice_id, reminder_level) constraint and the fee entry is booked before the reminder row, so a run dying mid-batch double-books the fee; the backlog would get highest-level reminders first. Comments and a test name only; no runtime change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
341d61131a
commit
e92b5a59b2
@@ -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<Response>) =>
|
||||
(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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user