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 ? ( +

{errors.run_date.message}

+ ) : ( +

+ {upcomingRuns.length > 0 + ? t('upcoming_runs', { dates: upcomingRuns.map((d) => formatDate(d)).join(', ') }) + : t('run_date_hint')} +

+ )} +
{ 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",