jaz-cli 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/assets/skills/api/SKILL.md +1 -1
  2. package/assets/skills/conversion/SKILL.md +1 -1
  3. package/assets/skills/jobs/SKILL.md +104 -0
  4. package/assets/skills/jobs/references/audit-prep.md +319 -0
  5. package/assets/skills/jobs/references/bank-recon.md +234 -0
  6. package/assets/skills/jobs/references/building-blocks.md +135 -0
  7. package/assets/skills/jobs/references/credit-control.md +273 -0
  8. package/assets/skills/jobs/references/fa-review.md +267 -0
  9. package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
  10. package/assets/skills/jobs/references/month-end-close.md +308 -0
  11. package/assets/skills/jobs/references/payment-run.md +246 -0
  12. package/assets/skills/jobs/references/quarter-end-close.md +268 -0
  13. package/assets/skills/jobs/references/supplier-recon.md +330 -0
  14. package/assets/skills/jobs/references/year-end-close.md +341 -0
  15. package/assets/skills/transaction-recipes/SKILL.md +1 -1
  16. package/dist/__tests__/jobs-audit-prep.test.js +125 -0
  17. package/dist/__tests__/jobs-bank-recon.test.js +108 -0
  18. package/dist/__tests__/jobs-credit-control.test.js +98 -0
  19. package/dist/__tests__/jobs-fa-review.test.js +104 -0
  20. package/dist/__tests__/jobs-gst-vat.test.js +113 -0
  21. package/dist/__tests__/jobs-month-end.test.js +162 -0
  22. package/dist/__tests__/jobs-payment-run.test.js +106 -0
  23. package/dist/__tests__/jobs-quarter-end.test.js +155 -0
  24. package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
  25. package/dist/__tests__/jobs-validate.test.js +181 -0
  26. package/dist/__tests__/jobs-year-end.test.js +149 -0
  27. package/dist/commands/jobs.js +184 -0
  28. package/dist/index.js +2 -0
  29. package/dist/jobs/audit-prep.js +211 -0
  30. package/dist/jobs/bank-recon.js +163 -0
  31. package/dist/jobs/credit-control.js +126 -0
  32. package/dist/jobs/fa-review.js +121 -0
  33. package/dist/jobs/format.js +102 -0
  34. package/dist/jobs/gst-vat.js +187 -0
  35. package/dist/jobs/month-end.js +232 -0
  36. package/dist/jobs/payment-run.js +199 -0
  37. package/dist/jobs/quarter-end.js +135 -0
  38. package/dist/jobs/supplier-recon.js +132 -0
  39. package/dist/jobs/types.js +36 -0
  40. package/dist/jobs/validate.js +115 -0
  41. package/dist/jobs/year-end.js +153 -0
  42. package/dist/types/index.js +2 -1
  43. package/package.json +1 -1
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Payment Run blueprint generator.
3
+ * Produces a structured JobBlueprint for processing supplier payments —
4
+ * no actual API calls, just an actionable checklist for accountants.
5
+ */
6
+ import { buildSummary } from './types.js';
7
+ import { validateDateString } from './validate.js';
8
+ export function generatePaymentRunBlueprint(opts = {}) {
9
+ validateDateString(opts.dueBefore, 'dueBefore');
10
+ const currency = opts.currency ?? 'SGD';
11
+ const period = opts.dueBefore ? `Due before ${opts.dueBefore}` : 'current';
12
+ const dueDateFilter = opts.dueBefore
13
+ ? { dueDateTo: opts.dueBefore }
14
+ : {};
15
+ // Phase 1: Identify Outstanding Bills
16
+ const identifyOutstanding = {
17
+ name: 'Identify Outstanding Bills',
18
+ description: 'List all unpaid bills and filter by due date if specified.',
19
+ steps: [
20
+ {
21
+ order: 1,
22
+ description: 'List all unpaid bills',
23
+ category: 'verify',
24
+ apiCall: 'POST /bills/search',
25
+ apiBody: {
26
+ filters: {
27
+ status: ['APPROVED'],
28
+ ...dueDateFilter,
29
+ },
30
+ },
31
+ verification: 'Total outstanding amount noted',
32
+ },
33
+ {
34
+ order: 2,
35
+ description: 'Filter bills by due date priority',
36
+ category: 'verify',
37
+ apiCall: 'POST /bills/search',
38
+ apiBody: {
39
+ filters: {
40
+ status: ['APPROVED'],
41
+ ...dueDateFilter,
42
+ },
43
+ sort: { field: 'dueDate', direction: 'ASC' },
44
+ },
45
+ notes: opts.dueBefore
46
+ ? `Only bills due before ${opts.dueBefore}`
47
+ : 'Sorted by due date — prioritize overdue and upcoming',
48
+ verification: 'Bills sorted by payment urgency',
49
+ },
50
+ ],
51
+ };
52
+ // Phase 2: Summarize by Supplier
53
+ const summarize = {
54
+ name: 'Summarize by Supplier',
55
+ description: 'Group outstanding bills by supplier and generate AP aging.',
56
+ steps: [
57
+ {
58
+ order: 3,
59
+ description: 'Group outstanding amounts by supplier',
60
+ category: 'report',
61
+ apiCall: 'POST /bills/search',
62
+ notes: 'Aggregate total owed per supplier for batch grouping',
63
+ verification: 'Supplier summary matches total outstanding from step 1',
64
+ },
65
+ {
66
+ order: 4,
67
+ description: 'Generate AP aging report',
68
+ category: 'report',
69
+ apiCall: 'POST /generate-reports/ap-aging',
70
+ notes: 'Current, 1-30, 31-60, 61-90, 90+ day buckets',
71
+ verification: 'AP aging total ties to unpaid bills total',
72
+ },
73
+ ],
74
+ };
75
+ // Phase 3: Select & Approve Payment Batch
76
+ const selectApprove = {
77
+ name: 'Select & Approve Payment Batch',
78
+ description: 'Build the payment batch and verify cash availability.',
79
+ steps: [
80
+ {
81
+ order: 5,
82
+ description: 'Build payment batch and check cash availability',
83
+ category: 'review',
84
+ apiCall: 'POST /generate-reports/trial-balance',
85
+ notes: 'Compare total payment batch amount against bank account balance on TB. Ensure sufficient funds before proceeding.',
86
+ verification: 'Bank balance >= total payment batch amount',
87
+ },
88
+ ],
89
+ };
90
+ // Phase 4: Record Payments
91
+ const recordPayments = {
92
+ name: 'Record Payments',
93
+ description: 'Record full and partial payments against approved bills.',
94
+ steps: [
95
+ {
96
+ order: 6,
97
+ description: 'Record full payments for each approved bill',
98
+ category: 'resolve',
99
+ apiCall: 'POST /bills/{id}/payments',
100
+ apiBody: {
101
+ payments: [
102
+ {
103
+ paymentAmount: '{{paymentAmount}}',
104
+ transactionAmount: '{{transactionAmount}}',
105
+ accountResourceId: '{{bankAccountResourceId}}',
106
+ paymentMethod: '{{paymentMethod}}',
107
+ reference: '{{reference}}',
108
+ valueDate: '{{today}}',
109
+ },
110
+ ],
111
+ },
112
+ recipeRef: 'bill-payment',
113
+ notes: 'Payments endpoint requires { payments: [...] } wrapping. Fields: paymentAmount, transactionAmount, accountResourceId, valueDate.',
114
+ verification: 'Each bill status changes to PAID after payment',
115
+ },
116
+ {
117
+ order: 7,
118
+ description: 'Record partial payments where applicable',
119
+ category: 'resolve',
120
+ apiCall: 'POST /bills/{id}/payments',
121
+ apiBody: {
122
+ payments: [
123
+ {
124
+ paymentAmount: '{{partialAmount}}',
125
+ transactionAmount: '{{partialAmount}}',
126
+ accountResourceId: '{{bankAccountResourceId}}',
127
+ paymentMethod: '{{paymentMethod}}',
128
+ reference: '{{reference}}',
129
+ valueDate: '{{today}}',
130
+ },
131
+ ],
132
+ },
133
+ notes: 'For bills being partially paid — record the agreed amount. Bill remains APPROVED with reduced balance.',
134
+ verification: 'Partial payment recorded, outstanding balance updated',
135
+ },
136
+ ],
137
+ };
138
+ // Phase 5: FX Payments
139
+ const fxPayments = {
140
+ name: 'FX Payments',
141
+ description: 'Handle multi-currency bill payments with exchange rate considerations.',
142
+ steps: [
143
+ {
144
+ order: 8,
145
+ description: 'Process foreign currency bill payments',
146
+ category: 'resolve',
147
+ apiCall: 'POST /bills/{id}/payments',
148
+ apiBody: {
149
+ payments: [
150
+ {
151
+ paymentAmount: '{{fxPaymentAmount}}',
152
+ transactionAmount: '{{fxTransactionAmount}}',
153
+ accountResourceId: '{{fxBankAccountResourceId}}',
154
+ paymentMethod: '{{paymentMethod}}',
155
+ reference: '{{reference}}',
156
+ valueDate: '{{today}}',
157
+ currency: { sourceCurrency: '{{foreignCurrency}}' },
158
+ },
159
+ ],
160
+ },
161
+ conditional: 'If multi-currency bills exist in the payment batch',
162
+ recipeRef: 'fx-bill-payment',
163
+ calcCommand: 'jaz calc fx-reval',
164
+ notes: 'Use currency: { sourceCurrency } object form for FX — string currencyCode is silently ignored. Platform auto-fetches ECB rates.',
165
+ verification: 'FX payments recorded with correct exchange rates, FX gain/loss recognized',
166
+ },
167
+ ],
168
+ };
169
+ // Phase 6: Verification
170
+ const verification = {
171
+ name: 'Verification',
172
+ description: 'Confirm all payments processed and balances updated.',
173
+ steps: [
174
+ {
175
+ order: 9,
176
+ description: 'Verify AP aging after payment run',
177
+ category: 'verify',
178
+ apiCall: 'POST /generate-reports/ap-aging',
179
+ verification: 'AP aging reduced by total payments made. No unexpected outstanding items.',
180
+ },
181
+ {
182
+ order: 10,
183
+ description: 'Verify bank balance after payment run',
184
+ category: 'verify',
185
+ apiCall: 'POST /generate-reports/trial-balance',
186
+ verification: 'Bank account balance reduced by total payment amount. TB still balances.',
187
+ },
188
+ ],
189
+ };
190
+ const phases = [identifyOutstanding, summarize, selectApprove, recordPayments, fxPayments, verification];
191
+ return {
192
+ jobType: 'payment-run',
193
+ period,
194
+ currency,
195
+ mode: 'standalone',
196
+ phases,
197
+ summary: buildSummary(phases),
198
+ };
199
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Quarter-end close blueprint generator.
3
+ * Builds on month-end phases with additional quarterly review steps.
4
+ * Supports standalone (full) and incremental (quarterly extras only) modes.
5
+ */
6
+ import { parseQuarterPeriod } from './validate.js';
7
+ import { buildMonthEndPhases } from './month-end.js';
8
+ import { buildSummary } from './types.js';
9
+ /**
10
+ * Build the quarterly extras phase (Phase 6).
11
+ * These steps only apply at quarter boundaries, beyond normal month-end work.
12
+ */
13
+ function buildQuarterlyExtrasPhase(startDate, endDate, startOrder) {
14
+ let order = startOrder;
15
+ const step = (partial) => ({
16
+ order: ++order,
17
+ ...partial,
18
+ });
19
+ return {
20
+ name: 'Phase 6: Quarterly Extras',
21
+ description: 'Additional reviews and filings required at quarter boundaries.',
22
+ steps: [
23
+ step({
24
+ description: 'GST/VAT filing preparation',
25
+ category: 'report',
26
+ apiCall: 'POST /generate-reports/vat-ledger',
27
+ apiBody: { startDate, endDate },
28
+ conditional: 'If GST-registered',
29
+ verification: 'Output tax minus input tax matches F5/F7 boxes',
30
+ }),
31
+ step({
32
+ description: 'ECL / bad debt provision formal review',
33
+ category: 'value',
34
+ recipeRef: 'bad-debt-provision',
35
+ calcCommand: 'jaz calc ecl',
36
+ verification: 'Provision movement disclosed in notes',
37
+ }),
38
+ step({
39
+ description: 'Bonus accrual true-up',
40
+ category: 'accrue',
41
+ recipeRef: 'employee-accruals',
42
+ conditional: 'If bonus schemes exist',
43
+ notes: 'Adjust accrual to reflect year-to-date actual performance vs. budget',
44
+ }),
45
+ step({
46
+ description: 'Intercompany reconciliation',
47
+ category: 'verify',
48
+ recipeRef: 'intercompany',
49
+ conditional: 'If multi-entity',
50
+ verification: 'Intercompany balances net to zero across all entities',
51
+ }),
52
+ step({
53
+ description: 'Provision unwinding (IAS 37 / FRS 37)',
54
+ category: 'value',
55
+ recipeRef: 'provisions',
56
+ calcCommand: 'jaz calc provision',
57
+ conditional: 'If IAS 37 provisions exist',
58
+ notes: 'Unwind discount on non-current provisions',
59
+ }),
60
+ ],
61
+ };
62
+ }
63
+ /**
64
+ * Build the quarterly verification phase (Phase 7).
65
+ * Full-quarter financial reports for consolidated review.
66
+ */
67
+ function buildQuarterlyVerificationPhase(startDate, endDate, label, startOrder) {
68
+ let order = startOrder;
69
+ const step = (partial) => ({
70
+ order: ++order,
71
+ ...partial,
72
+ });
73
+ return {
74
+ name: 'Phase 7: Quarterly Verification',
75
+ description: `Full-quarter financial report review for ${label}.`,
76
+ steps: [
77
+ step({
78
+ description: `Review trial balance for ${label}`,
79
+ category: 'report',
80
+ apiCall: 'POST /generate-reports/trial-balance',
81
+ apiBody: { startDate, endDate },
82
+ verification: 'Total debits equal total credits',
83
+ }),
84
+ step({
85
+ description: `Generate P&L for ${label}`,
86
+ category: 'report',
87
+ apiCall: 'POST /generate-reports/profit-and-loss',
88
+ apiBody: { primarySnapshotDate: endDate, secondarySnapshotDate: startDate },
89
+ }),
90
+ step({
91
+ description: `Generate balance sheet as at ${endDate}`,
92
+ category: 'report',
93
+ apiCall: 'POST /generate-reports/balance-sheet',
94
+ apiBody: { primarySnapshotDate: endDate },
95
+ verification: 'Assets = Liabilities + Equity',
96
+ }),
97
+ ],
98
+ };
99
+ }
100
+ /**
101
+ * Generate a quarter-end close blueprint.
102
+ *
103
+ * @param opts.period Quarter in YYYY-QN format (e.g. "2025-Q1")
104
+ * @param opts.currency Optional base currency code (e.g. "SGD")
105
+ * @param opts.incremental If true, generate only the quarterly extras (skip month-end phases)
106
+ */
107
+ export function generateQuarterEndBlueprint(opts) {
108
+ const parsed = parseQuarterPeriod(opts.period);
109
+ const mode = opts.incremental ? 'incremental' : 'standalone';
110
+ const phases = [];
111
+ if (!opts.incremental) {
112
+ // Standalone: include month-end phases for each of the 3 months
113
+ for (const month of parsed.months) {
114
+ const monthPhases = buildMonthEndPhases(month.startDate, month.endDate, month.year, month.month, month.label);
115
+ phases.push(...monthPhases);
116
+ }
117
+ }
118
+ // Count the highest step order from existing phases
119
+ const maxOrder = phases.reduce((max, phase) => phase.steps.reduce((m, s) => Math.max(m, s.order), max), 0);
120
+ // Add quarterly extras (Phase 6)
121
+ const extras = buildQuarterlyExtrasPhase(parsed.startDate, parsed.endDate, maxOrder);
122
+ phases.push(extras);
123
+ // Add quarterly verification (Phase 7)
124
+ const lastExtraOrder = extras.steps.reduce((max, s) => Math.max(max, s.order), 0);
125
+ const verification = buildQuarterlyVerificationPhase(parsed.startDate, parsed.endDate, parsed.label, lastExtraOrder);
126
+ phases.push(verification);
127
+ return {
128
+ jobType: 'quarter-end-close',
129
+ period: parsed.label,
130
+ currency: opts.currency ?? 'SGD',
131
+ mode,
132
+ phases,
133
+ summary: buildSummary(phases),
134
+ };
135
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Supplier Reconciliation blueprint generator.
3
+ * Produces a structured JobBlueprint for reconciling supplier statements —
4
+ * no actual API calls, just an actionable checklist for accountants.
5
+ */
6
+ import { buildSummary } from './types.js';
7
+ import { parseMonthPeriod } from './validate.js';
8
+ export function generateSupplierReconBlueprint(opts = {}) {
9
+ const currency = opts.currency ?? 'SGD';
10
+ const period = opts.period ?? 'current';
11
+ const supplierFilter = opts.supplier
12
+ ? { supplierName: opts.supplier }
13
+ : {};
14
+ const periodFilter = opts.period
15
+ ? (() => {
16
+ const mp = parseMonthPeriod(opts.period);
17
+ return { dateFrom: mp.startDate, dateTo: mp.endDate };
18
+ })()
19
+ : {};
20
+ // Phase 1: Pull AP Data
21
+ const pullApData = {
22
+ name: 'Pull AP Data',
23
+ description: 'Generate AP aging and pull bills for the supplier.',
24
+ steps: [
25
+ {
26
+ order: 1,
27
+ description: 'Generate AP aging report',
28
+ category: 'report',
29
+ apiCall: 'POST /generate-reports/ap-aging',
30
+ notes: opts.supplier
31
+ ? `Filtered to supplier: ${opts.supplier}`
32
+ : 'Full AP aging — filter to target supplier(s) for comparison',
33
+ verification: 'AP aging total for supplier noted as starting reference',
34
+ },
35
+ {
36
+ order: 2,
37
+ description: 'List all bills for the supplier',
38
+ category: 'verify',
39
+ apiCall: 'POST /bills/search',
40
+ apiBody: {
41
+ filters: {
42
+ ...supplierFilter,
43
+ ...periodFilter,
44
+ },
45
+ },
46
+ notes: 'Include all statuses (DRAFT, APPROVED, PAID) for complete picture',
47
+ verification: 'Bill list pulled — note total count and amounts',
48
+ },
49
+ ],
50
+ };
51
+ // Phase 2: Compare Against Supplier Statement
52
+ const compare = {
53
+ name: 'Compare Against Supplier Statement',
54
+ description: 'Match internal records against the supplier statement to identify discrepancies.',
55
+ steps: [
56
+ {
57
+ order: 3,
58
+ description: 'Match bills against supplier statement line items',
59
+ category: 'verify',
60
+ notes: [
61
+ 'Compare each supplier statement line to internal bills by:',
62
+ '(1) invoice/reference number, (2) amount, (3) date.',
63
+ 'Mark items as matched, missing internally, or missing on statement.',
64
+ ].join(' '),
65
+ verification: 'Every statement line has been compared to internal records',
66
+ },
67
+ {
68
+ order: 4,
69
+ description: 'Identify and document mismatches',
70
+ category: 'review',
71
+ notes: [
72
+ 'Common mismatch types:',
73
+ '(a) Bill in books but not on statement — timing difference or supplier error,',
74
+ '(b) On statement but not in books — missing bill, needs to be entered,',
75
+ '(c) Amount difference — pricing dispute, currency conversion, or data entry error,',
76
+ '(d) Payment not reflected — check payment clearing dates.',
77
+ ].join(' '),
78
+ verification: 'All mismatches documented with root cause and proposed resolution',
79
+ },
80
+ ],
81
+ };
82
+ // Phase 3: Resolve Discrepancies
83
+ const resolve = {
84
+ name: 'Resolve Discrepancies',
85
+ description: 'Create missing bills and record adjustments to align balances.',
86
+ steps: [
87
+ {
88
+ order: 5,
89
+ description: 'Create missing bills from supplier statement',
90
+ category: 'resolve',
91
+ apiCall: 'POST /bills',
92
+ recipeRef: 'standard-bill',
93
+ notes: 'For items on supplier statement but missing from books — create and approve the bill. Attach supplier invoice as supporting document.',
94
+ verification: 'All missing bills created and approved',
95
+ },
96
+ {
97
+ order: 6,
98
+ description: 'Record adjustments for amount differences',
99
+ category: 'adjust',
100
+ apiCall: 'POST /journals',
101
+ notes: 'For amount mismatches — create adjustment journals (e.g., price adjustments, rounding differences). For disputed amounts, create debit notes via POST /bills/credit-notes.',
102
+ verification: 'All adjustments journaled, disputed amounts documented',
103
+ },
104
+ ],
105
+ };
106
+ // Phase 4: Verification
107
+ const verification = {
108
+ name: 'Verification',
109
+ description: 'Confirm AP balance now matches supplier statement.',
110
+ steps: [
111
+ {
112
+ order: 7,
113
+ description: 'Re-check AP aging for supplier after resolution',
114
+ category: 'verify',
115
+ apiCall: 'POST /generate-reports/ap-aging',
116
+ notes: opts.supplier
117
+ ? `Verify ${opts.supplier} balance matches their statement closing balance`
118
+ : 'Verify each reconciled supplier balance matches their statement',
119
+ verification: 'AP balance per books agrees with supplier statement balance. Any remaining differences are documented and accepted.',
120
+ },
121
+ ],
122
+ };
123
+ const phases = [pullApData, compare, resolve, verification];
124
+ return {
125
+ jobType: 'supplier-recon',
126
+ period,
127
+ currency,
128
+ mode: 'standalone',
129
+ phases,
130
+ summary: buildSummary(phases),
131
+ };
132
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared types for jaz jobs blueprint generators.
3
+ */
4
+ export const JOB_TYPES = [
5
+ 'month-end-close',
6
+ 'quarter-end-close',
7
+ 'year-end-close',
8
+ 'bank-recon',
9
+ 'gst-vat-filing',
10
+ 'payment-run',
11
+ 'credit-control',
12
+ 'supplier-recon',
13
+ 'audit-prep',
14
+ 'fa-review',
15
+ ];
16
+ /** Build the summary from phases. */
17
+ export function buildSummary(phases) {
18
+ const allSteps = phases.flatMap(p => p.steps);
19
+ const recipeSet = new Set();
20
+ const calcSet = new Set();
21
+ const apiSet = new Set();
22
+ for (const step of allSteps) {
23
+ if (step.recipeRef)
24
+ recipeSet.add(step.recipeRef);
25
+ if (step.calcCommand)
26
+ calcSet.add(step.calcCommand);
27
+ if (step.apiCall)
28
+ apiSet.add(step.apiCall);
29
+ }
30
+ return {
31
+ totalSteps: allSteps.length,
32
+ recipeReferences: [...recipeSet].sort(),
33
+ calcReferences: [...calcSet].sort(),
34
+ apiCalls: [...apiSet].sort(),
35
+ };
36
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Input validation for jaz jobs blueprint generators.
3
+ * Parses period strings and validates flags.
4
+ */
5
+ export class JobValidationError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'JobValidationError';
9
+ }
10
+ }
11
+ /** Last day of a given month (1-indexed). */
12
+ function lastDay(year, month) {
13
+ return new Date(year, month, 0).getDate();
14
+ }
15
+ /** Format YYYY-MM-DD from components. */
16
+ function fmtDate(y, m, d) {
17
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
18
+ }
19
+ /** Short month name (Jan, Feb, etc.) */
20
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
21
+ /** Parse a month period string like "2025-01". */
22
+ export function parseMonthPeriod(period) {
23
+ const match = period.match(/^(\d{4})-(\d{2})$/);
24
+ if (!match) {
25
+ throw new JobValidationError(`Invalid month period: "${period}". Expected YYYY-MM (e.g., 2025-01)`);
26
+ }
27
+ const year = parseInt(match[1], 10);
28
+ const month = parseInt(match[2], 10);
29
+ if (month < 1 || month > 12) {
30
+ throw new JobValidationError(`Invalid month: ${month}. Must be 01-12`);
31
+ }
32
+ const end = lastDay(year, month);
33
+ return {
34
+ type: 'month',
35
+ year,
36
+ month,
37
+ startDate: fmtDate(year, month, 1),
38
+ endDate: fmtDate(year, month, end),
39
+ label: `${MONTH_NAMES[month - 1]} ${year}`,
40
+ };
41
+ }
42
+ /** Parse a quarter period string like "2025-Q1". */
43
+ export function parseQuarterPeriod(period) {
44
+ const match = period.match(/^(\d{4})-Q([1-4])$/i);
45
+ if (!match) {
46
+ throw new JobValidationError(`Invalid quarter period: "${period}". Expected YYYY-QN (e.g., 2025-Q1)`);
47
+ }
48
+ const year = parseInt(match[1], 10);
49
+ const quarter = parseInt(match[2], 10);
50
+ const firstMonth = (quarter - 1) * 3 + 1;
51
+ const lastMonth = firstMonth + 2;
52
+ const endDay = lastDay(year, lastMonth);
53
+ const months = [];
54
+ for (let m = firstMonth; m <= lastMonth; m++) {
55
+ months.push({
56
+ type: 'month',
57
+ year,
58
+ month: m,
59
+ startDate: fmtDate(year, m, 1),
60
+ endDate: fmtDate(year, m, lastDay(year, m)),
61
+ label: `${MONTH_NAMES[m - 1]} ${year}`,
62
+ });
63
+ }
64
+ return {
65
+ type: 'quarter',
66
+ year,
67
+ quarter,
68
+ startDate: fmtDate(year, firstMonth, 1),
69
+ endDate: fmtDate(year, lastMonth, endDay),
70
+ months,
71
+ label: `Q${quarter} ${year}`,
72
+ };
73
+ }
74
+ /** Parse a year period string like "2025". */
75
+ export function parseYearPeriod(period) {
76
+ const match = period.match(/^(\d{4})$/);
77
+ if (!match) {
78
+ throw new JobValidationError(`Invalid year period: "${period}". Expected YYYY (e.g., 2025)`);
79
+ }
80
+ const year = parseInt(match[1], 10);
81
+ const quarters = [];
82
+ for (let q = 1; q <= 4; q++) {
83
+ quarters.push(parseQuarterPeriod(`${year}-Q${q}`));
84
+ }
85
+ return {
86
+ type: 'year',
87
+ year,
88
+ startDate: fmtDate(year, 1, 1),
89
+ endDate: fmtDate(year, 12, 31),
90
+ quarters,
91
+ label: `FY${year}`,
92
+ };
93
+ }
94
+ /** Validate an optional date string (YYYY-MM-DD). Round-trip checks components. */
95
+ export function validateDateString(date, name) {
96
+ if (!date)
97
+ return;
98
+ const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
99
+ if (!m) {
100
+ throw new JobValidationError(`${name} must be YYYY-MM-DD format (got "${date}")`);
101
+ }
102
+ const y = Number(m[1]);
103
+ const mo = Number(m[2]);
104
+ const da = Number(m[3]);
105
+ const d = new Date(Date.UTC(y, mo - 1, da));
106
+ if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== da) {
107
+ throw new JobValidationError(`Invalid ${name}: "${date}"`);
108
+ }
109
+ }
110
+ /** Validate overdue-days is a positive integer. */
111
+ export function validateOverdueDays(days) {
112
+ if (!Number.isInteger(days) || days < 1) {
113
+ throw new JobValidationError(`Overdue days must be a positive integer (got ${days})`);
114
+ }
115
+ }