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
@@ -147,6 +147,72 @@ describe('PATCH /api/invoices/recurring/[id] reactivation', () => {
|
||||
expect(updatePayloads[0]).toEqual({ interval_months: 3 })
|
||||
})
|
||||
|
||||
it('writes an explicit future next_run_date verbatim (re-phasing a yearly schedule)', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ next_run_date: '2027-02-15' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({ next_run_date: '2027-02-15' })
|
||||
expect(updatePayloads[0]).not.toHaveProperty('last_run_warning')
|
||||
})
|
||||
|
||||
it('an explicit next_run_date wins over the day_of_month recompute', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ day_of_month: 20, next_run_date: '2027-02-20' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, next_run_date: '2027-02-20' })
|
||||
})
|
||||
|
||||
it('an explicit next_run_date on reactivation replaces the roll-forward and clears the warning', async () => {
|
||||
scheduleRow = { next_run_date: '2026-01-05', day_of_month: 5, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ status: 'active', next_run_date: '2026-09-05' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({
|
||||
status: 'active',
|
||||
next_run_date: '2026-09-05',
|
||||
last_run_warning: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a next_run_date that is not after today in Stockholm', async () => {
|
||||
scheduleRow = { next_run_date: '2026-08-06', day_of_month: 6, interval_months: 1 }
|
||||
|
||||
// Today is 2026-07-06 in Sweden: same day is refused, so an edit can never
|
||||
// trigger a same-hour send.
|
||||
const res = await PATCH(patchReq({ next_run_date: '2026-07-06' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(body.error).toMatch(/after today/)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a next_run_date off the day_of_month grid', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
const res = await PATCH(patchReq({ next_run_date: '2027-02-14' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/day_of_month/)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates next_run_date against the edited day_of_month, not the stored one', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
const res = await PATCH(patchReq({ day_of_month: 20, next_run_date: '2027-02-15' }), params)
|
||||
const { status } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 for next_run_date on a schedule that does not exist', async () => {
|
||||
scheduleRow = null
|
||||
|
||||
const res = await PATCH(patchReq({ next_run_date: '2027-02-15' }), params)
|
||||
const { status } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('does not touch next_run_date or warning when pausing', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-05', day_of_month: 5 }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -117,8 +118,13 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (input.status === 'active' || input.day_of_month !== undefined) {
|
||||
// (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')
|
||||
@@ -141,6 +147,28 @@ export const PATCH = withRouteContext(
|
||||
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:
|
||||
@@ -151,7 +179,7 @@ export const PATCH = withRouteContext(
|
||||
// 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 (staleOnReactivate || dayChanged) {
|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
@@ -215,6 +215,83 @@ describe('POST /api/invoices/recurring', () => {
|
||||
expect(itemRows[1].dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a start_date that is not on the day_of_month grid', async () => {
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2999-02-14',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(body.error).toMatch(/day_of_month/)
|
||||
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a start_date in the past', async () => {
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2020-02-15',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/past/)
|
||||
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses an explicit start_date as the first run so a yearly schedule keeps its month', async () => {
|
||||
const createdSchedule = {
|
||||
id: 's-2',
|
||||
company_id: 'company-1',
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
next_run_date: '2999-02-15',
|
||||
status: 'active',
|
||||
}
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
enqueue({ data: createdSchedule, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2999-02-15',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
expect(status).toBe(201)
|
||||
const insertArgs = findCall('recurring_invoice_schedules', 'insert')
|
||||
expect(insertArgs?.[0]).toMatchObject({ next_run_date: '2999-02-15', interval_months: 12 })
|
||||
})
|
||||
|
||||
it('creates a schedule on the happy path', async () => {
|
||||
const createdSchedule = {
|
||||
id: 's-1',
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { CreateRecurringScheduleSchema } from '@/lib/api/schemas'
|
||||
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'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -89,6 +93,30 @@ export const POST = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// An explicit first run date fixes the month phase (yearly in February,
|
||||
// quarterly Feb/May/Aug/Nov). It must sit on the schedule grid, or the
|
||||
// cron would drift back to day_of_month after the first run, and it
|
||||
// cannot be in the past: the dialog prefills the next occurrence, so a
|
||||
// past date is a stale form, not an intent to backfill.
|
||||
if (input.start_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(input.start_date, input.day_of_month)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
if (input.start_date < todayStockholm) {
|
||||
return NextResponse.json(
|
||||
{ error: 'start_date cannot be in the past', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunDate = computeInitialRunDate(
|
||||
new Date(),
|
||||
input.day_of_month,
|
||||
|
||||
Reference in New Issue
Block a user