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
@@ -110,6 +110,43 @@ describe('PATCH /api/invoices/recurring/[id] reactivation', () => {
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, name: 'Renamed' })
})
it('keeps a quarterly schedule on its month grid when day_of_month is edited', async () => {
// Quarterly schedule anchored on October; editing the day must stay in
// October (a today-anchored recompute would bill a quarter early).
scheduleRow = { next_run_date: '2026-10-15', day_of_month: 15, interval_months: 3 }
await PATCH(patchReq({ day_of_month: 20 }), params)
expect(updatePayloads[0]).toMatchObject({ day_of_month: 20, next_run_date: '2026-10-20' })
})
it('rolls a stale quarterly schedule forward by whole quarters on reactivation', async () => {
// Jan/Apr/Jul/Oct schedule missed Apr 5; today is 2026-07-06, so Jul 5
// has also passed. Next strictly-future grid slot is Oct 5.
scheduleRow = { next_run_date: '2026-04-05', day_of_month: 5, interval_months: 3 }
await PATCH(patchReq({ status: 'active' }), params)
expect(updatePayloads[0]).toMatchObject({
status: 'active',
next_run_date: '2026-10-05',
last_run_warning: null,
})
})
it('reactivating a quarterly schedule on its own day rolls strictly past today', async () => {
scheduleRow = { next_run_date: '2026-01-06', day_of_month: 6, interval_months: 3 }
await PATCH(patchReq({ status: 'active' }), params)
// Grid: Jan 6 -> Apr 6 -> Jul 6 (today, excluded) -> Oct 6.
expect(updatePayloads[0].next_run_date).toBe('2026-10-06')
})
it('changing interval_months alone leaves next_run_date untouched', async () => {
scheduleRow = { next_run_date: '2026-07-20', day_of_month: 20, interval_months: 1 }
await PATCH(patchReq({ interval_months: 3 }), params)
expect(updatePayloads[0]).toEqual({ interval_months: 3 })
})
it('does not touch next_run_date or warning when pausing', async () => {
scheduleRow = { next_run_date: '2026-07-05', day_of_month: 5 }
+25 -8
View File
@@ -7,6 +7,7 @@ import { applyRecurringScheduleUpdate } from '@/lib/invoices/apply-recurring-sch
import {
computeInitialRunDate,
computeNextRunDate,
rollNextRunDateForward,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
@@ -120,7 +121,7 @@ export const PATCH = withRouteContext(
if (input.status === 'active' || input.day_of_month !== undefined) {
const { data: existing } = await supabase
.from('recurring_invoice_schedules')
.select('next_run_date, day_of_month')
.select('next_run_date, day_of_month, interval_months')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -136,6 +137,7 @@ export const PATCH = withRouteContext(
const dayChanged =
input.day_of_month !== undefined && input.day_of_month !== existing.day_of_month
const effectiveDay = input.day_of_month ?? existing.day_of_month
const effectiveInterval = input.interval_months ?? existing.interval_months ?? 1
const { date: todayStockholm } = getStockholmDateHour(new Date())
const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`)
@@ -145,15 +147,30 @@ export const PATCH = withRouteContext(
// - reactivating a schedule whose date already passed (e.g. the safety
// pause when the send-hour cron shipped), or
// - the day-of-month changed, so "Nästa körning" follows the new day.
// Editing other fields (name, items, time) leaves next_run_date alone,
// so an unrelated edit never skips an imminent send.
// Editing other fields (name, items, time, interval) leaves
// 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) {
const rolled = computeInitialRunDate(stockholmToday, effectiveDay)
updateRow.next_run_date =
rolled === todayStockholm
? computeNextRunDate(stockholmToday, effectiveDay)
: rolled
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.
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 a day edit or
// reactivation cannot shift a quarterly schedule off its
// Jan/Apr/Jul/Oct phase (or bill a quarter early).
updateRow.next_run_date = rollNextRunDateForward(
existing.next_run_date,
stockholmToday,
effectiveDay,
effectiveInterval,
)
}
}
// A conscious reactivation invalidates any lingering warning (the
@@ -65,6 +65,7 @@ function makeSchedule(overrides: Record<string, unknown> = {}) {
id: 's-1',
company_id: 'c-1',
day_of_month: 6,
interval_months: 1,
send_hour: 8,
next_run_date: '2026-07-06',
last_run_at: null,
@@ -203,6 +204,49 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(roll!.payload.last_run_warning).toContain('2026-08-05')
})
it('advances a quarterly schedule one quarter after a successful run', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ interval_months: 3 })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: true,
warning: null,
})
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(body.succeeded).toBe(1)
const bump = updatePayloads.find(
(u) => u.table === 'recurring_invoice_schedules' && 'next_run_date' in u.payload,
)
expect(bump).toBeDefined()
expect(bump!.payload.next_run_date).toBe('2026-10-06')
})
it('rolls a stale quarterly schedule forward on its own quarter grid', async () => {
// Quarterly Jan/Apr/Jul/Oct schedule missed Apr 5 (long outage or pause);
// today is Jul 6. The next slot on the grid is Oct 5 (Jul 5 already
// passed), NOT Aug 5 as a today-anchored monthly roll would give.
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
data: [makeSchedule({ next_run_date: '2026-04-05', day_of_month: 5, interval_months: 3 })],
error: null,
})
// Roll-forward update.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(executeRecurringSchedule).not.toHaveBeenCalled()
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
expect(roll).toBeDefined()
expect(roll!.payload.next_run_date).toBe('2026-10-05')
})
it('skips a schedule that already ran earlier today', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
+18 -4
View File
@@ -5,7 +5,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import {
executeRecurringSchedule,
computeNextRunDate,
computeInitialRunDate,
rollNextRunDateForward,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
@@ -35,7 +35,8 @@ type DueSchedule = RecurringInvoiceSchedule & { items: RecurringInvoiceScheduleI
* paused on deploy so nothing resumes sending behind the user's back.
*
* Each schedule runs in isolated try/catch so a failure on one doesn't block
* the rest. On a successful send: bump next_run_date to next month, set
* the rest. On a successful send: bump next_run_date one interval_months
* step forward (1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly), set
* last_run_at/last_invoice_id/generated_count. On failure: leave next_run_date
* alone so a later run retries.
*/
@@ -90,7 +91,16 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
// reactivation path: turning a long-paused schedule back on rolls it to
// its next date rather than firing a stale one immediately.
if (schedule.next_run_date < todayStockholm) {
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
// Roll on the schedule's own month grid (anchored on the missed date,
// not on today) so a quarterly/yearly schedule keeps its phase: a
// Jan 15 quarterly run missed in an outage rolls to Apr 15, not Feb 15.
const rolledNext = rollNextRunDateForward(
schedule.next_run_date,
stockholmToday,
schedule.day_of_month,
schedule.interval_months,
{ allowToday: true },
)
// Surface the skip on the schedule: a day of failed runs (or a cron
// outage) would otherwise roll the month forward with no user-visible
// trace. The next successful run overwrites this, and a conscious
@@ -209,7 +219,11 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
throw err
}
const nextRunDate = computeNextRunDate(stockholmToday, schedule.day_of_month)
const nextRunDate = computeNextRunDate(
stockholmToday,
schedule.day_of_month,
schedule.interval_months,
)
const { error: updateError } = await supabase
.from('recurring_invoice_schedules')
.update({
+1
View File
@@ -103,6 +103,7 @@ export const POST = withRouteContext(
customer_id: input.customer_id,
name: input.name,
day_of_month: input.day_of_month,
interval_months: input.interval_months,
send_hour: input.send_hour,
payment_terms_days: input.payment_terms_days,
currency: input.currency,