Files
accounted/app/api/invoices/recurring/[id]/route.ts
T
MattssonandClaude Fable 5.1 238cbe13f9 feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules

A yearly or quarterly recurring schedule had no way to say which month it
bills in: the dialog exposed interval and day of month only, so a yearly
schedule created in September always fired in September. The phase of a
schedule is fully defined by its first run date, which the table already
stores as next_run_date and the create API already accepted as start_date
but nothing exposed.

- Dialog: new date field (first invoice date on create, next invoice date on
  edit), prefilled with the next natural occurrence so the default is "no
  offset"; kept in step with day of month both ways; shows the following
  three run dates so the phase is visible. Sent as start_date on create and
  as next_run_date on edit only when the user actually re-phased.
- API: create validates start_date (on the day_of_month grid, not in the
  past); update accepts next_run_date (on the grid for the effective day,
  strictly after today in Stockholm) and lets it win over the automatic
  recompute a day change or reactivation does.
- Staged operations / MCP: start_date documented as the phase; update tool
  gains next_run_date. Commit executor rejects off-grid dates and rolls a
  date that went stale before approval forward on its own grid.
- lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared
  by the dialog, the routes, the executors and the cron service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC

* fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog

Resolves the skeptic and CI findings on #2338 in one pass:

- MCP staging tools now apply the same grid and past/future rules as the
  routes to start_date and next_run_date, so the preview a human approves
  is exactly what the commit executor writes (previously an off-grid date
  staged fine and failed at approval, and a past next_run_date was rolled
  to another date silently).
- The dialog computes today and the default first invoice date in
  Europe/Stockholm instead of the browser's zone, matching the server;
  getStockholmDateHour moved to the client-safe module and is re-exported
  from the service.
- gnubok_update_recurring_schedule description trimmed under the 280-char
  limit while keeping the clamping and Stockholm phrases the registration
  test requires.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:49:29 +02:00

