feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d41ef2a909
commit
a0ca692fed
@@ -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 }
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user