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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user