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
@@ -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() })
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user