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