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
+1
View File
@@ -804,6 +804,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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".
@@ -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 })}
</>
)}
</span>
</TableCell>
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
@@ -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 }
+25 -8
View File
@@ -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({
+18 -4
View File
@@ -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({
+1
View File
@@ -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,
@@ -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({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="interval_months">{t('interval_label')}</Label>
<Controller
control={control}
name="interval_months"
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger id="interval_months">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('interval_monthly')}</SelectItem>
<SelectItem value="3">{t('interval_quarterly')}</SelectItem>
<SelectItem value="6">{t('interval_semiannual')}</SelectItem>
<SelectItem value="12">{t('interval_yearly')}</SelectItem>
{/* A non-preset interval (set via API/MCP, e.g. every 2
months) must stay selectable so an edit doesn't
silently coerce it to a preset. */}
{![1, 3, 6, 12].includes(field.value) && (
<SelectItem value={String(field.value)}>
{t('interval_every_n', { n: field.value })}
</SelectItem>
)}
</SelectContent>
</Select>
)}
/>
</div>
<div>
<Label htmlFor="day_of_month">{t('day_label')}</Label>
<Input
@@ -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,
+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)
})
})
+6
View File
@@ -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(),
@@ -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', () => {
+85 -18
View File
@@ -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
}
/**
+22 -6
View File
@@ -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.
+13 -3
View File
@@ -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",
+13 -3
View File
@@ -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",
@@ -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';
+5 -2
View File
@@ -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