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
@@ -30,12 +30,48 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { Plus, Trash2 } from 'lucide-react'
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
import { formatCurrency } from '@/lib/utils'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
import {
alignRunDateToDay,
getStockholmDateHour,
isoFromParts,
lastDayOfMonth,
parseIsoDate,
projectRunDates,
runDateMatchesDayOfMonth,
} from '@/lib/invoices/recurring-run-date'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
/**
* Today as yyyy-mm-dd in Europe/Stockholm: the calendar the server validates
* against. The browser's own zone must not leak in, or a user west of Sweden
* late in the evening would pass client validation and get a 400.
*/
function stockholmTodayIso(): string {
return getStockholmDateHour(new Date()).date
}
/**
* Client-side twin of computeInitialRunDate on Stockholm's calendar: this
* month's occurrence of day_of_month if it has not passed, otherwise next
* month's. Prefills the date field so the default is "no offset", exactly
* what the server would pick when start_date is omitted.
*/
function defaultRunDate(dayOfMonth: number): string {
const today = parseIsoDate(stockholmTodayIso())
if (!today) return ''
const { year: y, month0: m, day: todayDay } = today
const thisMonthDay = Math.min(dayOfMonth, lastDayOfMonth(y, m))
if (todayDay <= thisMonthDay) return isoFromParts(y, m, thisMonthDay)
const ny = m === 11 ? y + 1 : y
const nm = (m + 1) % 12
return isoFromParts(ny, nm, Math.min(dayOfMonth, lastDayOfMonth(ny, nm)))
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
@@ -108,21 +144,62 @@ function NewRecurringScheduleForm({
.nullable()
.optional(),
})
return z.object({
customer_id: z.string().uuid(t('validation_customer_required')),
name: z.string().min(1, t('validation_name_required')),
day_of_month: z.number().int().min(1).max(31),
interval_months: z.number().int().min(1).max(12),
send_hour: z.number().int().min(0).max(23),
payment_terms_days: z.number().int().min(0).max(90),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
auto_send: z.boolean(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
}, [t])
return z
.object({
customer_id: z.string().uuid(t('validation_customer_required')),
name: z.string().min(1, t('validation_name_required')),
day_of_month: z.number().int().min(1).max(31),
interval_months: z.number().int().min(1).max(12),
// First run (create) or next run (edit). The month is what the user
// is really choosing: it fixes the phase of a quarterly/yearly
// schedule ("bill in February"). Sent as start_date / next_run_date.
run_date: z.string().regex(ISO_DATE_RE, t('validation_run_date_required')),
send_hour: z.number().int().min(0).max(23),
payment_terms_days: z.number().int().min(0).max(90),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
auto_send: z.boolean(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
.superRefine((data, ctx) => {
if (!ISO_DATE_RE.test(data.run_date)) return
// Mirrors the API: the date must sit on the schedule grid for the
// chosen day (the field syncs with day_of_month, so this only fires
// on a hand-typed mismatch), and it may not be in the past. An edit
// that keeps the stored date is not re-validated: a paused schedule
// with a stale date is reactivated by the server's roll-forward.
if (!runDateMatchesDayOfMonth(data.run_date, data.day_of_month)) {
ctx.addIssue({
code: 'custom',
path: ['run_date'],
message: t('validation_run_date_grid', { day: data.day_of_month }),
})
return
}
const today = stockholmTodayIso()
if (schedule) {
// The stored date, moved onto the grid for the (possibly edited)
// day, is the "unchanged" reference: a day-only edit keeps the
// server's own recompute and is not a re-phase.
const unchanged = alignRunDateToDay(schedule.next_run_date, data.day_of_month)
if (data.run_date !== unchanged && data.run_date <= today) {
ctx.addIssue({
code: 'custom',
path: ['run_date'],
message: t('validation_run_date_not_future'),
})
}
} else if (data.run_date < today) {
ctx.addIssue({
code: 'custom',
path: ['run_date'],
message: t('validation_run_date_past'),
})
}
})
}, [t, schedule])
type FormData = z.infer<typeof schema>
@@ -141,6 +218,7 @@ function NewRecurringScheduleForm({
name: schedule.name,
day_of_month: schedule.day_of_month,
interval_months: schedule.interval_months ?? 1,
run_date: schedule.next_run_date,
send_hour: schedule.send_hour ?? 8,
payment_terms_days: schedule.payment_terms_days,
currency: schedule.currency,
@@ -166,6 +244,7 @@ function NewRecurringScheduleForm({
name: '',
day_of_month: 15,
interval_months: 1,
run_date: defaultRunDate(15),
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
@@ -190,12 +269,23 @@ function NewRecurringScheduleForm({
async function onSubmit(data: FormData) {
setIsSubmitting(true)
try {
const { run_date, ...rest } = data
// Create: the chosen date is the first run. Edit: only send it when the
// user re-phased the schedule (a different month/year than the stored
// date aligned to the chosen day), so an unrelated edit, a day-only
// edit or a reactivation keeps the server's own recompute and never
// re-sends a stale date.
const rePhased =
!!schedule && run_date !== alignRunDateToDay(schedule.next_run_date, rest.day_of_month)
const body = schedule
? { ...rest, ...(rePhased ? { next_run_date: run_date } : {}) }
: { ...rest, start_date: run_date }
const res = await fetch(
schedule ? `/api/invoices/recurring/${schedule.id}` : '/api/invoices/recurring',
{
method: schedule ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
body: JSON.stringify(body),
},
)
if (!res.ok) {
@@ -237,6 +327,24 @@ function NewRecurringScheduleForm({
const items = watch('items')
const watchCurrency = watch('currency')
const watchDay = watch('day_of_month')
const watchInterval = watch('interval_months')
const watchRunDate = watch('run_date')
// Keep the date on the schedule grid when the day field changes: same
// month, day moved to the new day_of_month (clamped). The reverse sync
// (date -> day) lives in the date field's onChange.
useEffect(() => {
if (!Number.isInteger(watchDay) || watchDay < 1 || watchDay > 31) return
const aligned = alignRunDateToDay(watchRunDate, watchDay)
if (aligned !== watchRunDate) setValue('run_date', aligned, { shouldValidate: true })
// watchRunDate is deliberately not a dependency: the effect exists to
// react to the day, not to re-run on every date keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchDay, setValue])
const upcomingRuns =
Number.isInteger(watchDay) && watchInterval >= 1
? projectRunDates(watchRunDate, watchDay, watchInterval, 4).slice(1)
: []
// Automatic sending requires a customer email; without one the cron would
// just produce a monthly draft + warning. Block it at the source.
const watchCustomerId = watch('customer_id')
@@ -360,6 +468,44 @@ function NewRecurringScheduleForm({
{t('day_hint')}
</p>
</div>
<div>
<Label htmlFor="run_date">
{schedule ? t('run_date_edit_label') : t('run_date_label')}
</Label>
<Controller
control={control}
name="run_date"
render={({ field }) => (
<Input
id="run_date"
type="date"
className="tabular-nums"
value={field.value}
onBlur={field.onBlur}
onChange={(e) => {
const next = e.target.value
field.onChange(next)
// Picking a day that is not where day_of_month lands in
// that month means the user changed the day too, so
// follow it. Feb 28 with day 31 stays 31 (clamped hit).
const parsed = parseIsoDate(next)
if (parsed && !runDateMatchesDayOfMonth(next, watchDay)) {
setValue('day_of_month', parsed.day, { shouldValidate: true })
}
}}
/>
)}
/>
{errors.run_date ? (
<p className="text-sm text-destructive mt-1">{errors.run_date.message}</p>
) : (
<p className="text-xs text-muted-foreground mt-1">
{upcomingRuns.length > 0
? t('upcoming_runs', { dates: upcomingRuns.map((d) => formatDate(d)).join(', ') })
: t('run_date_hint')}
</p>
)}
</div>
<div>
<Label htmlFor="send_hour">{t('send_hour_label')}</Label>
<Controller