307 lines
11 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { UpdateRecurringScheduleSchema } from '@/lib/api/schemas'
import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-schedule-update'
import {
computeInitialRunDate,
computeNextRunDate,
rollNextRunDateForward,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
ensureInitialized()
export const GET = withRouteContext(
'recurring_invoice.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log } = ctx
const { data, error } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !data) {
log.warn('recurring schedule not found', { scheduleId: id })
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
return NextResponse.json({ data })
},
)
export const PATCH = withRouteContext(
'recurring_invoice.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = UpdateRecurringScheduleSchema.safeParse(rawBody)
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
},
{ status: 400 },
)
}
const input = parsed.data
const { items, ...scheduleFields } = input
// Only forward fields the user actually supplied.
const updateRow: Record<string, unknown> = {}
for (const [k, v] of Object.entries(scheduleFields)) {
if (v !== undefined) updateRow[k] = v
}
// Turning auto_send on (or moving the schedule to another customer while
// it is on) requires the customer to have an email address; otherwise
// every cron run degrades to a draft + warning. Mirrors the create
// route's guard.
if (input.auto_send === true || input.customer_id !== undefined) {
const { data: current } = await supabase
.from('recurring_invoice_schedules')
.select('auto_send, customer_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!current) {
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
const effectiveAutoSend = input.auto_send ?? current.auto_send
if (effectiveAutoSend) {
const { data: customer } = await supabase
.from('customers')
.select('email')
.eq('id', input.customer_id ?? current.customer_id)
.eq('company_id', companyId)
.maybeSingle()
if (!customer?.email) {
return NextResponse.json(
{
error: 'Customer has no email address: automatic sending requires one',
type: 'validation_error',
},
{ status: 400 },
)
}
}
}
// Recompute next_run_date when either the schedule is being reactivated
// (from a stale date) or its day-of-month actually changed via an edit,
// unless the caller re-phases the schedule with an explicit date.
if (
input.status === 'active' ||
input.day_of_month !== undefined ||
input.next_run_date !== undefined
) {
const { data: existing } = await supabase
.from('recurring_invoice_schedules')
.select('next_run_date, day_of_month, interval_months')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!existing) {
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
const reactivating = input.status === 'active'
const dayChanged =
input.day_of_month !== undefined && input.day_of_month !== existing.day_of_month
const effectiveDay = input.day_of_month ?? existing.day_of_month
const effectiveInterval = input.interval_months ?? existing.interval_months ?? 1
const { date: todayStockholm } = getStockholmDateHour(new Date())
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
// An explicit next_run_date is the user re-phasing the schedule (move a
// yearly invoice from January to February). It must be on the grid for
// the effective day and strictly in the future (same no-surprise-send
// rule as the recompute below), and it wins over that recompute.
if (input.next_run_date !== undefined) {
if (!runDateMatchesDayOfMonth(input.next_run_date, effectiveDay)) {
return NextResponse.json(
{
error: 'next_run_date must fall on day_of_month (clamped to the last day in shorter months)',
type: 'validation_error',
},
{ status: 400 },
)
}
if (input.next_run_date <= todayStockholm) {
return NextResponse.json(
{ error: 'next_run_date must be after today', type: 'validation_error' },
{ status: 400 },
)
}
}
// Recompute to the next STRICTLY-future occurrence (never today, so an
// edit or reactivation can't trigger a same-hour surprise send; today's
// invoice is the explicit run-now action instead) when either:
// - reactivating a schedule whose date already passed (e.g. the safety
// pause when the send-hour cron shipped), or
// - the day-of-month changed, so "Nästa körning" follows the new day.
// Editing other fields (name, items, time, interval) leaves
// next_run_date alone, so an unrelated edit never skips an imminent
// send; a changed interval applies from the next run onward.
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
if (input.next_run_date === undefined && (staleOnReactivate || dayChanged)) {
if (effectiveInterval === 1) {
// Monthly keeps its long-standing semantics: re-anchor on today so
// a day edit lands on the new day's nearest future occurrence.
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
updateRow.next_run_date =
rolled === todayStockholm
? computeNextRunDate(stockholmToday, effectiveDay)
: rolled
} else {
// Interval schedules roll on their own month grid so a day edit or
// reactivation cannot shift a quarterly schedule off its
// Jan/Apr/Jul/Oct phase (or bill a quarter early).
updateRow.next_run_date = rollNextRunDateForward(
existing.next_run_date,
stockholmToday,
effectiveDay,
effectiveInterval,
)
}
}
// A conscious reactivation invalidates any lingering warning (the
// safety-pause note, or a stale failure from months ago).
if (reactivating) {
updateRow.last_run_warning = null
}
}
// Existence check BEFORE any write: with items in the payload the request
// writes two tables, so a 404 must not leave a header update behind.
if (items) {
const { data: existing } = await supabase
.from('recurring_invoice_schedules')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!existing) {
return NextResponse.json(
{ error: 'Schedule not found', type: 'not_found' },
{ status: 404 },
)
}
}
// Items are replaced wholesale. Cheaper than diffing for a small list and
// matches how the UI form sends the full list back on every save. Both
// writes are compensated on failure inside the shared helper.
const result = await applyRecurringScheduleUpdate(supabase, {
scheduleId: id,
companyId,
fields: updateRow,
items,
log,
})
if (!result.ok) {
if (result.stage === 'header') {
log.error('failed to update recurring schedule', result.error)
return errorResponse(result.error, log, { requestId })
}
if (!result.itemsRestored || !result.headerRestored) {
// A compensation did not apply, so the schedule may be half-saved: say
// so instead of reporting a clean failure. Logged here with the repair
// context (errorResponseFromCode only records the code itself), which
// the clean-rollback path below does not need.
log.error('recurring schedule update left a partial state', result.error, {
scheduleId: id,
stage: result.stage,
itemsRestored: result.itemsRestored,
headerRestored: result.headerRestored,
})
return errorResponseFromCode('INVOICE_RECURRING_UPDATE_PARTIAL', log, {
requestId,
// camelCase throughout, matching the pgCode key errorResponse itself
// merges into details for Postgres failures.
details: {
pgCode: result.error.code,
stage: result.stage,
itemsRestored: result.itemsRestored,
headerRestored: result.headerRestored,
},
})
}
// Clean rollback: keep the PG-mapped error so a CHECK violation still
// surfaces its specific Swedish message. errorResponse logs it, so no
// second log line here.
return errorResponse(result.error, log, { requestId })
}
const { data: complete } = await supabase
.from('recurring_invoice_schedules')
.select('*, customer:customers(*), items:recurring_invoice_schedule_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
return NextResponse.json({ data: complete })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'recurring_invoice.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
// Items cascade-delete via FK ON DELETE CASCADE.
const { error } = await supabase
.from('recurring_invoice_schedules')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
log.error('failed to delete recurring schedule', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)