diff --git a/DECISIONS.md b/DECISIONS.md index a006b2dc..35d08610 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -804,6 +804,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed. [2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original. [2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice. +[2026-08-06] Recurring invoice cadence (user request) is a generic interval_months SMALLINT 1-12 (default 1) rather than a cadence enum: the UI offers only the four presets (1/3/6/12), while API/MCP accept any 1-12 (e.g. every 2 months), and existing rows stay monthly via the default. Missed-run roll-forward and edits/reactivation of interval>1 schedules anchor on the schedule's own next_run_date month grid (rollNextRunDateForward), so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 -> Apr 15, never Feb 15; monthly (interval 1) keeps its pinned today-anchored recompute semantics unchanged. Changing interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier (a 3->1 change therefore waits out the current gap; visible via Nasta korning). [2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on `!purpose` reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. `denied` and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results. [2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0. [2026-08-06] Sandbox ledger history marked no_doc_required instead of seeding receipt documents: the history represents books kept before the company arrived in Accounted, so its underlag sits in the previous system. Same rationale and same sidecar table the SIE-import opt-in uses. Without it the demo's first screen read "Verifikat utan underlag: 39". diff --git a/app/(dashboard)/invoices/recurring/page.tsx b/app/(dashboard)/invoices/recurring/page.tsx index e1c3e648..d3b7bb39 100644 --- a/app/(dashboard)/invoices/recurring/page.tsx +++ b/app/(dashboard)/invoices/recurring/page.tsx @@ -256,6 +256,20 @@ export default function RecurringInvoicesPage() { {t('send_time', { time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`, })} + {/* Monthly is the norm; only a deviating cadence is + worth a label (chips-mark-exceptions convention). */} + {(s.interval_months ?? 1) > 1 && ( + <> + {' · '} + {s.interval_months === 3 + ? t('interval_quarterly') + : s.interval_months === 6 + ? t('interval_semiannual') + : s.interval_months === 12 + ? t('interval_yearly') + : t('interval_every_n', { n: s.interval_months })} + + )} {formatDate(s.next_run_date)} diff --git a/app/api/invoices/recurring/[id]/__tests__/route.test.ts b/app/api/invoices/recurring/[id]/__tests__/route.test.ts index 660c3c26..db73561b 100644 --- a/app/api/invoices/recurring/[id]/__tests__/route.test.ts +++ b/app/api/invoices/recurring/[id]/__tests__/route.test.ts @@ -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 } diff --git a/app/api/invoices/recurring/[id]/route.ts b/app/api/invoices/recurring/[id]/route.ts index ca3c6659..445df5a4 100644 --- a/app/api/invoices/recurring/[id]/route.ts +++ b/app/api/invoices/recurring/[id]/route.ts @@ -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 diff --git a/app/api/invoices/recurring/cron/__tests__/route.test.ts b/app/api/invoices/recurring/cron/__tests__/route.test.ts index 4f375d88..96e26521 100644 --- a/app/api/invoices/recurring/cron/__tests__/route.test.ts +++ b/app/api/invoices/recurring/cron/__tests__/route.test.ts @@ -65,6 +65,7 @@ function makeSchedule(overrides: Record = {}) { 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(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(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({ diff --git a/app/api/invoices/recurring/cron/route.ts b/app/api/invoices/recurring/cron/route.ts index 6b04bd59..24afd0ca 100644 --- a/app/api/invoices/recurring/cron/route.ts +++ b/app/api/invoices/recurring/cron/route.ts @@ -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({ diff --git a/app/api/invoices/recurring/route.ts b/app/api/invoices/recurring/route.ts index d1e764ea..d421be26 100644 --- a/app/api/invoices/recurring/route.ts +++ b/app/api/invoices/recurring/route.ts @@ -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, diff --git a/components/invoices/NewRecurringScheduleDialog.tsx b/components/invoices/NewRecurringScheduleDialog.tsx index d826e2f1..765f505c 100644 --- a/components/invoices/NewRecurringScheduleDialog.tsx +++ b/components/invoices/NewRecurringScheduleDialog.tsx @@ -112,6 +112,7 @@ function NewRecurringScheduleForm({ customer_id: z.string().uuid(t('validation_customer_required')), name: z.string().min(1, t('validation_name_required')), day_of_month: z.number().int().min(1).max(31), + interval_months: z.number().int().min(1).max(12), send_hour: z.number().int().min(0).max(23), payment_terms_days: z.number().int().min(0).max(90), currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']), @@ -139,6 +140,7 @@ function NewRecurringScheduleForm({ customer_id: schedule.customer_id, name: schedule.name, day_of_month: schedule.day_of_month, + interval_months: schedule.interval_months ?? 1, send_hour: schedule.send_hour ?? 8, payment_terms_days: schedule.payment_terms_days, currency: schedule.currency, @@ -163,6 +165,7 @@ function NewRecurringScheduleForm({ customer_id: '', name: '', day_of_month: 15, + interval_months: 1, send_hour: 8, payment_terms_days: 30, currency: 'SEK', @@ -296,6 +299,37 @@ function NewRecurringScheduleForm({
+
+ + ( + + )} + /> +
{ @@ -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', () => { diff --git a/extensions/general/mcp-server/recommended-tools.ts b/extensions/general/mcp-server/recommended-tools.ts index 83b0bef0..ac8a8761 100644 --- a/extensions/general/mcp-server/recommended-tools.ts +++ b/extensions/general/mcp-server/recommended-tools.ts @@ -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', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 5a6a9440..3fc9dcf1 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 | 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, diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 7cd5a820..6ca4e52d 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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) + }) +}) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 10d5bfc5..d1b7bb3d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -725,6 +725,9 @@ export const CreateRecurringScheduleSchema = z.object({ customer_id: uuid, name: z.string().min(1, 'Schedule name is required').max(200), day_of_month: z.number().int().min(1).max(31), + // Months between runs: 1 = monthly (default), 3 = quarterly, 6 = half- + // yearly, 12 = yearly. Any 1-12 is accepted (e.g. every 2 months). + interval_months: z.number().int().min(1).max(12).default(1), // Whole hour (0-23) in Europe/Stockholm at which the invoice is sent. send_hour: z.number().int().min(0).max(23).default(8), payment_terms_days: z.number().int().min(0).max(90).default(30), @@ -745,6 +748,9 @@ export const UpdateRecurringScheduleSchema = z.object({ customer_id: uuid.optional(), name: z.string().min(1).max(200).optional(), day_of_month: z.number().int().min(1).max(31).optional(), + // Changing the interval alone leaves next_run_date untouched: the new + // cadence applies from the next run onward. + interval_months: z.number().int().min(1).max(12).optional(), send_hour: z.number().int().min(0).max(23).optional(), payment_terms_days: z.number().int().min(0).max(90).optional(), currency: CurrencySchema.optional(), diff --git a/lib/invoices/__tests__/recurring-schedule-service.test.ts b/lib/invoices/__tests__/recurring-schedule-service.test.ts index 76962ae9..d136750b 100644 --- a/lib/invoices/__tests__/recurring-schedule-service.test.ts +++ b/lib/invoices/__tests__/recurring-schedule-service.test.ts @@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { computeNextRunDate, computeInitialRunDate, + rollNextRunDateForward, getStockholmDateHour, executeRecurringSchedule, } from '@/lib/invoices/recurring-schedule-service' @@ -153,6 +154,104 @@ describe('computeNextRunDate', () => { expect(() => computeNextRunDate(new Date(), 0)).toThrow() expect(() => computeNextRunDate(new Date(), 32)).toThrow() }) + + it('advances a quarter with interval 3', () => { + const result = computeNextRunDate(new Date(Date.UTC(2026, 0, 15)), 15, 3) + expect(result).toBe('2026-04-15') + }) + + it('advances a quarter across the year boundary', () => { + const result = computeNextRunDate(new Date(Date.UTC(2026, 10, 15)), 15, 3) + expect(result).toBe('2027-02-15') + }) + + it('clamps day 31 when a quarterly step lands in February', () => { + // Nov 30 + 3 months = Feb; 2027 February has 28 days. + const result = computeNextRunDate(new Date(Date.UTC(2026, 10, 30)), 31, 3) + expect(result).toBe('2027-02-28') + }) + + it('advances a full year with interval 12, keeping leap-day clamp', () => { + // 2028-02-29 (leap) + 12 months, day 29 -> 2029-02-28. + const result = computeNextRunDate(new Date(Date.UTC(2028, 1, 29)), 29, 12) + expect(result).toBe('2029-02-28') + }) + + it('rejects invalid interval_months', () => { + expect(() => computeNextRunDate(new Date(), 15, 0)).toThrow() + expect(() => computeNextRunDate(new Date(), 15, 13)).toThrow() + expect(() => computeNextRunDate(new Date(), 15, 1.5)).toThrow() + }) +}) + +describe('rollNextRunDateForward', () => { + const today = new Date(Date.UTC(2026, 6, 6)) // 2026-07-06 + + it('monthly: rolls a stale date to the next occurrence on or after today', () => { + expect(rollNextRunDateForward('2026-07-05', today, 5, 1, { allowToday: true })) + .toBe('2026-08-05') + expect(rollNextRunDateForward('2026-05-15', today, 15, 1, { allowToday: true })) + .toBe('2026-07-15') + }) + + it('monthly: allowToday keeps an occurrence landing on today', () => { + expect(rollNextRunDateForward('2026-06-06', today, 6, 1, { allowToday: true })) + .toBe('2026-07-06') + }) + + it('monthly: default (strictly future) skips today', () => { + expect(rollNextRunDateForward('2026-06-06', today, 6, 1)).toBe('2026-08-06') + }) + + it('quarterly: preserves the month phase across a missed run', () => { + // Jan 15 quarterly run missed; today is Jul 6 -> Jul 15, NOT Feb/Aug 15. + expect(rollNextRunDateForward('2026-01-15', today, 15, 3, { allowToday: true })) + .toBe('2026-07-15') + // Apr 5 missed -> Jul 5 already past today -> Oct 5. + expect(rollNextRunDateForward('2026-04-05', today, 5, 3, { allowToday: true })) + .toBe('2026-10-05') + }) + + it('yearly: rolls a missed run a whole year forward', () => { + expect(rollNextRunDateForward('2026-03-01', today, 1, 12, { allowToday: true })) + .toBe('2027-03-01') + }) + + it('keeps a future anchor as-is (day edit within the anchor month)', () => { + // Quarterly schedule anchored on Oct; day edited to 20 -> stays in Oct. + expect(rollNextRunDateForward('2026-10-15', today, 20, 3)).toBe('2026-10-20') + }) + + it('re-derives the day from day_of_month when the anchor was clamped', () => { + // Anchor 2026-02-28 stored for a day-31 schedule; monthly roll from a + // stale date recovers day 31 in months that have it. + expect(rollNextRunDateForward('2026-02-28', today, 31, 1, { allowToday: true })) + .toBe('2026-07-31') + }) + + it('clamps per month while stepping (quarterly day 31 through February)', () => { + const winter = new Date(Date.UTC(2027, 1, 10)) // 2027-02-10 + expect(rollNextRunDateForward('2026-11-30', winter, 31, 3, { allowToday: true })) + .toBe('2027-02-28') + }) + + it('rejects malformed anchors and invalid cadence', () => { + expect(() => rollNextRunDateForward('2026-1-5', today, 5, 1)).toThrow() + expect(() => rollNextRunDateForward('2026-01-05', today, 5, 0)).toThrow() + expect(() => rollNextRunDateForward('2026-01-05', today, 0, 1)).toThrow() + }) + + it('rejects calendar-invalid anchors that pass the shape regex', () => { + expect(() => rollNextRunDateForward('2026-13-05', today, 5, 1)).toThrow() + expect(() => rollNextRunDateForward('2026-00-05', today, 5, 1)).toThrow() + expect(() => rollNextRunDateForward('2026-02-31', today, 31, 1)).toThrow() + expect(() => rollNextRunDateForward('2026-04-00', today, 5, 1)).toThrow() + }) + + it('rejects fractional day_of_month', () => { + expect(() => rollNextRunDateForward('2026-01-05', today, 5.5, 1)).toThrow() + expect(() => computeNextRunDate(today, 15.5)).toThrow() + }) }) describe('computeInitialRunDate', () => { diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index a291bd26..15fd103f 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -5,10 +5,14 @@ * - executeRecurringSchedule: spawn one invoice from a schedule, optionally * sending it. Used by the daily cron and by a manual "run now" admin * action. - * - computeNextRunDate: pure date helper. Given today + day_of_month, return - * the next date the schedule should run. Day-of-month values >28 are - * clamped to the last day of shorter months; the schedule keeps its - * original day_of_month so it jumps back in months that have it. + * - computeNextRunDate: pure date helper. Given a reference date, + * day_of_month and interval_months, return the next date the schedule + * should run. Day-of-month values >28 are clamped to the last day of + * shorter months; the schedule keeps its original day_of_month so it + * jumps back in months that have it. + * - rollNextRunDateForward: pure date helper for stale schedules. Advances + * a missed next_run_date in whole intervals so a quarterly or yearly + * schedule keeps its month phase across an outage or a pause. */ import type { SupabaseClient } from '@supabase/supabase-js' @@ -76,33 +80,96 @@ function lastDayOfMonth(year: number, monthIndex0: number): number { return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate() } +function isoFromParts(year: number, monthIndex0: number, day: number): string { + const yyyy = year.toString().padStart(4, '0') + const mm = (monthIndex0 + 1).toString().padStart(2, '0') + const dd = day.toString().padStart(2, '0') + return `${yyyy}-${mm}-${dd}` +} + +function assertValidCadence(dayOfMonth: number, intervalMonths: number): void { + if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) { + throw new Error(`invalid day_of_month: ${dayOfMonth}`) + } + if (!Number.isInteger(intervalMonths) || intervalMonths < 1 || intervalMonths > 12) { + throw new Error(`invalid interval_months: ${intervalMonths}`) + } +} + /** - * Compute the next run date for a schedule given a reference date and the - * stored day_of_month. The reference is always interpreted in UTC to avoid - * timezone surprises around the day boundary in Vercel cron. + * Compute the next run date for a schedule given a reference date, the + * stored day_of_month and interval_months. The reference is always + * interpreted in UTC to avoid timezone surprises around the day boundary in + * Vercel cron. * * Rules: * - If reference is the same as a valid day_of_month occurrence, returns - * NEXT month's occurrence (callers compute the FIRST run via + * the occurrence one interval later (callers compute the FIRST run via * computeInitialRunDate). * - Day 29-31 in shorter months clamps to that month's last day. * - The schedule's stored day_of_month is unchanged: caller passes it in. + * - interval_months (default 1 = monthly) is how many months to advance; + * the cron passes the reference on the schedule's own due date, so the + * month phase of a quarterly/yearly schedule is preserved. */ -export function computeNextRunDate(reference: Date, dayOfMonth: number): string { - if (dayOfMonth < 1 || dayOfMonth > 31) { - throw new Error(`invalid day_of_month: ${dayOfMonth}`) - } +export function computeNextRunDate( + reference: Date, + dayOfMonth: number, + intervalMonths = 1, +): string { + assertValidCadence(dayOfMonth, intervalMonths) const refY = reference.getUTCFullYear() const refM = reference.getUTCMonth() - // Advance to the next month. - const nextM = refM + 1 + // Advance one interval. + const nextM = refM + intervalMonths const nextYear = refY + Math.floor(nextM / 12) const nextMonth = ((nextM % 12) + 12) % 12 const clamped = Math.min(dayOfMonth, lastDayOfMonth(nextYear, nextMonth)) - const yyyy = nextYear.toString().padStart(4, '0') - const mm = (nextMonth + 1).toString().padStart(2, '0') - const dd = clamped.toString().padStart(2, '0') - return `${yyyy}-${mm}-${dd}` + return isoFromParts(nextYear, nextMonth, clamped) +} + +/** + * Roll a missed (or being-edited) next_run_date forward on the schedule's + * own month grid: start from the anchor's year-month, apply day_of_month + * (clamped per month), and advance in whole interval_months steps until the + * result is on-or-after today (allowToday, cron's stale roll-forward) or + * strictly after today (edits/reactivation, so nothing can trigger a + * same-hour surprise send). + * + * Anchoring on the stale date rather than on today is what keeps a + * quarterly schedule on its Jan/Apr/Jul/Oct phase: a Jan 15 run missed + * during an outage rolls to Apr 15, not to Feb 15. For interval 1 every + * month is on the grid, so this degenerates to the pre-interval behavior. + */ +export function rollNextRunDateForward( + anchorDate: string, + today: Date, + dayOfMonth: number, + intervalMonths = 1, + { allowToday = false }: { allowToday?: boolean } = {}, +): string { + assertValidCadence(dayOfMonth, intervalMonths) + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(anchorDate) + if (!match) { + throw new Error(`invalid anchor date: ${anchorDate}`) + } + let year = Number(match[1]) + let month0 = Number(match[2]) - 1 + // The regex only shapes the string; reject calendar-invalid anchors like + // 2026-13-05 or 2026-02-31 instead of silently normalizing them. + const anchorDay = Number(match[3]) + if (month0 < 0 || month0 > 11 || anchorDay < 1 || anchorDay > lastDayOfMonth(year, month0)) { + throw new Error(`invalid anchor date: ${anchorDate}`) + } + const todayIso = isoFromParts(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + let candidate = isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0))) + while (allowToday ? candidate < todayIso : candidate <= todayIso) { + const m = month0 + intervalMonths + year += Math.floor(m / 12) + month0 = m % 12 + candidate = isoFromParts(year, month0, Math.min(dayOfMonth, lastDayOfMonth(year, month0))) + } + return candidate } /** diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index c210aafb..45282db8 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -125,6 +125,7 @@ import { import { computeInitialRunDate, computeNextRunDate, + rollNextRunDateForward, getStockholmDateHour, } from '@/lib/invoices/recurring-schedule-service' import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice' @@ -559,6 +560,7 @@ async function commitCreateRecurringSchedule( customer_id: validated.customer_id, name: validated.name, day_of_month: validated.day_of_month, + interval_months: validated.interval_months, send_hour: validated.send_hour, payment_terms_days: validated.payment_terms_days, currency: validated.currency, @@ -610,6 +612,7 @@ async function commitCreateRecurringSchedule( name: validated.name, customer_id: validated.customer_id, day_of_month: validated.day_of_month, + interval_months: validated.interval_months, send_hour: validated.send_hour, currency: validated.currency, auto_send: validated.auto_send, @@ -644,7 +647,7 @@ async function commitUpdateRecurringSchedule( const { data: existing, error: existingError } = await supabase .from('recurring_invoice_schedules') - .select('id, status, auto_send, customer_id, day_of_month, next_run_date') + .select('id, status, auto_send, customer_id, day_of_month, interval_months, next_run_date') .eq('id', scheduleId) .eq('company_id', companyId) .maybeSingle() @@ -691,16 +694,29 @@ async function commitUpdateRecurringSchedule( const dayChanged = changes.day_of_month !== undefined && changes.day_of_month !== existing.day_of_month const effectiveDay = changes.day_of_month ?? existing.day_of_month + const effectiveInterval = changes.interval_months ?? existing.interval_months ?? 1 const { date: todayStockholm } = getStockholmDateHour(new Date()) const stockholmToday = new Date(`${todayStockholm}T00:00:00Z`) 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 today-anchored semantics. + 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 an edit or + // reactivation cannot shift a quarterly schedule off its phase. + updateRow.next_run_date = rollNextRunDateForward( + existing.next_run_date, + stockholmToday, + effectiveDay, + effectiveInterval, + ) + } } // A conscious reactivation invalidates any lingering warning. diff --git a/messages/en.json b/messages/en.json index bb8d2d9e..30519858 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3536,7 +3536,7 @@ "load_failed_title": "Could not load recurring invoices", "load_failed_description": "Check your connection and try again.", "empty_title": "No recurring invoices", - "empty_description": "Create a schedule to automatically invoice customers on a specific day every month.", + "empty_description": "Create a schedule to automatically invoice customers monthly, quarterly, semi-annually or yearly.", "th_name": "Name", "th_customer": "Customer", "th_day": "Day", @@ -3561,9 +3561,13 @@ "run_now_confirm_title": "Create invoice now?", "run_now_success_title": "Invoice created", "run_now_failed_title": "Could not create invoice", - "resume_autosend_confirm": "Reactivate \"{name}\"? This resumes automatic monthly emails to the customer.", + "resume_autosend_confirm": "Reactivate \"{name}\"? This resumes automatic emails to the customer on the schedule's interval.", "resume_autosend_confirm_title": "Resume automatic sending?", - "send_time": "at {time}" + "send_time": "at {time}", + "interval_quarterly": "Quarterly", + "interval_semiannual": "Semi-annually", + "interval_yearly": "Yearly", + "interval_every_n": "Every {n} months" }, "invoice_recurring_new": { "back": "Back", @@ -3580,6 +3584,12 @@ "customer_placeholder": "Select customer", "day_label": "Day of month", "day_hint": "29-31 run on the last day in shorter months.", + "interval_label": "Interval", + "interval_monthly": "Every month", + "interval_quarterly": "Every quarter", + "interval_semiannual": "Every six months", + "interval_yearly": "Every year", + "interval_every_n": "Every {n} months", "payment_terms_label": "Payment terms (days)", "currency_label": "Currency", "auto_send_label": "Create and send automatically", diff --git a/messages/sv.json b/messages/sv.json index 642a6167..e8ddfc8a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3536,7 +3536,7 @@ "load_failed_title": "Kunde inte ladda återkommande fakturor", "load_failed_description": "Kontrollera din anslutning och försök igen.", "empty_title": "Inga återkommande fakturor", - "empty_description": "Skapa ett schema för att automatiskt fakturera kunder på en bestämd dag varje månad.", + "empty_description": "Skapa ett schema för att automatiskt fakturera kunder månadsvis, kvartalsvis, halvårsvis eller årsvis.", "th_name": "Namn", "th_customer": "Kund", "th_day": "Dag", @@ -3561,9 +3561,13 @@ "run_now_confirm_title": "Skapa faktura nu?", "run_now_success_title": "Faktura skapad", "run_now_failed_title": "Kunde inte skapa faktura", - "resume_autosend_confirm": "Aktivera \"{name}\" igen? Detta återupptar automatiska månatliga utskick till kunden.", + "resume_autosend_confirm": "Aktivera \"{name}\" igen? Detta återupptar automatiska utskick till kunden enligt schemats intervall.", "resume_autosend_confirm_title": "Återuppta automatiska utskick?", - "send_time": "kl {time}" + "send_time": "kl {time}", + "interval_quarterly": "Kvartalsvis", + "interval_semiannual": "Halvårsvis", + "interval_yearly": "Årsvis", + "interval_every_n": "Var {n}:e månad" }, "invoice_recurring_new": { "back": "Tillbaka", @@ -3580,6 +3584,12 @@ "customer_placeholder": "Välj kund", "day_label": "Dag i månaden", "day_hint": "29-31 körs sista dagen i kortare månader.", + "interval_label": "Intervall", + "interval_monthly": "Varje månad", + "interval_quarterly": "Varje kvartal", + "interval_semiannual": "Varje halvår", + "interval_yearly": "Varje år", + "interval_every_n": "Var {n}:e månad", "payment_terms_label": "Betalningsvillkor (dagar)", "currency_label": "Valuta", "auto_send_label": "Skapa och skicka automatiskt", diff --git a/supabase/migrations/20260806090000_recurring_schedule_interval_months.sql b/supabase/migrations/20260806090000_recurring_schedule_interval_months.sql new file mode 100644 index 00000000..b3662547 --- /dev/null +++ b/supabase/migrations/20260806090000_recurring_schedule_interval_months.sql @@ -0,0 +1,18 @@ +-- Migration: interval_months on recurring_invoice_schedules +-- +-- User request: recurring invoice schedules on quarterly, half-yearly, or +-- yearly cadence, "simplest via some form of month interval". The schedule +-- keeps day_of_month as the day anchor and next_run_date as the month anchor; +-- interval_months is how many months the cron advances next_run_date after a +-- successful run (and per step when rolling a missed schedule forward, so a +-- quarterly schedule keeps its Jan/Apr/Jul/Oct phase). +-- +-- 1 = monthly (existing behavior, default so all existing rows are +-- unchanged), 3 = quarterly, 6 = half-yearly, 12 = yearly. The UI offers +-- those four presets; the API accepts any 1-12 (e.g. every 2 months). + +ALTER TABLE public.recurring_invoice_schedules + ADD COLUMN interval_months SMALLINT NOT NULL DEFAULT 1 + CHECK (interval_months BETWEEN 1 AND 12); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index b224b88e..4ae31717 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1247,9 +1247,12 @@ export interface RecurringInvoiceSchedule { name: string - // Monthly cadence, day-of-month 1-31. Clamped to last day of month in - // shorter months (handled by computeNextRunDate). + // Day-of-month anchor, 1-31. Clamped to last day of month in shorter + // months (handled by computeNextRunDate). day_of_month: number + // Months between runs: 1 = monthly, 3 = quarterly, 6 = half-yearly, + // 12 = yearly. next_run_date is the month anchor the interval advances from. + interval_months: number // Whole hour (0-23) in Europe/Stockholm time at which the schedule sends. // The hourly cron only fires schedules matching the current Stockholm hour. send_hour: number