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)
})
})