feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d41ef2a909
commit
a0ca692fed
@@ -2744,3 +2744,32 @@ describe('CreateRecurringScheduleSchema send_hour', () => {
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, send_hour: -1 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateRecurringScheduleSchema interval_months', () => {
|
||||
const base = {
|
||||
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'Retainer',
|
||||
day_of_month: 15,
|
||||
items: [{ description: 'Service', quantity: 1, unit_price: 1000 }],
|
||||
}
|
||||
|
||||
it('defaults interval_months to 1 (monthly) when omitted', () => {
|
||||
const result = CreateRecurringScheduleSchema.safeParse(base)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.interval_months).toBe(1)
|
||||
})
|
||||
|
||||
it('accepts quarterly, half-yearly and yearly intervals', () => {
|
||||
for (const interval of [3, 6, 12]) {
|
||||
const result = CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: interval })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.interval_months).toBe(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects out-of-range or fractional intervals', () => {
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 0 }).success).toBe(false)
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 13 }).success).toBe(false)
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 1.5 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -725,6 +725,9 @@ export const CreateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid,
|
||||
name: z.string().min(1, 'Schedule name is required').max(200),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
// Months between runs: 1 = monthly (default), 3 = quarterly, 6 = half-
|
||||
// yearly, 12 = yearly. Any 1-12 is accepted (e.g. every 2 months).
|
||||
interval_months: z.number().int().min(1).max(12).default(1),
|
||||
// Whole hour (0-23) in Europe/Stockholm at which the invoice is sent.
|
||||
send_hour: z.number().int().min(0).max(23).default(8),
|
||||
payment_terms_days: z.number().int().min(0).max(90).default(30),
|
||||
@@ -745,6 +748,9 @@ export const UpdateRecurringScheduleSchema = z.object({
|
||||
customer_id: uuid.optional(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
day_of_month: z.number().int().min(1).max(31).optional(),
|
||||
// Changing the interval alone leaves next_run_date untouched: the new
|
||||
// cadence applies from the next run onward.
|
||||
interval_months: z.number().int().min(1).max(12).optional(),
|
||||
send_hour: z.number().int().min(0).max(23).optional(),
|
||||
payment_terms_days: z.number().int().min(0).max(90).optional(),
|
||||
currency: CurrencySchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
computeNextRunDate,
|
||||
computeInitialRunDate,
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
executeRecurringSchedule,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
@@ -153,6 +154,104 @@ describe('computeNextRunDate', () => {
|
||||
expect(() => computeNextRunDate(new Date(), 0)).toThrow()
|
||||
expect(() => computeNextRunDate(new Date(), 32)).toThrow()
|
||||
})
|
||||
|
||||
it('advances a quarter with interval 3', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 0, 15)), 15, 3)
|
||||
expect(result).toBe('2026-04-15')
|
||||
})
|
||||
|
||||
it('advances a quarter across the year boundary', () => {
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 10, 15)), 15, 3)
|
||||
expect(result).toBe('2027-02-15')
|
||||
})
|
||||
|
||||
it('clamps day 31 when a quarterly step lands in February', () => {
|
||||
// Nov 30 + 3 months = Feb; 2027 February has 28 days.
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2026, 10, 30)), 31, 3)
|
||||
expect(result).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('advances a full year with interval 12, keeping leap-day clamp', () => {
|
||||
// 2028-02-29 (leap) + 12 months, day 29 -> 2029-02-28.
|
||||
const result = computeNextRunDate(new Date(Date.UTC(2028, 1, 29)), 29, 12)
|
||||
expect(result).toBe('2029-02-28')
|
||||
})
|
||||
|
||||
it('rejects invalid interval_months', () => {
|
||||
expect(() => computeNextRunDate(new Date(), 15, 0)).toThrow()
|
||||
expect(() => computeNextRunDate(new Date(), 15, 13)).toThrow()
|
||||
expect(() => computeNextRunDate(new Date(), 15, 1.5)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollNextRunDateForward', () => {
|
||||
const today = new Date(Date.UTC(2026, 6, 6)) // 2026-07-06
|
||||
|
||||
it('monthly: rolls a stale date to the next occurrence on or after today', () => {
|
||||
expect(rollNextRunDateForward('2026-07-05', today, 5, 1, { allowToday: true }))
|
||||
.toBe('2026-08-05')
|
||||
expect(rollNextRunDateForward('2026-05-15', today, 15, 1, { allowToday: true }))
|
||||
.toBe('2026-07-15')
|
||||
})
|
||||
|
||||
it('monthly: allowToday keeps an occurrence landing on today', () => {
|
||||
expect(rollNextRunDateForward('2026-06-06', today, 6, 1, { allowToday: true }))
|
||||
.toBe('2026-07-06')
|
||||
})
|
||||
|
||||
it('monthly: default (strictly future) skips today', () => {
|
||||
expect(rollNextRunDateForward('2026-06-06', today, 6, 1)).toBe('2026-08-06')
|
||||
})
|
||||
|
||||
it('quarterly: preserves the month phase across a missed run', () => {
|
||||
// Jan 15 quarterly run missed; today is Jul 6 -> Jul 15, NOT Feb/Aug 15.
|
||||
expect(rollNextRunDateForward('2026-01-15', today, 15, 3, { allowToday: true }))
|
||||
.toBe('2026-07-15')
|
||||
// Apr 5 missed -> Jul 5 already past today -> Oct 5.
|
||||
expect(rollNextRunDateForward('2026-04-05', today, 5, 3, { allowToday: true }))
|
||||
.toBe('2026-10-05')
|
||||
})
|
||||
|
||||
it('yearly: rolls a missed run a whole year forward', () => {
|
||||
expect(rollNextRunDateForward('2026-03-01', today, 1, 12, { allowToday: true }))
|
||||
.toBe('2027-03-01')
|
||||
})
|
||||
|
||||
it('keeps a future anchor as-is (day edit within the anchor month)', () => {
|
||||
// Quarterly schedule anchored on Oct; day edited to 20 -> stays in Oct.
|
||||
expect(rollNextRunDateForward('2026-10-15', today, 20, 3)).toBe('2026-10-20')
|
||||
})
|
||||
|
||||
it('re-derives the day from day_of_month when the anchor was clamped', () => {
|
||||
// Anchor 2026-02-28 stored for a day-31 schedule; monthly roll from a
|
||||
// stale date recovers day 31 in months that have it.
|
||||
expect(rollNextRunDateForward('2026-02-28', today, 31, 1, { allowToday: true }))
|
||||
.toBe('2026-07-31')
|
||||
})
|
||||
|
||||
it('clamps per month while stepping (quarterly day 31 through February)', () => {
|
||||
const winter = new Date(Date.UTC(2027, 1, 10)) // 2027-02-10
|
||||
expect(rollNextRunDateForward('2026-11-30', winter, 31, 3, { allowToday: true }))
|
||||
.toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('rejects malformed anchors and invalid cadence', () => {
|
||||
expect(() => rollNextRunDateForward('2026-1-5', today, 5, 1)).toThrow()
|
||||
expect(() => rollNextRunDateForward('2026-01-05', today, 5, 0)).toThrow()
|
||||
expect(() => rollNextRunDateForward('2026-01-05', today, 0, 1)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects calendar-invalid anchors that pass the shape regex', () => {
|
||||
expect(() => rollNextRunDateForward('2026-13-05', today, 5, 1)).toThrow()
|
||||
expect(() => rollNextRunDateForward('2026-00-05', today, 5, 1)).toThrow()
|
||||
expect(() => rollNextRunDateForward('2026-02-31', today, 31, 1)).toThrow()
|
||||
expect(() => rollNextRunDateForward('2026-04-00', today, 5, 1)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects fractional day_of_month', () => {
|
||||
expect(() => rollNextRunDateForward('2026-01-05', today, 5.5, 1)).toThrow()
|
||||
expect(() => computeNextRunDate(today, 15.5)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeInitialRunDate', () => {
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
* - executeRecurringSchedule: spawn one invoice from a schedule, optionally
|
||||
* sending it. Used by the daily cron and by a manual "run now" admin
|
||||
* action.
|
||||
* - computeNextRunDate: pure date helper. Given today + day_of_month, return
|
||||
* the next date the schedule should run. Day-of-month values >28 are
|
||||
* clamped to the last day of shorter months; the schedule keeps its
|
||||
* original day_of_month so it jumps back in months that have it.
|
||||
* - computeNextRunDate: pure date helper. Given a reference date,
|
||||
* day_of_month and interval_months, return the next date the schedule
|
||||
* should run. Day-of-month values >28 are clamped to the last day of
|
||||
* shorter months; the schedule keeps its original day_of_month so it
|
||||
* jumps back in months that have it.
|
||||
* - rollNextRunDateForward: pure date helper for stale schedules. Advances
|
||||
* a missed next_run_date in whole intervals so a quarterly or yearly
|
||||
* schedule keeps its month phase across an outage or a pause.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
@@ -76,33 +80,96 @@ function lastDayOfMonth(year: number, monthIndex0: number): number {
|
||||
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}`)
|
||||
}
|
||||
if (!Number.isInteger(intervalMonths) || intervalMonths < 1 || intervalMonths > 12) {
|
||||
throw new Error(`invalid interval_months: ${intervalMonths}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next run date for a schedule given a reference date and the
|
||||
* stored day_of_month. The reference is always interpreted in UTC to avoid
|
||||
* timezone surprises around the day boundary in Vercel cron.
|
||||
* Compute the next run date for a schedule given a reference date, the
|
||||
* stored day_of_month and interval_months. The reference is always
|
||||
* interpreted in UTC to avoid timezone surprises around the day boundary in
|
||||
* Vercel cron.
|
||||
*
|
||||
* Rules:
|
||||
* - If reference is the same as a valid day_of_month occurrence, returns
|
||||
* NEXT month's occurrence (callers compute the FIRST run via
|
||||
* the occurrence one interval later (callers compute the FIRST run via
|
||||
* computeInitialRunDate).
|
||||
* - Day 29-31 in shorter months clamps to that month's last day.
|
||||
* - The schedule's stored day_of_month is unchanged: caller passes it in.
|
||||
* - interval_months (default 1 = monthly) is how many months to advance;
|
||||
* the cron passes the reference on the schedule's own due date, so the
|
||||
* month phase of a quarterly/yearly schedule is preserved.
|
||||
*/
|
||||
export function computeNextRunDate(reference: Date, dayOfMonth: number): string {
|
||||
if (dayOfMonth < 1 || dayOfMonth > 31) {
|
||||
throw new Error(`invalid day_of_month: ${dayOfMonth}`)
|
||||
}
|
||||
export function computeNextRunDate(
|
||||
reference: Date,
|
||||
dayOfMonth: number,
|
||||
intervalMonths = 1,
|
||||
): string {
|
||||
assertValidCadence(dayOfMonth, intervalMonths)
|
||||
const refY = reference.getUTCFullYear()
|
||||
const refM = reference.getUTCMonth()
|
||||
// Advance to the next month.
|
||||
const nextM = refM + 1
|
||||
// Advance one interval.
|
||||
const nextM = refM + intervalMonths
|
||||
const nextYear = refY + Math.floor(nextM / 12)
|
||||
const nextMonth = ((nextM % 12) + 12) % 12
|
||||
const clamped = Math.min(dayOfMonth, lastDayOfMonth(nextYear, nextMonth))
|
||||
const yyyy = nextYear.toString().padStart(4, '0')
|
||||
const mm = (nextMonth + 1).toString().padStart(2, '0')
|
||||
const dd = clamped.toString().padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
return isoFromParts(nextYear, nextMonth, clamped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a missed (or being-edited) next_run_date forward on the schedule's
|
||||
* own month grid: start from the anchor's year-month, apply day_of_month
|
||||
* (clamped per month), and advance in whole interval_months steps until the
|
||||
* result is on-or-after today (allowToday, cron's stale roll-forward) or
|
||||
* strictly after today (edits/reactivation, so nothing can trigger a
|
||||
* same-hour surprise send).
|
||||
*
|
||||
* Anchoring on the stale date rather than on today is what keeps a
|
||||
* quarterly schedule on its Jan/Apr/Jul/Oct phase: a Jan 15 run missed
|
||||
* during an outage rolls to Apr 15, not to Feb 15. For interval 1 every
|
||||
* month is on the grid, so this degenerates to the pre-interval behavior.
|
||||
*/
|
||||
export function rollNextRunDateForward(
|
||||
anchorDate: string,
|
||||
today: Date,
|
||||
dayOfMonth: number,
|
||||
intervalMonths = 1,
|
||||
{ allowToday = false }: { allowToday?: boolean } = {},
|
||||
): string {
|
||||
assertValidCadence(dayOfMonth, intervalMonths)
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(anchorDate)
|
||||
if (!match) {
|
||||
throw new Error(`invalid anchor date: ${anchorDate}`)
|
||||
}
|
||||
let year = Number(match[1])
|
||||
let month0 = Number(match[2]) - 1
|
||||
// The regex only shapes the string; reject calendar-invalid anchors like
|
||||
// 2026-13-05 or 2026-02-31 instead of silently normalizing them.
|
||||
const anchorDay = Number(match[3])
|
||||
if (month0 < 0 || month0 > 11 || anchorDay < 1 || anchorDay > lastDayOfMonth(year, month0)) {
|
||||
throw new Error(`invalid anchor date: ${anchorDate}`)
|
||||
}
|
||||
const todayIso = isoFromParts(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())
|
||||
let candidate = isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0)))
|
||||
while (allowToday ? candidate < todayIso : candidate <= todayIso) {
|
||||
const m = month0 + intervalMonths
|
||||
year += Math.floor(m / 12)
|
||||
month0 = m % 12
|
||||
candidate = isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0)))
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
import {
|
||||
computeInitialRunDate,
|
||||
computeNextRunDate,
|
||||
rollNextRunDateForward,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice'
|
||||
@@ -559,6 +560,7 @@ async function commitCreateRecurringSchedule(
|
||||
customer_id: validated.customer_id,
|
||||
name: validated.name,
|
||||
day_of_month: validated.day_of_month,
|
||||
interval_months: validated.interval_months,
|
||||
send_hour: validated.send_hour,
|
||||
payment_terms_days: validated.payment_terms_days,
|
||||
currency: validated.currency,
|
||||
@@ -610,6 +612,7 @@ async function commitCreateRecurringSchedule(
|
||||
name: validated.name,
|
||||
customer_id: validated.customer_id,
|
||||
day_of_month: validated.day_of_month,
|
||||
interval_months: validated.interval_months,
|
||||
send_hour: validated.send_hour,
|
||||
currency: validated.currency,
|
||||
auto_send: validated.auto_send,
|
||||
@@ -644,7 +647,7 @@ async function commitUpdateRecurringSchedule(
|
||||
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.select('id, status, auto_send, customer_id, day_of_month, next_run_date')
|
||||
.select('id, status, auto_send, customer_id, day_of_month, interval_months, next_run_date')
|
||||
.eq('id', scheduleId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -691,16 +694,29 @@ async function commitUpdateRecurringSchedule(
|
||||
const dayChanged =
|
||||
changes.day_of_month !== undefined && changes.day_of_month !== existing.day_of_month
|
||||
const effectiveDay = changes.day_of_month ?? existing.day_of_month
|
||||
const effectiveInterval = changes.interval_months ?? existing.interval_months ?? 1
|
||||
const { date: todayStockholm } = getStockholmDateHour(new Date())
|
||||
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
|
||||
|
||||
const staleOnReactivate = reactivating && existing.next_run_date <= todayStockholm
|
||||
if (staleOnReactivate || dayChanged) {
|
||||
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
||||
updateRow.next_run_date =
|
||||
rolled === todayStockholm
|
||||
? computeNextRunDate(stockholmToday, effectiveDay)
|
||||
: rolled
|
||||
if (effectiveInterval === 1) {
|
||||
// Monthly keeps its long-standing today-anchored semantics.
|
||||
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
|
||||
updateRow.next_run_date =
|
||||
rolled === todayStockholm
|
||||
? computeNextRunDate(stockholmToday, effectiveDay)
|
||||
: rolled
|
||||
} else {
|
||||
// Interval schedules roll on their own month grid so an edit or
|
||||
// reactivation cannot shift a quarterly schedule off its phase.
|
||||
updateRow.next_run_date = rollNextRunDateForward(
|
||||
existing.next_run_date,
|
||||
stockholmToday,
|
||||
effectiveDay,
|
||||
effectiveInterval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A conscious reactivation invalidates any lingering warning.
|
||||
|
||||
Reference in New Issue
Block a user