feat(invoices): choose the first invoice date on recurring schedules (#2338)
* feat(invoices): choose the first invoice date on recurring schedules A yearly or quarterly recurring schedule had no way to say which month it bills in: the dialog exposed interval and day of month only, so a yearly schedule created in September always fired in September. The phase of a schedule is fully defined by its first run date, which the table already stores as next_run_date and the create API already accepted as start_date but nothing exposed. - Dialog: new date field (first invoice date on create, next invoice date on edit), prefilled with the next natural occurrence so the default is "no offset"; kept in step with day of month both ways; shows the following three run dates so the phase is visible. Sent as start_date on create and as next_run_date on edit only when the user actually re-phased. - API: create validates start_date (on the day_of_month grid, not in the past); update accepts next_run_date (on the grid for the effective day, strictly after today in Stockholm) and lets it win over the automatic recompute a day change or reactivation does. - Staged operations / MCP: start_date documented as the phase; update tool gains next_run_date. Commit executor rejects off-grid dates and rolls a date that went stale before approval forward on its own grid. - lib/invoices/recurring-run-date.ts: pure, client-safe grid helpers shared by the dialog, the routes, the executors and the cron service. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC * fix(invoices): validate schedule dates at MCP staging, use Stockholm's calendar in the dialog Resolves the skeptic and CI findings on #2338 in one pass: - MCP staging tools now apply the same grid and past/future rules as the routes to start_date and next_run_date, so the preview a human approves is exactly what the commit executor writes (previously an off-grid date staged fine and failed at approval, and a past next_run_date was rolled to another date silently). - The dialog computes today and the default first invoice date in Europe/Stockholm instead of the browser's zone, matching the server; getStockholmDateHour moved to the client-safe module and is re-exported from the service. - gnubok_update_recurring_schedule description trimmed under the 280-char limit while keeping the clamping and Stockholm phrases the registration test requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqkDCm4nhPZda2ft5WpRNC --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0c854ac54f
commit
238cbe13f9
@@ -1608,5 +1608,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one".
|
||||
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
|
||||
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
|
||||
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
|
||||
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
|
||||
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
|
||||
|
||||
@@ -147,6 +147,72 @@ describe('PATCH /api/invoices/recurring/[id] reactivation', () => {
|
||||
expect(updatePayloads[0]).toEqual({ interval_months: 3 })
|
||||
})
|
||||
|
||||
it('writes an explicit future next_run_date verbatim (re-phasing a yearly schedule)', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ next_run_date: '2027-02-15' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({ next_run_date: '2027-02-15' })
|
||||
expect(updatePayloads[0]).not.toHaveProperty('last_run_warning')
|
||||
})
|
||||
|
||||
it('an explicit next_run_date wins over the day_of_month recompute', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ day_of_month: 20, next_run_date: '2027-02-20' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, next_run_date: '2027-02-20' })
|
||||
})
|
||||
|
||||
it('an explicit next_run_date on reactivation replaces the roll-forward and clears the warning', async () => {
|
||||
scheduleRow = { next_run_date: '2026-01-05', day_of_month: 5, interval_months: 12 }
|
||||
|
||||
await PATCH(patchReq({ status: 'active', next_run_date: '2026-09-05' }), params)
|
||||
expect(updatePayloads[0]).toMatchObject({
|
||||
status: 'active',
|
||||
next_run_date: '2026-09-05',
|
||||
last_run_warning: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a next_run_date that is not after today in Stockholm', async () => {
|
||||
scheduleRow = { next_run_date: '2026-08-06', day_of_month: 6, interval_months: 1 }
|
||||
|
||||
// Today is 2026-07-06 in Sweden: same day is refused, so an edit can never
|
||||
// trigger a same-hour send.
|
||||
const res = await PATCH(patchReq({ next_run_date: '2026-07-06' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(body.error).toMatch(/after today/)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a next_run_date off the day_of_month grid', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
const res = await PATCH(patchReq({ next_run_date: '2027-02-14' }), params)
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/day_of_month/)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates next_run_date against the edited day_of_month, not the stored one', async () => {
|
||||
scheduleRow = { next_run_date: '2027-01-15', day_of_month: 15, interval_months: 12 }
|
||||
|
||||
const res = await PATCH(patchReq({ day_of_month: 20, next_run_date: '2027-02-15' }), params)
|
||||
const { status } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 404 for next_run_date on a schedule that does not exist', async () => {
|
||||
scheduleRow = null
|
||||
|
||||
const res = await PATCH(patchReq({ next_run_date: '2027-02-15' }), params)
|
||||
const { status } = await parseJsonResponse<{ type: string }>(res)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('does not touch next_run_date or warning when pausing', async () => {
|
||||
scheduleRow = { next_run_date: '2026-07-05', day_of_month: 5 }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -117,8 +118,13 @@ export const PATCH = withRouteContext(
|
||||
}
|
||||
|
||||
// Recompute next_run_date when either the schedule is being reactivated
|
||||
// (from a stale date) or its day-of-month actually changed via an edit.
|
||||
if (input.status === 'active' || input.day_of_month !== undefined) {
|
||||
// (from a stale date) or its day-of-month actually changed via an edit,
|
||||
// unless the caller re-phases the schedule with an explicit date.
|
||||
if (
|
||||
input.status === 'active' ||
|
||||
input.day_of_month !== undefined ||
|
||||
input.next_run_date !== undefined
|
||||
) {
|
||||
const { data: existing } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('next_run_date, day_of_month, interval_months')
|
||||
@@ -141,6 +147,28 @@ export const PATCH = withRouteContext(
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
// An explicit next_run_date is the user re-phasing the schedule (move a
|
||||
// yearly invoice from January to February). It must be on the grid for
|
||||
// the effective day and strictly in the future (same no-surprise-send
|
||||
// rule as the recompute below), and it wins over that recompute.
|
||||
if (input.next_run_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(input.next_run_date, effectiveDay)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'next_run_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (input.next_run_date <= todayStockholm) {
|
||||
return NextResponse.json(
|
||||
{ error: 'next_run_date must be after today', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute to the next STRICTLY-future occurrence (never today, so an
|
||||
// edit or reactivation can't trigger a same-hour surprise send; today's
|
||||
// invoice is the explicit run-now action instead) when either:
|
||||
@@ -151,7 +179,7 @@ export const PATCH = withRouteContext(
|
||||
// next_run_date alone, so an unrelated edit never skips an imminent
|
||||
// send; a changed interval applies from the next run onward.
|
||||
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
||||
if (staleOnReactivate || dayChanged) {
|
||||
if (input.next_run_date === undefined && (staleOnReactivate || dayChanged)) {
|
||||
if (effectiveInterval === 1) {
|
||||
// Monthly keeps its long-standing semantics: re-anchor on today so
|
||||
// a day edit lands on the new day's nearest future occurrence.
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
@@ -215,6 +215,83 @@ describe('POST /api/invoices/recurring', () => {
|
||||
expect(itemRows[1].dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects a start_date that is not on the day_of_month grid', async () => {
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2999-02-14',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.type).toBe('validation_error')
|
||||
expect(body.error).toMatch(/day_of_month/)
|
||||
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a start_date in the past', async () => {
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2020-02-15',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toMatch(/past/)
|
||||
expect(findCall('recurring_invoice_schedules', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses an explicit start_date as the first run so a yearly schedule keeps its month', async () => {
|
||||
const createdSchedule = {
|
||||
id: 's-2',
|
||||
company_id: 'company-1',
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
next_run_date: '2999-02-15',
|
||||
status: 'active',
|
||||
}
|
||||
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
||||
enqueue({ data: createdSchedule, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Årsavgift',
|
||||
day_of_month: 15,
|
||||
interval_months: 12,
|
||||
start_date: '2999-02-15',
|
||||
items: [{ description: 'Licens', quantity: 1, unit: 'st', unit_price: 12000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
expect(status).toBe(201)
|
||||
const insertArgs = findCall('recurring_invoice_schedules', 'insert')
|
||||
expect(insertArgs?.[0]).toMatchObject({ next_run_date: '2999-02-15', interval_months: 12 })
|
||||
})
|
||||
|
||||
it('creates a schedule on the happy path', async () => {
|
||||
const createdSchedule = {
|
||||
id: 's-1',
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { CreateRecurringScheduleSchema } from '@/lib/api/schemas'
|
||||
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
|
||||
import {
|
||||
computeInitialRunDate,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -89,6 +93,30 @@ export const POST = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// An explicit first run date fixes the month phase (yearly in February,
|
||||
// quarterly Feb/May/Aug/Nov). It must sit on the schedule grid, or the
|
||||
// cron would drift back to day_of_month after the first run, and it
|
||||
// cannot be in the past: the dialog prefills the next occurrence, so a
|
||||
// past date is a stale form, not an intent to backfill.
|
||||
if (input.start_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(input.start_date, input.day_of_month)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'start_date must fall on day_of_month (clamped to the last day in shorter months)',
|
||||
type: 'validation_error',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
if (input.start_date < todayStockholm) {
|
||||
return NextResponse.json(
|
||||
{ error: 'start_date cannot be in the past', type: 'validation_error' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const nextRunDate = computeInitialRunDate(
|
||||
new Date(),
|
||||
input.day_of_month,
|
||||
|
||||
@@ -30,12 +30,48 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import {
|
||||
alignRunDateToDay,
|
||||
getStockholmDateHour,
|
||||
isoFromParts,
|
||||
lastDayOfMonth,
|
||||
parseIsoDate,
|
||||
projectRunDates,
|
||||
runDateMatchesDayOfMonth,
|
||||
} from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
|
||||
/**
|
||||
* Today as yyyy-mm-dd in Europe/Stockholm: the calendar the server validates
|
||||
* against. The browser's own zone must not leak in, or a user west of Sweden
|
||||
* late in the evening would pass client validation and get a 400.
|
||||
*/
|
||||
function stockholmTodayIso(): string {
|
||||
return getStockholmDateHour(new Date()).date
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side twin of computeInitialRunDate on Stockholm's calendar: this
|
||||
* month's occurrence of day_of_month if it has not passed, otherwise next
|
||||
* month's. Prefills the date field so the default is "no offset", exactly
|
||||
* what the server would pick when start_date is omitted.
|
||||
*/
|
||||
function defaultRunDate(dayOfMonth: number): string {
|
||||
const today = parseIsoDate(stockholmTodayIso())
|
||||
if (!today) return ''
|
||||
const { year: y, month0: m, day: todayDay } = today
|
||||
const thisMonthDay = Math.min(dayOfMonth, lastDayOfMonth(y, m))
|
||||
if (todayDay <= thisMonthDay) return isoFromParts(y, m, thisMonthDay)
|
||||
const ny = m === 11 ? y + 1 : y
|
||||
const nm = (m + 1) % 12
|
||||
return isoFromParts(ny, nm, Math.min(dayOfMonth, lastDayOfMonth(ny, nm)))
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -108,21 +144,62 @@ function NewRecurringScheduleForm({
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
return z.object({
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
interval_months: z.number().int().min(1).max(12),
|
||||
send_hour: z.number().int().min(0).max(23),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
})
|
||||
}, [t])
|
||||
return z
|
||||
.object({
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
interval_months: z.number().int().min(1).max(12),
|
||||
// First run (create) or next run (edit). The month is what the user
|
||||
// is really choosing: it fixes the phase of a quarterly/yearly
|
||||
// schedule ("bill in February"). Sent as start_date / next_run_date.
|
||||
run_date: z.string().regex(ISO_DATE_RE, t('validation_run_date_required')),
|
||||
send_hour: z.number().int().min(0).max(23),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!ISO_DATE_RE.test(data.run_date)) return
|
||||
// Mirrors the API: the date must sit on the schedule grid for the
|
||||
// chosen day (the field syncs with day_of_month, so this only fires
|
||||
// on a hand-typed mismatch), and it may not be in the past. An edit
|
||||
// that keeps the stored date is not re-validated: a paused schedule
|
||||
// with a stale date is reactivated by the server's roll-forward.
|
||||
if (!runDateMatchesDayOfMonth(data.run_date, data.day_of_month)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['run_date'],
|
||||
message: t('validation_run_date_grid', { day: data.day_of_month }),
|
||||
})
|
||||
return
|
||||
}
|
||||
const today = stockholmTodayIso()
|
||||
if (schedule) {
|
||||
// The stored date, moved onto the grid for the (possibly edited)
|
||||
// day, is the "unchanged" reference: a day-only edit keeps the
|
||||
// server's own recompute and is not a re-phase.
|
||||
const unchanged = alignRunDateToDay(schedule.next_run_date, data.day_of_month)
|
||||
if (data.run_date !== unchanged && data.run_date <= today) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['run_date'],
|
||||
message: t('validation_run_date_not_future'),
|
||||
})
|
||||
}
|
||||
} else if (data.run_date < today) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['run_date'],
|
||||
message: t('validation_run_date_past'),
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [t, schedule])
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
@@ -141,6 +218,7 @@ function NewRecurringScheduleForm({
|
||||
name: schedule.name,
|
||||
day_of_month: schedule.day_of_month,
|
||||
interval_months: schedule.interval_months ?? 1,
|
||||
run_date: schedule.next_run_date,
|
||||
send_hour: schedule.send_hour ?? 8,
|
||||
payment_terms_days: schedule.payment_terms_days,
|
||||
currency: schedule.currency,
|
||||
@@ -166,6 +244,7 @@ function NewRecurringScheduleForm({
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
interval_months: 1,
|
||||
run_date: defaultRunDate(15),
|
||||
send_hour: 8,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
@@ -190,12 +269,23 @@ function NewRecurringScheduleForm({
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const { run_date, ...rest } = data
|
||||
// Create: the chosen date is the first run. Edit: only send it when the
|
||||
// user re-phased the schedule (a different month/year than the stored
|
||||
// date aligned to the chosen day), so an unrelated edit, a day-only
|
||||
// edit or a reactivation keeps the server's own recompute and never
|
||||
// re-sends a stale date.
|
||||
const rePhased =
|
||||
!!schedule && run_date !== alignRunDateToDay(schedule.next_run_date, rest.day_of_month)
|
||||
const body = schedule
|
||||
? { ...rest, ...(rePhased ? { next_run_date: run_date } : {}) }
|
||||
: { ...rest, start_date: run_date }
|
||||
const res = await fetch(
|
||||
schedule ? `/api/invoices/recurring/${schedule.id}` : '/api/invoices/recurring',
|
||||
{
|
||||
method: schedule ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
@@ -237,6 +327,24 @@ function NewRecurringScheduleForm({
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
const watchDay = watch('day_of_month')
|
||||
const watchInterval = watch('interval_months')
|
||||
const watchRunDate = watch('run_date')
|
||||
// Keep the date on the schedule grid when the day field changes: same
|
||||
// month, day moved to the new day_of_month (clamped). The reverse sync
|
||||
// (date -> day) lives in the date field's onChange.
|
||||
useEffect(() => {
|
||||
if (!Number.isInteger(watchDay) || watchDay < 1 || watchDay > 31) return
|
||||
const aligned = alignRunDateToDay(watchRunDate, watchDay)
|
||||
if (aligned !== watchRunDate) setValue('run_date', aligned, { shouldValidate: true })
|
||||
// watchRunDate is deliberately not a dependency: the effect exists to
|
||||
// react to the day, not to re-run on every date keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [watchDay, setValue])
|
||||
const upcomingRuns =
|
||||
Number.isInteger(watchDay) && watchInterval >= 1
|
||||
? projectRunDates(watchRunDate, watchDay, watchInterval, 4).slice(1)
|
||||
: []
|
||||
// Automatic sending requires a customer email; without one the cron would
|
||||
// just produce a monthly draft + warning. Block it at the source.
|
||||
const watchCustomerId = watch('customer_id')
|
||||
@@ -360,6 +468,44 @@ function NewRecurringScheduleForm({
|
||||
{t('day_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="run_date">
|
||||
{schedule ? t('run_date_edit_label') : t('run_date_label')}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="run_date"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="run_date"
|
||||
type="date"
|
||||
className="tabular-nums"
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
field.onChange(next)
|
||||
// Picking a day that is not where day_of_month lands in
|
||||
// that month means the user changed the day too, so
|
||||
// follow it. Feb 28 with day 31 stays 31 (clamped hit).
|
||||
const parsed = parseIsoDate(next)
|
||||
if (parsed && !runDateMatchesDayOfMonth(next, watchDay)) {
|
||||
setValue('day_of_month', parsed.day, { shouldValidate: true })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.run_date ? (
|
||||
<p className="text-sm text-destructive mt-1">{errors.run_date.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{upcomingRuns.length > 0
|
||||
? t('upcoming_runs', { dates: upcomingRuns.map((d) => formatDate(d)).join(', ') })
|
||||
: t('run_date_hint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="send_hour">{t('send_hour_label')}</Label>
|
||||
<Controller
|
||||
|
||||
@@ -209,6 +209,37 @@ describe('gnubok_create_recurring_schedule: validation and staging', () => {
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a start_date off the day_of_month grid at staging, before writing anything', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
|
||||
|
||||
await expect(
|
||||
createTool().execute(
|
||||
{ ...validArgs, day_of_month: 1, start_date: '2999-09-10' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/day_of_month/)
|
||||
// Customer lookup only; no pending_operations insert.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a start_date in the past at staging', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
|
||||
|
||||
await expect(
|
||||
createTool().execute(
|
||||
{ ...validArgs, day_of_month: 25, start_date: '2020-01-25' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/past/)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stages the schedule for approval at medium risk', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CUSTOMER_ID, name: 'Test Customer AB', email: 'billing@example.test' } })
|
||||
@@ -347,6 +378,86 @@ describe('gnubok_update_recurring_schedule: validation and staging', () => {
|
||||
expect(result.preview.current.recurring_schedule_id).toBe(SCHEDULE_ID)
|
||||
})
|
||||
|
||||
it('stages an explicit next_run_date so an agent can re-phase a yearly schedule', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: currentSchedule({ interval_months: 12 }) })
|
||||
enqueue({ data: { id: 'op-recurring-5' } })
|
||||
|
||||
const result = (await updateTool().execute(
|
||||
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-02-25' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as {
|
||||
staged: boolean
|
||||
preview: { current: Record<string, unknown>; proposed: Record<string, unknown> }
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.current.next_run_date).toBe('2999-01-25')
|
||||
expect(result.preview.proposed.next_run_date).toBe('2999-02-25')
|
||||
})
|
||||
|
||||
it('rejects a next_run_date off the effective day_of_month grid at staging', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
|
||||
|
||||
await expect(
|
||||
updateTool().execute(
|
||||
{ schedule_id: SCHEDULE_ID, next_run_date: '2999-11-01' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/day_of_month 15/)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('validates next_run_date against a day_of_month changed in the same call', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: currentSchedule({ day_of_month: 15 }) })
|
||||
enqueue({ data: { id: 'op-recurring-6' } })
|
||||
|
||||
const result = (await updateTool().execute(
|
||||
{ schedule_id: SCHEDULE_ID, day_of_month: 1, next_run_date: '2999-11-01' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; preview: { proposed: Record<string, unknown> } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.proposed).toMatchObject({ day_of_month: 1, next_run_date: '2999-11-01' })
|
||||
})
|
||||
|
||||
it('rejects a next_run_date that is not after today at staging', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: currentSchedule() })
|
||||
|
||||
await expect(
|
||||
updateTool().execute(
|
||||
{ schedule_id: SCHEDULE_ID, next_run_date: '2020-01-25' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/after today/)
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a malformed next_run_date before querying the database', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
updateTool().execute(
|
||||
{ schedule_id: SCHEDULE_ID, next_run_date: 'februari', dry_run: true },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/next_run_date/i)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews an item replace against the current lines', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: currentSchedule() })
|
||||
|
||||
@@ -167,7 +167,8 @@ import {
|
||||
CreateRecurringScheduleParamsSchema,
|
||||
UpdateRecurringScheduleParamsSchema,
|
||||
} from '@/lib/pending-operations/schemas/recurring-schedule'
|
||||
import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service'
|
||||
import { computeInitialRunDate, getStockholmDateHour } from '@/lib/invoices/recurring-schedule-service'
|
||||
import { runDateMatchesDayOfMonth } from '@/lib/invoices/recurring-run-date'
|
||||
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { effectiveQuoteStatus } from '@/lib/invoices/quote-status'
|
||||
@@ -20900,7 +20901,10 @@ export const tools: McpTool[] = [
|
||||
type: 'boolean',
|
||||
description: 'Default false: invoices are created as drafts for manual review. true emails every generated invoice to the customer with no further approval; requires the customer to have an email address.',
|
||||
},
|
||||
start_date: { type: 'string', description: 'YYYY-MM-DD first run date. Omit to run on the next occurrence of day_of_month.' },
|
||||
start_date: {
|
||||
type: 'string',
|
||||
description: 'YYYY-MM-DD first run date; fixes the month phase of a quarterly/yearly schedule (e.g. 2027-02-15 with interval_months 12 = every February). Must fall on day_of_month (clamped in shorter months) and not be in the past. Omit to run on the next occurrence of day_of_month.',
|
||||
},
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
@@ -21006,6 +21010,21 @@ export const tools: McpTool[] = [
|
||||
throw new Error('Customer has no email address: auto_send requires one. Stage with auto_send=false or add an email first.')
|
||||
}
|
||||
|
||||
// Same rules the create route enforces, applied at staging so the
|
||||
// preview the human approves is what the commit executor will write:
|
||||
// an off-grid or past start_date must fail here, not after approval.
|
||||
if (params.start_date !== undefined) {
|
||||
if (!runDateMatchesDayOfMonth(params.start_date, params.day_of_month)) {
|
||||
throw new Error(
|
||||
`start_date ${params.start_date} does not fall on day_of_month ${params.day_of_month} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month.`,
|
||||
)
|
||||
}
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
if (params.start_date < todayStockholm) {
|
||||
throw new Error(`start_date ${params.start_date} is in the past (today in Europe/Stockholm is ${todayStockholm}).`)
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyTotalExclVat =
|
||||
Math.round(params.items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0) * 100) / 100
|
||||
|
||||
@@ -21054,7 +21073,7 @@ export const tools: McpTool[] = [
|
||||
name: 'gnubok_update_recurring_schedule',
|
||||
keywords: ['återkommande faktura', 'stående faktura'],
|
||||
title: 'Update Recurring Invoice Schedule',
|
||||
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). Pause/resume via status. items replace all lines; omit to keep them. day_of_month clamps to the last day in shorter months; send_hour is a whole hour in Europe/Stockholm.',
|
||||
description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). status pauses/resumes; items replace all lines; day_of_month clamps to the last day in shorter months; next_run_date re-phases (yearly in February); send_hour is Europe/Stockholm.',
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -21090,6 +21109,10 @@ export const tools: McpTool[] = [
|
||||
enum: ['active', 'paused'],
|
||||
description: 'paused stops generating invoices; active resumes. Reactivating from a stale date rolls next_run_date to the next future occurrence, never today.',
|
||||
},
|
||||
next_run_date: {
|
||||
type: 'string',
|
||||
description: 'YYYY-MM-DD explicit next run; re-phases the schedule (e.g. move a yearly invoice to February). Must fall on the effective day_of_month (clamped in shorter months) and be after today in Europe/Stockholm. Wins over the automatic recompute.',
|
||||
},
|
||||
default_dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
@@ -21160,6 +21183,7 @@ export const tools: McpTool[] = [
|
||||
'notes',
|
||||
'auto_send',
|
||||
'status',
|
||||
'next_run_date',
|
||||
]) {
|
||||
if (args[key] !== undefined) changes[key] = args[key]
|
||||
}
|
||||
@@ -21218,6 +21242,23 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
}
|
||||
|
||||
// Same rules the PATCH route enforces, applied at staging so the
|
||||
// proposed next_run_date in the preview is exactly what gets written
|
||||
// on approval (the executor only rolls a date that went stale while
|
||||
// waiting for approval; it never silently moves a date staged today).
|
||||
if (parsedChanges.next_run_date !== undefined) {
|
||||
const effectiveDay = parsedChanges.day_of_month ?? (current.day_of_month as number)
|
||||
if (!runDateMatchesDayOfMonth(parsedChanges.next_run_date, effectiveDay)) {
|
||||
throw new Error(
|
||||
`next_run_date ${parsedChanges.next_run_date} does not fall on day_of_month ${effectiveDay} (clamped to the last day in shorter months). Pick a date on that day or change day_of_month in the same call.`,
|
||||
)
|
||||
}
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
if (parsedChanges.next_run_date <= todayStockholm) {
|
||||
throw new Error(`next_run_date must be after today in Europe/Stockholm (${todayStockholm}).`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentItems = ((current.items as Array<Record<string, unknown>>) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.sort_order) - Number(b.sort_order))
|
||||
|
||||
+8
-1
@@ -876,7 +876,9 @@ export const CreateRecurringScheduleSchema = z.object({
|
||||
// Copied onto invoices.default_dimensions for every generated invoice.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Optional: when to first run. Defaults to next occurrence of day_of_month
|
||||
// (today if day_of_month === today, otherwise next month).
|
||||
// (today if day_of_month === today, otherwise next month). Fixes the month
|
||||
// phase of a quarterly/yearly schedule ("bill in February"); must be on the
|
||||
// schedule grid (day = day_of_month clamped) and not in the past.
|
||||
start_date: isoDate.optional(),
|
||||
items: z.array(RecurringScheduleItemSchema).min(1, 'At least one item is required'),
|
||||
})
|
||||
@@ -896,6 +898,11 @@ export const UpdateRecurringScheduleSchema = z.object({
|
||||
notes: z.string().nullable().optional(),
|
||||
auto_send: z.boolean().optional(),
|
||||
status: z.enum(['active', 'paused']).optional(),
|
||||
// Explicit next run date: re-phases the schedule (e.g. move a yearly
|
||||
// schedule from January to February). Must be on the schedule grid for
|
||||
// the effective day_of_month and strictly after today in Stockholm; wins
|
||||
// over the automatic recompute a day_of_month edit or reactivation does.
|
||||
next_run_date: isoDate.optional(),
|
||||
// Replaces the whole bag if provided ({} clears all tags). Omit to keep.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Replace all items if provided. Omit to keep existing items unchanged.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
alignRunDateToDay,
|
||||
projectRunDates,
|
||||
runDateMatchesDayOfMonth,
|
||||
} from '../recurring-run-date'
|
||||
|
||||
describe('runDateMatchesDayOfMonth', () => {
|
||||
it('accepts a date on the exact day', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-15', 15)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the clamped last day in a shorter month', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-28', 31)).toBe(true)
|
||||
expect(runDateMatchesDayOfMonth('2028-02-29', 31)).toBe(true)
|
||||
expect(runDateMatchesDayOfMonth('2027-04-30', 31)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a day that is not where day_of_month lands', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-02-14', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-03-30', 31)).toBe(false)
|
||||
// 28 is not the clamp of 31 in a 31-day month.
|
||||
expect(runDateMatchesDayOfMonth('2027-01-28', 31)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed or calendar-invalid dates', () => {
|
||||
expect(runDateMatchesDayOfMonth('2027-2-15', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-13-15', 15)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('2027-02-31', 31)).toBe(false)
|
||||
expect(runDateMatchesDayOfMonth('', 15)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignRunDateToDay', () => {
|
||||
it('moves the day within the same month', () => {
|
||||
expect(alignRunDateToDay('2027-02-15', 20)).toBe('2027-02-20')
|
||||
})
|
||||
|
||||
it('clamps to the last day of the month', () => {
|
||||
expect(alignRunDateToDay('2027-02-15', 31)).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('returns the input unchanged when it is not a date', () => {
|
||||
expect(alignRunDateToDay('', 20)).toBe('')
|
||||
expect(alignRunDateToDay('nope', 20)).toBe('nope')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectRunDates', () => {
|
||||
it('projects a yearly schedule on its February phase', () => {
|
||||
expect(projectRunDates('2027-02-15', 15, 12, 3)).toEqual([
|
||||
'2027-02-15',
|
||||
'2028-02-15',
|
||||
'2029-02-15',
|
||||
])
|
||||
})
|
||||
|
||||
it('projects a quarterly schedule and clamps day 31', () => {
|
||||
expect(projectRunDates('2027-02-28', 31, 3, 4)).toEqual([
|
||||
'2027-02-28',
|
||||
'2027-05-31',
|
||||
'2027-08-31',
|
||||
'2027-11-30',
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls over the year boundary for a monthly schedule', () => {
|
||||
expect(projectRunDates('2027-11-05', 5, 1, 3)).toEqual([
|
||||
'2027-11-05',
|
||||
'2027-12-05',
|
||||
'2028-01-05',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for an unparseable first date', () => {
|
||||
expect(projectRunDates('', 15, 12, 3)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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')),
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ import {
|
||||
import { snapshotInvoicePayee } from '@/lib/invoices/invoice-payee'
|
||||
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { lastDayOfMonth, isoFromParts } from '@/lib/invoices/recurring-run-date'
|
||||
|
||||
// Lives in the client-safe module now (the dialog needs Stockholm's calendar
|
||||
// day too); re-exported so the cron, routes and executors keep importing it
|
||||
// from here.
|
||||
export { getStockholmDateHour } from '@/lib/invoices/recurring-run-date'
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
@@ -74,22 +80,6 @@ export interface ExecuteResult {
|
||||
warning: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Last day of the month for the given year/month (1-indexed month).
|
||||
* Used to clamp day_of_month values >28 in shorter months.
|
||||
*/
|
||||
function lastDayOfMonth(year: number, monthIndex0: number): number {
|
||||
// Day 0 of next month = last day of this month.
|
||||
return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function isoFromParts(year: number, monthIndex0: number, day: number): string {
|
||||
const yyyy = year.toString().padStart(4, '0')
|
||||
const mm = (monthIndex0 + 1).toString().padStart(2, '0')
|
||||
const dd = day.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function assertValidCadence(dayOfMonth: number, intervalMonths: number): void {
|
||||
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
||||
@@ -204,29 +194,6 @@ export function computeInitialRunDate(
|
||||
return computeNextRunDate(today, dayOfMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calendar date (yyyy-mm-dd) and hour (0-23) in Europe/Stockholm
|
||||
* for a given instant. The recurring cron runs in UTC on Vercel, but users
|
||||
* pick a send time in Swedish local time, so we need "what day and hour is it
|
||||
* in Sweden right now". Uses Intl (DST-aware, no extra dependency); en-CA +
|
||||
* hourCycle 'h23' guarantees zero-padded ISO-shaped parts and a 0-23 hour.
|
||||
*/
|
||||
export function getStockholmDateHour(instant: Date): { date: string; hour: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(instant)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
return {
|
||||
date: `${get('year')}-${get('month')}-${get('day')}`,
|
||||
hour: Number(get('hour')),
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteScheduleOptions {
|
||||
/**
|
||||
* Defence-in-depth sandbox suppression (ASVS V2.3): callers that resolved
|
||||
|
||||
@@ -173,6 +173,24 @@ describe('commitPendingOperation: create_recurring_schedule', () => {
|
||||
expect(result.error).toMatch(/email/i)
|
||||
})
|
||||
|
||||
it('rejects a start_date off the day_of_month grid at commit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
enqueue({ data: { id: CUSTOMER_ID, email: null } }) // customer
|
||||
enqueue({ data: null }) // status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('create_recurring_schedule', { ...createParams, start_date: '2999-09-24' }),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/day_of_month/)
|
||||
})
|
||||
|
||||
it('rejects tampered params at the commit boundary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
@@ -347,6 +365,77 @@ describe('commitPendingOperation: update_recurring_schedule', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('writes an explicit future next_run_date verbatim and skips the recompute', async () => {
|
||||
const { supabase, updates } = createCapturingSupabase([
|
||||
{ data: { id: 'op-recurring-1' }, error: null }, // claim
|
||||
{ data: { ...existingRow, interval_months: 12 }, error: null },
|
||||
{ data: null, error: null }, // schedule update
|
||||
{ data: null, error: null }, // finalize
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { day_of_month: 20, next_run_date: '2999-02-20' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(updates.recurring_invoice_schedules).toEqual([
|
||||
{ day_of_month: 20, next_run_date: '2999-02-20' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls a next_run_date that went stale before approval forward on its own grid', async () => {
|
||||
const { supabase, updates } = createCapturingSupabase([
|
||||
{ data: { id: 'op-recurring-1' }, error: null }, // claim
|
||||
{ data: { ...existingRow, interval_months: 12 }, error: null },
|
||||
{ data: null, error: null }, // schedule update
|
||||
{ data: null, error: null }, // finalize
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { next_run_date: '2020-02-25' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
const next = String(updates.recurring_invoice_schedules?.[0]?.next_run_date)
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
// Strictly future, and still a February 25 (the chosen phase survives).
|
||||
expect(next > todayStockholm).toBe(true)
|
||||
expect(next.slice(5)).toBe('02-25')
|
||||
})
|
||||
|
||||
it('rejects a next_run_date off the day_of_month grid at commit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
enqueue({ data: existingRow }) // existing
|
||||
enqueue({ data: null }) // status update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp('update_recurring_schedule', {
|
||||
schedule_id: SCHEDULE_ID,
|
||||
changes: { next_run_date: '2999-02-24' },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(result.error).toMatch(/day_of_month/)
|
||||
})
|
||||
|
||||
it('rejects enabling auto_send at commit when the customer has no email', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-recurring-1' } }) // claim
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user