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:
Mattsson
2026-08-06 15:43:54 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d41ef2a909
commit a0ca692fed
20 changed files with 515 additions and 50 deletions
+29
View File
@@ -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)
})
})
+6
View File
@@ -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', () => {
+85 -18
View File
@@ -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
}
/**
+22 -6
View File
@@ -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.