* 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>
79 lines
2.4 KiB
TypeScript
79 lines
2.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
alignRunDateToDay,
|
|
projectRunDates,
|
|
runDateMatchesDayOfMonth,
|
|
} from '../recurring-run-date'
|
|
|
|
describe('runDateMatchesDayOfMonth', () => {
|
|
it('accepts a date on the exact day', () => {
|
|
expect(runDateMatchesDayOfMonth('2027-02-15', 15)).toBe(true)
|
|
})
|
|
|
|
it('accepts the clamped last day in a shorter month', () => {
|
|
expect(runDateMatchesDayOfMonth('2027-02-28', 31)).toBe(true)
|
|
expect(runDateMatchesDayOfMonth('2028-02-29', 31)).toBe(true)
|
|
expect(runDateMatchesDayOfMonth('2027-04-30', 31)).toBe(true)
|
|
})
|
|
|
|
it('rejects a day that is not where day_of_month lands', () => {
|
|
expect(runDateMatchesDayOfMonth('2027-02-14', 15)).toBe(false)
|
|
expect(runDateMatchesDayOfMonth('2027-03-30', 31)).toBe(false)
|
|
// 28 is not the clamp of 31 in a 31-day month.
|
|
expect(runDateMatchesDayOfMonth('2027-01-28', 31)).toBe(false)
|
|
})
|
|
|
|
it('rejects malformed or calendar-invalid dates', () => {
|
|
expect(runDateMatchesDayOfMonth('2027-2-15', 15)).toBe(false)
|
|
expect(runDateMatchesDayOfMonth('2027-13-15', 15)).toBe(false)
|
|
expect(runDateMatchesDayOfMonth('2027-02-31', 31)).toBe(false)
|
|
expect(runDateMatchesDayOfMonth('', 15)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('alignRunDateToDay', () => {
|
|
it('moves the day within the same month', () => {
|
|
expect(alignRunDateToDay('2027-02-15', 20)).toBe('2027-02-20')
|
|
})
|
|
|
|
it('clamps to the last day of the month', () => {
|
|
expect(alignRunDateToDay('2027-02-15', 31)).toBe('2027-02-28')
|
|
})
|
|
|
|
it('returns the input unchanged when it is not a date', () => {
|
|
expect(alignRunDateToDay('', 20)).toBe('')
|
|
expect(alignRunDateToDay('nope', 20)).toBe('nope')
|
|
})
|
|
})
|
|
|
|
describe('projectRunDates', () => {
|
|
it('projects a yearly schedule on its February phase', () => {
|
|
expect(projectRunDates('2027-02-15', 15, 12, 3)).toEqual([
|
|
'2027-02-15',
|
|
'2028-02-15',
|
|
'2029-02-15',
|
|
])
|
|
})
|
|
|
|
it('projects a quarterly schedule and clamps day 31', () => {
|
|
expect(projectRunDates('2027-02-28', 31, 3, 4)).toEqual([
|
|
'2027-02-28',
|
|
'2027-05-31',
|
|
'2027-08-31',
|
|
'2027-11-30',
|
|
])
|
|
})
|
|
|
|
it('rolls over the year boundary for a monthly schedule', () => {
|
|
expect(projectRunDates('2027-11-05', 5, 1, 3)).toEqual([
|
|
'2027-11-05',
|
|
'2027-12-05',
|
|
'2028-01-05',
|
|
])
|
|
})
|
|
|
|
it('returns an empty list for an unparseable first date', () => {
|
|
expect(projectRunDates('', 15, 12, 3)).toEqual([])
|
|
})
|
|
})
|