* 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>
307 lines
11 KiB
TypeScript
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 },
|
|
)
|