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:
Mattsson
2026-09-06 16:49:29 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 0c854ac54f
commit 238cbe13f9
16 changed files with 873 additions and 67 deletions
@@ -209,6 +209,37 @@ describe('gnubok_create_recurring_schedule: validation and staging', () => {
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a start_date off the day_of_month grid at staging, before writing anything', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
await expect(
createTool().execute(
{ ...validArgs, day_of_month: 1, start_date: '2999-09-10' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/day_of_month/)
// Customer lookup only; no pending_operations insert.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a start_date in the past at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
await expect(
createTool().execute(
{ ...validArgs, day_of_month: 25, start_date: '2020-01-25' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/past/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages the schedule for approval at medium risk', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
@@ -347,6 +378,86 @@ describe('gnubok_update_recurring_schedule: validation and staging', () => {
expect(result.preview.current.recurring_schedule_id).toBe(SCHEDULE_ID)
})
it('stages an explicit next_run_date so an agent can re-phase a yearly schedule', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ interval_months: 12 }) })
enqueue({ data: { id: 'op-recurring-5' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-02-25' },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: { current: Record<string, unknown>; proposed: Record<string, unknown> }
}
expect(result.staged).toBe(true)
expect(result.preview.current.next_run_date).toBe('2999-01-25')
expect(result.preview.proposed.next_run_date).toBe('2999-02-25')
})
it('rejects a next_run_date off the effective day_of_month grid at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-11-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/day_of_month 15/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('validates next_run_date against a day_of_month changed in the same call', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
enqueue({ data: { id: 'op-recurring-6' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, day_of_month: 1, next_run_date: '2999-11-01' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { proposed: Record<string, unknown> } }
expect(result.staged).toBe(true)
expect(result.preview.proposed).toMatchObject({ day_of_month: 1, next_run_date: '2999-11-01' })
})
it('rejects a next_run_date that is not after today at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2020-01-25' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/after today/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a malformed next_run_date before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: 'februari', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/next_run_date/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('previews an item replace against the current lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
+44 -3
View File
@@ -167,7 +167,8 @@ import {
CreateRecurringScheduleParamsSchema,
UpdateRecurringScheduleParamsSchema,
} from '@/lib/pending-operations/schemas/recurring-schedule'
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
import { computeInitialRunDate, 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 { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
@@ -20900,7 +20901,10 @@ export const tools: McpTool[] = [
type: 'boolean',
description: 'Default false: invoices are created as drafts for manual review. true emails every generated invoice to the customer with no further approval; requires the customer to have an email address.',
},
start_date: { type: 'string', description: 'YYYY-MM-DD first run date. Omit to run on the next occurrence of day_of_month.' },
start_date: {
type: 'string',
description: 'YYYY-MM-DD first run date; fixes the month phase of a quarterly/yearly schedule (e.g. 2027-02-15 with interval_months 12 = every February). Must fall on day_of_month (clamped in shorter months) and not be in the past. Omit to run on the next occurrence of day_of_month.',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -21006,6 +21010,21 @@ export const tools: McpTool[] = [
throw new Error('Customer has no email address: auto_send requires one. Stage with auto_send=false or add an email first.')
}
// Same rules the create route enforces, applied at staging so the
// preview the human approves is what the commit executor will write:
// an off-grid or past start_date must fail here, not after approval.
if (params.start_date !== undefined) {
if (!runDateMatchesDayOfMonth(params.start_date, params.day_of_month)) {
throw new Error(
`start_date ${params.start_date} does not fall on day_of_month ${params.day_of_month} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month.`,
)
}
const { date: todayStockholm } = getStockholmDateHour(new Date())
if (params.start_date < todayStockholm) {
throw new Error(`start_date ${params.start_date} is in the past (today in Europe/Stockholm is ${todayStockholm}).`)
}
}
const monthlyTotalExclVat =
Math.round(params.items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0) * 100) / 100
@@ -21054,7 +21073,7 @@ export const tools: McpTool[] = [
name: 'gnubok_update_recurring_schedule',
keywords: ['återkommande faktura', 'stående faktura'],
title: 'Update Recurring Invoice Schedule',
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). Pause/resume via status. items replace all lines; omit to keep them. day_of_month clamps to the last day in shorter months; send_hour is a whole hour in Europe/Stockholm.',
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). status pauses/resumes; items replace all lines; day_of_month clamps to the last day in shorter months; next_run_date re-phases (yearly in February); send_hour is Europe/Stockholm.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -21090,6 +21109,10 @@ export const tools: McpTool[] = [
enum: ['active', 'paused'],
description: 'paused stops generating invoices; active resumes. Reactivating from a stale date rolls next_run_date to the next future occurrence, never today.',
},
next_run_date: {
type: 'string',
description: 'YYYY-MM-DD explicit next run; re-phases the schedule (e.g. move a yearly invoice to February). Must fall on the effective day_of_month (clamped in shorter months) and be after today in Europe/Stockholm. Wins over the automatic recompute.',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -21160,6 +21183,7 @@ export const tools: McpTool[] = [
'notes',
'auto_send',
'status',
'next_run_date',
]) {
if (args[key] !== undefined) changes[key] = args[key]
}
@@ -21218,6 +21242,23 @@ export const tools: McpTool[] = [
}
}
// Same rules the PATCH route enforces, applied at staging so the
// proposed next_run_date in the preview is exactly what gets written
// on approval (the executor only rolls a date that went stale while
// waiting for approval; it never silently moves a date staged today).
if (parsedChanges.next_run_date !== undefined) {
const effectiveDay = parsedChanges.day_of_month ?? (current.day_of_month as number)
if (!runDateMatchesDayOfMonth(parsedChanges.next_run_date, effectiveDay)) {
throw new Error(
`next_run_date ${parsedChanges.next_run_date} does not fall on day_of_month ${effectiveDay} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month in the same call.`,
)
}
const { date: todayStockholm } = getStockholmDateHour(new Date())
if (parsedChanges.next_run_date <= todayStockholm) {
throw new Error(`next_run_date must be after today in Europe/Stockholm (${todayStockholm}).`)
}
}
const currentItems = ((current.items as Array<Record<string, unknown>>) ?? [])
.slice()
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order))