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