diff --git a/DECISIONS.md b/DECISIONS.md
index b30be369..1e1328e0 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1608,5 +1608,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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.
diff --git a/app/api/invoices/recurring/[id]/__tests__/route.test.ts b/app/api/invoices/recurring/[id]/__tests__/route.test.ts
index db73561b..3ef014a2 100644
--- a/app/api/invoices/recurring/[id]/__tests__/route.test.ts
+++ b/app/api/invoices/recurring/[id]/__tests__/route.test.ts
@@ -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 }
diff --git a/app/api/invoices/recurring/[id]/route.ts b/app/api/invoices/recurring/[id]/route.ts
index 445df5a4..0cc141ee 100644
--- a/app/api/invoices/recurring/[id]/route.ts
+++ b/app/api/invoices/recurring/[id]/route.ts
@@ -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.
diff --git a/app/api/invoices/recurring/__tests__/route.test.ts b/app/api/invoices/recurring/__tests__/route.test.ts
index 28dfb3b4..6f2987ed 100644
--- a/app/api/invoices/recurring/__tests__/route.test.ts
+++ b/app/api/invoices/recurring/__tests__/route.test.ts
@@ -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',
diff --git a/app/api/invoices/recurring/route.ts b/app/api/invoices/recurring/route.ts
index d421be26..b040ee90 100644
--- a/app/api/invoices/recurring/route.ts
+++ b/app/api/invoices/recurring/route.ts
@@ -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,
diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx
index 43fa6710..54e8cab3 100644
--- a/components/invoices/NewRecurringScheduleDialog.tsx
+++ b/components/invoices/NewRecurringScheduleDialog.tsx
@@ -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
@@ -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')}
+
+
+ (
+ {
+ 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 ? (
+
{
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; proposed: Record }
+ }
+
+ 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 } }
+
+ 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() })
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index a7c50c5a..7ded7280 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -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>) ?? [])
.slice()
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order))
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index b22d8ac5..27b9aa6d 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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.
diff --git a/lib/invoices/__tests__/recurring-run-date.test.ts b/lib/invoices/__tests__/recurring-run-date.test.ts
new file mode 100644
index 00000000..91a79424
--- /dev/null
+++ b/lib/invoices/__tests__/recurring-run-date.test.ts
@@ -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([])
+ })
+})
diff --git a/lib/invoices/recurring-run-date.ts b/lib/invoices/recurring-run-date.ts
new file mode 100644
index 00000000..758c22c2
--- /dev/null
+++ b/lib/invoices/recurring-run-date.ts
@@ -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')),
+ }
+}
diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts
index 85a62e86..4e203603 100644
--- a/lib/invoices/recurring-schedule-service.ts
+++ b/lib/invoices/recurring-schedule-service.ts
@@ -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
diff --git a/lib/pending-operations/__tests__/recurring-schedule-executors.test.ts b/lib/pending-operations/__tests__/recurring-schedule-executors.test.ts
index f5bbe7e8..0d3b2996 100644
--- a/lib/pending-operations/__tests__/recurring-schedule-executors.test.ts
+++ b/lib/pending-operations/__tests__/recurring-schedule-executors.test.ts
@@ -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
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 5c8d0032..bb29a65d 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -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)
diff --git a/messages/en.json b/messages/en.json
index c14f0960..8b8f49e7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/sv.json b/messages/sv.json
index 2d22336c..d139191f 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -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",