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
+1
View File
@@ -1608,5 +1608,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one".
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
@@ -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 }
+31 -3
View File
@@ -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',
+29 -1
View File
@@ -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,
@@ -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
@@ -209,6 +209,37 @@ describe('gnubok_create_recurring_schedule: validation and staging', () => {
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a start_date off the day_of_month grid at staging, before writing anything', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
await expect(
createTool().execute(
{ ...validArgs, day_of_month: 1, start_date: '2999-09-10' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/day_of_month/)
// Customer lookup only; no pending_operations insert.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a start_date in the past at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
await expect(
createTool().execute(
{ ...validArgs, day_of_month: 25, start_date: '2020-01-25' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/past/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('stages the schedule for approval at medium risk', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
@@ -347,6 +378,86 @@ describe('gnubok_update_recurring_schedule: validation and staging', () => {
expect(result.preview.current.recurring_schedule_id).toBe(SCHEDULE_ID)
})
it('stages an explicit next_run_date so an agent can re-phase a yearly schedule', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ interval_months: 12 }) })
enqueue({ data: { id: 'op-recurring-5' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-02-25' },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: { current: Record<string, unknown>; proposed: Record<string, unknown> }
}
expect(result.staged).toBe(true)
expect(result.preview.current.next_run_date).toBe('2999-01-25')
expect(result.preview.proposed.next_run_date).toBe('2999-02-25')
})
it('rejects a next_run_date off the effective day_of_month grid at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-11-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/day_of_month 15/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('validates next_run_date against a day_of_month changed in the same call', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
enqueue({ data: { id: 'op-recurring-6' } })
const result = (await updateTool().execute(
{ schedule_id: SCHEDULE_ID, day_of_month: 1, next_run_date: '2999-11-01' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { proposed: Record<string, unknown> } }
expect(result.staged).toBe(true)
expect(result.preview.proposed).toMatchObject({ day_of_month: 1, next_run_date: '2999-11-01' })
})
it('rejects a next_run_date that is not after today at staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: '2020-01-25' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/after today/)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects a malformed next_run_date before querying the database', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateTool().execute(
{ schedule_id: SCHEDULE_ID, next_run_date: 'februari', dry_run: true },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/next_run_date/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('previews an item replace against the current lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: currentSchedule() })
+44 -3
View File
@@ -167,7 +167,8 @@ import {
CreateRecurringScheduleParamsSchema,
UpdateRecurringScheduleParamsSchema,
} from '@/lib/pending-operations/schemas/recurring-schedule'
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'
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
@@ -20900,7 +20901,10 @@ export const tools: McpTool[] = [
type: 'boolean',
description: 'Default false: invoices are created as drafts for manual review. true emails every generated invoice to the customer with no further approval; requires the customer to have an email address.',
},
start_date: { type: 'string', description: 'YYYY-MM-DD first run date. Omit to run on the next occurrence of day_of_month.' },
start_date: {
type: 'string',
description: 'YYYY-MM-DD first run date; fixes the month phase of a quarterly/yearly schedule (e.g. 2027-02-15 with interval_months 12 = every February). Must fall on day_of_month (clamped in shorter months) and not be in the past. Omit to run on the next occurrence of day_of_month.',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -21006,6 +21010,21 @@ export const tools: McpTool[] = [
throw new Error('Customer has no email address: auto_send requires one. Stage with auto_send=false or add an email first.')
}
// Same rules the create route enforces, applied at staging so the
// preview the human approves is what the commit executor will write:
// an off-grid or past start_date must fail here, not after approval.
if (params.start_date !== undefined) {
if (!runDateMatchesDayOfMonth(params.start_date, params.day_of_month)) {
throw new Error(
`start_date ${params.start_date} does not fall on day_of_month ${params.day_of_month} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month.`,
)
}
const { date: todayStockholm } = getStockholmDateHour(new Date())
if (params.start_date < todayStockholm) {
throw new Error(`start_date ${params.start_date} is in the past (today in Europe/Stockholm is ${todayStockholm}).`)
}
}
const monthlyTotalExclVat =
Math.round(params.items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0) * 100) / 100
@@ -21054,7 +21073,7 @@ export const tools: McpTool[] = [
name: 'gnubok_update_recurring_schedule',
keywords: ['återkommande faktura', 'stående faktura'],
title: 'Update Recurring Invoice Schedule',
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). Pause/resume via status. items replace all lines; omit to keep them. day_of_month clamps to the last day in shorter months; send_hour is a whole hour in Europe/Stockholm.',
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). status pauses/resumes; items replace all lines; day_of_month clamps to the last day in shorter months; next_run_date re-phases (yearly in February); send_hour is Europe/Stockholm.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -21090,6 +21109,10 @@ export const tools: McpTool[] = [
enum: ['active', 'paused'],
description: 'paused stops generating invoices; active resumes. Reactivating from a stale date rolls next_run_date to the next future occurrence, never today.',
},
next_run_date: {
type: 'string',
description: 'YYYY-MM-DD explicit next run; re-phases the schedule (e.g. move a yearly invoice to February). Must fall on the effective day_of_month (clamped in shorter months) and be after today in Europe/Stockholm. Wins over the automatic recompute.',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -21160,6 +21183,7 @@ export const tools: McpTool[] = [
'notes',
'auto_send',
'status',
'next_run_date',
]) {
if (args[key] !== undefined) changes[key] = args[key]
}
@@ -21218,6 +21242,23 @@ export const tools: McpTool[] = [
}
}
// Same rules the PATCH route enforces, applied at staging so the
// proposed next_run_date in the preview is exactly what gets written
// on approval (the executor only rolls a date that went stale while
// waiting for approval; it never silently moves a date staged today).
if (parsedChanges.next_run_date !== undefined) {
const effectiveDay = parsedChanges.day_of_month ?? (current.day_of_month as number)
if (!runDateMatchesDayOfMonth(parsedChanges.next_run_date, effectiveDay)) {
throw new Error(
`next_run_date ${parsedChanges.next_run_date} does not fall on day_of_month ${effectiveDay} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month in the same call.`,
)
}
const { date: todayStockholm } = getStockholmDateHour(new Date())
if (parsedChanges.next_run_date <= todayStockholm) {
throw new Error(`next_run_date must be after today in Europe/Stockholm (${todayStockholm}).`)
}
}
const currentItems = ((current.items as Array<Record<string, unknown>>) ?? [])
.slice()
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order))
+8 -1
View File
@@ -876,7 +876,9 @@ export const CreateRecurringScheduleSchema = z.object({
// Copied onto invoices.default_dimensions for every generated invoice.
default_dimensions: DimensionsBagSchema.optional(),
// Optional: when to first run. Defaults to next occurrence of day_of_month
// (today if day_of_month === today, otherwise next month).
// (today if day_of_month === today, otherwise next month). Fixes the month
// phase of a quarterly/yearly schedule ("bill in February"); must be on the
// schedule grid (day = day_of_month clamped) and not in the past.
start_date: isoDate.optional(),
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
})
@@ -896,6 +898,11 @@ export const UpdateRecurringScheduleSchema = z.object({
notes: z.string().nullable().optional(),
auto_send: z.boolean().optional(),
status: z.enum(['active', 'paused']).optional(),
// Explicit next run date: re-phases the schedule (e.g. move a yearly
// schedule from January to February). Must be on the schedule grid for
// the effective day_of_month and strictly after today in Stockholm; wins
// over the automatic recompute a day_of_month edit or reactivation does.
next_run_date: isoDate.optional(),
// Replaces the whole bag if provided ({} clears all tags). Omit to keep.
default_dimensions: DimensionsBagSchema.optional(),
// Replace all items if provided. Omit to keep existing items unchanged.
@@ -0,0 +1,78 @@
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([])
})
})
+110
View File
@@ -0,0 +1,110 @@
/**
* 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')),
}
}
+6 -39
View File
@@ -56,6 +56,12 @@ import {
import { snapshotInvoicePayee } from '@/lib/invoices/invoice-payee'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { createLogger } from '@/lib/logger'
import { lastDayOfMonth, isoFromParts } from '@/lib/invoices/recurring-run-date'
// Lives in the client-safe module now (the dialog needs Stockholm's calendar
// day too); re-exported so the cron, routes and executors keep importing it
// from here.
export { getStockholmDateHour } from '@/lib/invoices/recurring-run-date'
import type {
Invoice,
InvoiceItem,
@@ -74,22 +80,6 @@ export interface ExecuteResult {
warning: string | null
}
/**
* Last day of the month for the given year/month (1-indexed month).
* Used to clamp day_of_month values >28 in shorter months.
*/
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()
}
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}`
}
function assertValidCadence(dayOfMonth: number, intervalMonths: number): void {
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
@@ -204,29 +194,6 @@ export function computeInitialRunDate(
return computeNextRunDate(today, dayOfMonth)
}
/**
* 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, but users
* pick a send time in Swedish local time, so we need "what day and hour is it
* in Sweden right now". 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')),
}
}
export interface ExecuteScheduleOptions {
/**
* Defence-in-depth sandbox suppression (ASVS V2.3): callers that resolved
@@ -173,6 +173,24 @@ describe('commitPendingOperation: create_recurring_schedule', () => {
expect(result.error).toMatch(/email/i)
})
it('rejects a start_date off the day_of_month grid at commit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-recurring-1' } }) // claim
enqueue({ data: { id: CUSTOMER_ID, email: null } }) // customer
enqueue({ data: null }) // status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp('create_recurring_schedule', { ...createParams, start_date: '2999-09-24' }),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/day_of_month/)
})
it('rejects tampered params at the commit boundary', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-recurring-1' } }) // claim
@@ -347,6 +365,77 @@ describe('commitPendingOperation: update_recurring_schedule', () => {
])
})
it('writes an explicit future next_run_date verbatim and skips the recompute', async () => {
const { supabase, updates } = createCapturingSupabase([
{ data: { id: 'op-recurring-1' }, error: null }, // claim
{ data: { ...existingRow, interval_months: 12 }, error: null },
{ data: null, error: null }, // schedule update
{ data: null, error: null }, // finalize
])
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp('update_recurring_schedule', {
schedule_id: SCHEDULE_ID,
changes: { day_of_month: 20, next_run_date: '2999-02-20' },
}),
)
expect(result.status).toBe('committed')
expect(updates.recurring_invoice_schedules).toEqual([
{ day_of_month: 20, next_run_date: '2999-02-20' },
])
})
it('rolls a next_run_date that went stale before approval forward on its own grid', async () => {
const { supabase, updates } = createCapturingSupabase([
{ data: { id: 'op-recurring-1' }, error: null }, // claim
{ data: { ...existingRow, interval_months: 12 }, error: null },
{ data: null, error: null }, // schedule update
{ data: null, error: null }, // finalize
])
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp('update_recurring_schedule', {
schedule_id: SCHEDULE_ID,
changes: { next_run_date: '2020-02-25' },
}),
)
expect(result.status).toBe('committed')
const next = String(updates.recurring_invoice_schedules?.[0]?.next_run_date)
const { date: todayStockholm } = getStockholmDateHour(new Date())
// Strictly future, and still a February 25 (the chosen phase survives).
expect(next > todayStockholm).toBe(true)
expect(next.slice(5)).toBe('02-25')
})
it('rejects a next_run_date off the day_of_month grid at commit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-recurring-1' } }) // claim
enqueue({ data: existingRow }) // existing
enqueue({ data: null }) // status update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp('update_recurring_schedule', {
schedule_id: SCHEDULE_ID,
changes: { next_run_date: '2999-02-24' },
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/day_of_month/)
})
it('rejects enabling auto_send at commit when the customer has no email', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-recurring-1' } }) // claim
+43 -2
View File
@@ -191,6 +191,7 @@ import {
rollNextRunDateForward,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
import {
buildInvoiceWriteData,
@@ -793,6 +794,19 @@ async function commitCreateRecurringSchedule(
}
}
// Same grid rule as the create route. A start_date that turned stale
// between staging and approval is NOT rejected: the cron rolls a missed
// date forward on the grid, so the phase the user chose still holds.
if (
validated.start_date !== undefined &&
!runDateMatchesDayOfMonth(validated.start_date, validated.day_of_month)
) {
return {
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
status: 400,
}
}
const nextRunDate = computeInitialRunDate(
new Date(),
validated.day_of_month,
@@ -936,7 +950,11 @@ async function commitUpdateRecurringSchedule(
// next STRICTLY-future occurrence, never today, so an approval cannot
// trigger a same-hour surprise send. Editing other fields leaves
// next_run_date alone so an unrelated edit never skips an imminent send.
if (changes.status === 'active' || changes.day_of_month !== undefined) {
if (
changes.status === 'active' ||
changes.day_of_month !== undefined ||
changes.next_run_date !== undefined
) {
const reactivating = changes.status === 'active'
const dayChanged =
changes.day_of_month !== undefined && changes.day_of_month !== existing.day_of_month
@@ -945,8 +963,31 @@ async function commitUpdateRecurringSchedule(
const { date: todayStockholm } = getStockholmDateHour(new Date())
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
// An explicit next_run_date re-phases the schedule and wins over the
// recompute. Grid mismatch is rejected like the PATCH route; a date that
// went stale while the operation waited for approval is rolled forward
// on its own grid (strictly future) instead of auto-rejecting, so the
// approved phase survives a slow approval.
if (changes.next_run_date !== undefined) {
if (!runDateMatchesDayOfMonth(changes.next_run_date, effectiveDay)) {
return {
error: 'next_run_date must fall on day_of_month (clamped to the last day in shorter months)',
status: 400,
}
}
updateRow.next_run_date =
changes.next_run_date <= todayStockholm
? rollNextRunDateForward(
changes.next_run_date,
stockholmToday,
effectiveDay,
effectiveInterval,
)
: changes.next_run_date
}
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
if (staleOnReactivate || dayChanged) {
if (changes.next_run_date === undefined && (staleOnReactivate || dayChanged)) {
if (effectiveInterval === 1) {
// Monthly keeps its long-standing today-anchored semantics.
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
+8
View File
@@ -4397,6 +4397,14 @@
"customer_placeholder": "Select customer",
"day_label": "Day of month",
"day_hint": "29-31 run on the last day in shorter months.",
"run_date_label": "First invoice date",
"run_date_edit_label": "Next invoice date",
"run_date_hint": "Sets which month the invoice is created in, e.g. February every year.",
"upcoming_runs": "Then: {dates}",
"validation_run_date_required": "Enter a date",
"validation_run_date_past": "The date cannot be in the past",
"validation_run_date_not_future": "The date must be after today",
"validation_run_date_grid": "The date must fall on day {day} of the month",
"interval_label": "Interval",
"interval_monthly": "Every month",
"interval_quarterly": "Every quarter",
+8
View File
@@ -4397,6 +4397,14 @@
"customer_placeholder": "Välj kund",
"day_label": "Dag i månaden",
"day_hint": "29-31 körs sista dagen i kortare månader.",
"run_date_label": "Första faktureringsdatum",
"run_date_edit_label": "Nästa faktureringsdatum",
"run_date_hint": "Styr vilken månad fakturan skapas, t.ex. februari varje år.",
"upcoming_runs": "Därefter: {dates}",
"validation_run_date_required": "Ange ett datum",
"validation_run_date_past": "Datumet får inte vara passerat",
"validation_run_date_not_future": "Datumet måste vara efter idag",
"validation_run_date_grid": "Datumet måste ligga på dag {day} i månaden",
"interval_label": "Intervall",
"interval_monthly": "Varje månad",
"interval_quarterly": "Varje kvartal",