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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0c854ac54f
commit
238cbe13f9
@@ -191,6 +191,7 @@ import {
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
|
||||
import {
|
||||
buildInvoiceWriteData,
|
||||
@@ -793,6 +794,19 @@ async function commitCreateRecurringSchedule(
|
||||
}
|
||||
}
|
||||
|
||||
// Same grid rule as the create route. A start_date that turned stale
|
||||
// between staging and approval is NOT rejected: the cron rolls a missed
|
||||
// date forward on the grid, so the phase the user chose still holds.
|
||||
if (
|
||||
validated.start_date !== undefined &&
|
||||
!runDateMatchesDayOfMonth(validated.start_date, validated.day_of_month)
|
||||
) {
|
||||
return {
|
||||
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunDate = computeInitialRunDate(
|
||||
new Date(),
|
||||
validated.day_of_month,
|
||||
@@ -936,7 +950,11 @@ async function commitUpdateRecurringSchedule(
|
||||
// next STRICTLY-future occurrence, never today, so an approval cannot
|
||||
// trigger a same-hour surprise send. Editing other fields leaves
|
||||
// next_run_date alone so an unrelated edit never skips an imminent send.
|
||||
if (changes.status === 'active' || changes.day_of_month !== undefined) {
|
||||
if (
|
||||
changes.status === 'active' ||
|
||||
changes.day_of_month !== undefined ||
|
||||
changes.next_run_date !== undefined
|
||||
) {
|
||||
const reactivating = changes.status === 'active'
|
||||
const dayChanged =
|
||||
changes.day_of_month !== undefined && changes.day_of_month !== existing.day_of_month
|
||||
@@ -945,8 +963,31 @@ async function commitUpdateRecurringSchedule(
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
// An explicit next_run_date re-phases the schedule and wins over the
|
||||
// recompute. Grid mismatch is rejected like the PATCH route; a date that
|
||||
// went stale while the operation waited for approval is rolled forward
|
||||
// on its own grid (strictly future) instead of auto-rejecting, so the
|
||||
// approved phase survives a slow approval.
|
||||
if (changes.next_run_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(changes.next_run_date, effectiveDay)) {
|
||||
return {
|
||||
error: 'next_run_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
updateRow.next_run_date =
|
||||
changes.next_run_date <= todayStockholm
|
||||
? rollNextRunDateForward(
|
||||
changes.next_run_date,
|
||||
stockholmToday,
|
||||
effectiveDay,
|
||||
effectiveInterval,
|
||||
)
|
||||
: changes.next_run_date
|
||||
}
|
||||
|
||||
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
||||
if (staleOnReactivate || dayChanged) {
|
||||
if (changes.next_run_date === undefined && (staleOnReactivate || dayChanged)) {
|
||||
if (effectiveInterval === 1) {
|
||||
// Monthly keeps its long-standing today-anchored semantics.
|
||||
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
||||
|
||||
Reference in New Issue
Block a user