* 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>
111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
/**
|
|
* Pure date helpers for recurring invoice schedules that are safe to import
|
|
* from client components (no Supabase, PDF or email imports). The cron-side
|
|
* service (recurring-schedule-service.ts) builds on the same primitives so
|
|
* the dialog, the routes and the cron agree on what "the schedule's grid"
|
|
* means: every run date has day = min(day_of_month, last day of that month),
|
|
* and the month phase is fixed by the first run date.
|
|
*/
|
|
|
|
import { ISO_DATE_RE } from '@/lib/invariants'
|
|
|
|
/** Last day of the month (1-indexed result, 0-indexed month). */
|
|
export function lastDayOfMonth(year: number, monthIndex0: number): number {
|
|
// Day 0 of next month = last day of this month.
|
|
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
|
}
|
|
|
|
export function isoFromParts(year: number, monthIndex0: number, day: number): string {
|
|
const yyyy = year.toString().padStart(4, '0')
|
|
const mm = (monthIndex0 + 1).toString().padStart(2, '0')
|
|
const dd = day.toString().padStart(2, '0')
|
|
return `${yyyy}-${mm}-${dd}`
|
|
}
|
|
|
|
/** Calendar-validated parts of a yyyy-mm-dd string, or null. */
|
|
export function parseIsoDate(iso: string): { year: number; month0: number; day: number } | null {
|
|
if (!ISO_DATE_RE.test(iso)) return null
|
|
const year = Number(iso.slice(0, 4))
|
|
const month0 = Number(iso.slice(5, 7)) - 1
|
|
const day = Number(iso.slice(8, 10))
|
|
if (month0 < 0 || month0 > 11 || day < 1 || day > lastDayOfMonth(year, month0)) return null
|
|
return { year, month0, day }
|
|
}
|
|
|
|
/**
|
|
* True when `iso` is a calendar-valid date whose day is exactly where the
|
|
* schedule's day_of_month lands in that month (31 -> 28/29/30 in shorter
|
|
* months). A run date off the grid would make the cron's "advance one
|
|
* interval from the due date" step drift to day_of_month on the next run,
|
|
* so both write paths refuse it instead of silently normalizing.
|
|
*/
|
|
export function runDateMatchesDayOfMonth(iso: string, dayOfMonth: number): boolean {
|
|
const parsed = parseIsoDate(iso)
|
|
if (!parsed) return false
|
|
return parsed.day === Math.min(dayOfMonth, lastDayOfMonth(parsed.year, parsed.month0))
|
|
}
|
|
|
|
/**
|
|
* Same year-month as `iso`, day moved onto the schedule grid for
|
|
* day_of_month. Used by the dialog to keep the date field in step when the
|
|
* user edits the day. Returns `iso` unchanged when it is not parseable.
|
|
*/
|
|
export function alignRunDateToDay(iso: string, dayOfMonth: number): string {
|
|
const parsed = parseIsoDate(iso)
|
|
if (!parsed) return iso
|
|
return isoFromParts(
|
|
parsed.year,
|
|
parsed.month0,
|
|
Math.min(dayOfMonth, lastDayOfMonth(parsed.year, parsed.month0)),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The first `count` run dates starting at `firstIso` and stepping
|
|
* interval_months on the grid. Preview only (the cron computes each next
|
|
* date from the actual due date); returns [] for an unparseable first date.
|
|
*/
|
|
export function projectRunDates(
|
|
firstIso: string,
|
|
dayOfMonth: number,
|
|
intervalMonths: number,
|
|
count: number,
|
|
): string[] {
|
|
const parsed = parseIsoDate(firstIso)
|
|
if (!parsed || count <= 0) return []
|
|
const out: string[] = [firstIso]
|
|
let { year, month0 } = parsed
|
|
while (out.length < count) {
|
|
const m = month0 + intervalMonths
|
|
year += Math.floor(m / 12)
|
|
month0 = m % 12
|
|
out.push(isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0))))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
|
* for a given instant. The recurring cron runs in UTC on Vercel and the
|
|
* dialog runs in whatever zone the browser is in, but users pick dates and a
|
|
* send hour in Swedish local time, so every surface asks "what day and hour
|
|
* is it in Sweden right now" through this one function. Uses Intl (DST-aware,
|
|
* no extra dependency); en-CA + hourCycle 'h23' guarantees zero-padded
|
|
* ISO-shaped parts and a 0-23 hour.
|
|
*/
|
|
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Europe/Stockholm',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
hourCycle: 'h23',
|
|
}).formatToParts(instant)
|
|
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
|
return {
|
|
date: `${get('year')}-${get('month')}-${get('day')}`,
|
|
hour: Number(get('hour')),
|
|
}
|
|
}
|