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
+8
-1
@@ -876,7 +876,9 @@ export const CreateRecurringScheduleSchema = z.object({
|
||||
// Copied onto invoices.default_dimensions for every generated invoice.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Optional: when to first run. Defaults to next occurrence of day_of_month
|
||||
// (today if day_of_month === today, otherwise next month).
|
||||
// (today if day_of_month === today, otherwise next month). Fixes the month
|
||||
// phase of a quarterly/yearly schedule ("bill in February"); must be on the
|
||||
// schedule grid (day = day_of_month clamped) and not in the past.
|
||||
start_date: isoDate.optional(),
|
||||
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
@@ -896,6 +898,11 @@ export const UpdateRecurringScheduleSchema = z.object({
|
||||
notes: z.string().nullable().optional(),
|
||||
auto_send: z.boolean().optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
// Explicit next run date: re-phases the schedule (e.g. move a yearly
|
||||
// schedule from January to February). Must be on the schedule grid for
|
||||
// the effective day_of_month and strictly after today in Stockholm; wins
|
||||
// over the automatic recompute a day_of_month edit or reactivation does.
|
||||
next_run_date: isoDate.optional(),
|
||||
// Replaces the whole bag if provided ({} clears all tags). Omit to keep.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Replace all items if provided. Omit to keep existing items unchanged.
|
||||
|
||||
Reference in New Issue
Block a user