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
@@ -7,7 +7,7 @@
* supabase call here would couple tests to internal query order.
*/
import { describe, it, expect } from 'vitest'
import { tools, computeMomsDeadline } from '../server'
import { tools, computeMomsDeadline, UNCATEGORIZED_TRANSACTIONS_HINT } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
describe('gnubok_vat_close_check', () => {
@@ -42,6 +42,16 @@ describe('gnubok_vat_close_check', () => {
it('is mapped to reports:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_vat_close_check).toBe('reports:read')
})
it('uncategorized-transactions hint offers both resolution paths', () => {
// The blocker must not steer agents into double-booking: a transaction
// whose affärshändelse is already booked needs the link tool, not a new
// booking via categorize/auto-match. An agent that only sees the booking
// tools concludes linking requires support intervention.
expect(UNCATEGORIZED_TRANSACTIONS_HINT).toContain('gnubok_categorize_transaction')
expect(UNCATEGORIZED_TRANSACTIONS_HINT).toContain('gnubok_auto_match_period')
expect(UNCATEGORIZED_TRANSACTIONS_HINT).toContain('gnubok_link_transaction_to_journal_entry')
})
})
describe('computeMomsDeadline', () => {
@@ -45,6 +45,10 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
'gnubok_suggest_categories',
'gnubok_categorize_transaction',
'gnubok_match_transaction_to_invoice',
// For transactions whose affärshändelse is already booked on an existing
// verifikat: links without creating new bookkeeping. Categorizing such a
// transaction would double-book it.
'gnubok_link_transaction_to_journal_entry',
// Tagging: check the registry before writing dimensions bags on
// categorize calls (resolve-don't-select needs real codes/names).
'gnubok_list_dimensions',
+36 -5
View File
@@ -1702,6 +1702,19 @@ interface VatCloseBlocker {
check_code?: VatDeclarationCheck['code']
}
/**
* Hint for the uncategorized_transactions blocker. Must name BOTH resolution
* paths: categorize/auto-match creates NEW bookkeeping, so for a transaction
* whose affärshändelse is already booked on an existing verifikat the agent
* needs gnubok_link_transaction_to_journal_entry instead; a hint that only
* offers the booking tools dead-ends that case into "contact support"
* (2026-08-06 support case). Exported so the test can pin the contract.
*/
export const UNCATEGORIZED_TRANSACTIONS_HINT =
'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period. ' +
'Är affärshändelsen redan bokförd på ett befintligt verifikat: koppla i stället med ' +
'gnubok_link_transaction_to_journal_entry (ingen ny bokföring skapas).'
/**
* Completeness codes that describe the omvänd-skattskyldighet pair. They keep
* the pre-existing `reverse_charge_input_missing` blocker kind so clients
@@ -2071,7 +2084,7 @@ export async function computeVatCloseCheck(
severity: 'high',
count: uncategorizedCount,
message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`,
hint: 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period.',
hint: UNCATEGORIZED_TRANSACTIONS_HINT,
})
}
const unapprovedCount = unapprovedRes.count ?? 0
@@ -15337,7 +15350,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_recurring_schedules',
title: 'List Recurring Invoice Schedules',
description: "List the company's recurring invoice schedules: monthly templates that auto-create customer invoices on day_of_month (clamps to the last day in shorter months) at send_hour (a whole hour in Europe/Stockholm time). Shows status, auto_send and next_run_date.",
description: "List the company's recurring invoice schedules: auto-create customer invoices on day_of_month (clamps to the last day in shorter months) every interval_months months (any 1-12; presets 1/3/6/12) at send_hour, Europe/Stockholm. Shows status, auto_send and next_run_date.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -15360,6 +15373,7 @@ export const tools: McpTool[] = [
customer_id: { type: 'string' },
customer_name: { type: ['string', 'null'] },
day_of_month: { type: 'number', description: '1-31; clamps to the last day in shorter months' },
interval_months: { type: 'number', description: 'Months between runs: any integer 1-12; 1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly' },
send_hour: { type: 'number', description: 'Whole hour 0-23 in Europe/Stockholm time' },
payment_terms_days: { type: 'number' },
currency: { type: 'string' },
@@ -15405,7 +15419,7 @@ export const tools: McpTool[] = [
let query = supabase
.from('recurring_invoice_schedules')
.select(
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, auto_send, default_dimensions, next_run_date, last_run_at, last_invoice_id, last_run_warning, generated_count, customer:customers(name), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
'id, name, status, customer_id, day_of_month, interval_months, send_hour, payment_terms_days, currency, auto_send, default_dimensions, next_run_date, last_run_at, last_invoice_id, last_run_warning, generated_count, customer:customers(name), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
{ count: 'exact' },
)
.eq('company_id', companyId)
@@ -15443,6 +15457,7 @@ export const tools: McpTool[] = [
customer_id: row.customer_id,
customer_name: (row.customer as Record<string, unknown> | null)?.name ?? null,
day_of_month: row.day_of_month,
interval_months: row.interval_months,
send_hour: row.send_hour,
payment_terms_days: row.payment_terms_days,
currency: row.currency,
@@ -15476,7 +15491,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_recurring_schedule',
title: 'Create Recurring Invoice Schedule',
description: 'Stage a new recurring invoice schedule: a monthly template that creates a customer invoice on day_of_month (clamps to the last day in shorter months) at send_hour (a whole hour in Europe/Stockholm time). auto_send defaults false; true emails each invoice without new approval.',
description: 'Stage a new recurring invoice schedule: creates a customer invoice on day_of_month (clamps to the last day in shorter months) every interval_months months (default 1) at send_hour, Europe/Stockholm. auto_send defaults false; true emails each invoice without new approval.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -15490,6 +15505,12 @@ export const tools: McpTool[] = [
maximum: 31,
description: 'Day of month the invoice is created. 29-31 clamp to the last day in shorter months; the stored day is kept for longer months.',
},
interval_months: {
type: 'integer',
minimum: 1,
maximum: 12,
description: 'Months between invoices: any integer 1-12. Default 1 (monthly); 3 = quarterly, 6 = half-yearly, 12 = yearly.',
},
send_hour: {
type: 'integer',
minimum: 0,
@@ -15575,6 +15596,7 @@ export const tools: McpTool[] = [
'customer_id',
'name',
'day_of_month',
'interval_months',
'send_hour',
'payment_terms_days',
'currency',
@@ -15626,6 +15648,7 @@ export const tools: McpTool[] = [
customer_id: customer.id,
customer_name: customer.name,
day_of_month: params.day_of_month,
interval_months: params.interval_months,
send_hour: params.send_hour,
payment_terms_days: params.payment_terms_days,
currency: params.currency,
@@ -15676,6 +15699,12 @@ export const tools: McpTool[] = [
maximum: 31,
description: '1-31; clamps to the last day in shorter months. Changing it rolls next_run_date to the next future occurrence.',
},
interval_months: {
type: 'integer',
minimum: 1,
maximum: 12,
description: 'Months between invoices: any integer 1-12; 1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly. Changing only interval_months leaves next_run_date untouched.',
},
send_hour: { type: 'integer', minimum: 0, maximum: 23, description: 'Whole hour (0-23) in Europe/Stockholm time.' },
payment_terms_days: { type: 'integer', minimum: 0, maximum: 90 },
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
@@ -15757,6 +15786,7 @@ export const tools: McpTool[] = [
'customer_id',
'name',
'day_of_month',
'interval_months',
'send_hour',
'payment_terms_days',
'currency',
@@ -15790,7 +15820,7 @@ export const tools: McpTool[] = [
const { data: current, error } = await supabase
.from('recurring_invoice_schedules')
.select(
'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, your_reference, our_reference, notes, auto_send, default_dimensions, next_run_date, customer:customers(name, email), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
'id, name, status, customer_id, day_of_month, interval_months, send_hour, payment_terms_days, currency, your_reference, our_reference, notes, auto_send, default_dimensions, next_run_date, customer:customers(name, email), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)',
)
.eq('id', parsed.data.schedule_id)
.eq('company_id', companyId)
@@ -15842,6 +15872,7 @@ export const tools: McpTool[] = [
customer_id: current.customer_id,
customer_name: (current.customer as { name?: string } | null)?.name ?? null,
day_of_month: current.day_of_month,
interval_months: current.interval_months,
send_hour: current.send_hour,
payment_terms_days: current.payment_terms_days,
currency: current.currency,