diff --git a/src/schedule.js b/src/schedule.js index 862dc55..e00c80b 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,70 @@ * calendar date in 'YYYY-MM-DD' form. */ export function billingDates(start, rule) { - throw new Error('not implemented'); + if (rule === null || typeof rule !== 'object') throw new RangeError('rule must be an object'); + const { every, until, skip = [] } = rule; + const interval = rule.interval === undefined ? 1 : rule.interval; + const count = rule.count; + + if (every !== 'week' && every !== 'month') throw new RangeError("every must be 'week' or 'month'"); + if (!isPositiveInt(interval)) throw new RangeError('interval must be a positive integer'); + if (count === undefined && until === undefined) throw new RangeError('count or until is required'); + if (count !== undefined && !isPositiveInt(count)) throw new RangeError('count must be a positive integer'); + if (!Array.isArray(skip)) throw new RangeError('skip must be an array'); + + const first = parseDate(start, 'start'); + if (until !== undefined) parseDate(until, 'until'); + + const skipped = new Set(skip); + const dates = []; + // Each date is computed from the start date, not from the previous date, so a clamped + // day (Feb 28) doesn't carry over into later months. + for (let i = 0; count === undefined || dates.length < count; i++) { + const date = every === 'week' + ? addDays(first, 7 * interval * i) + : addMonths(first, interval * i); + if (date === null) break; // ran past year 9999 + if (until !== undefined && date > until) break; + if (!skipped.has(date)) dates.push(date); + } + return dates; +} + +function isPositiveInt(n) { + return Number.isInteger(n) && n > 0; +} + +function daysInMonth(year, month) { + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +function parseDate(str, name) { + const m = typeof str === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(str); + if (m) { + const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)) { + return { year, month, day }; + } + } + throw new RangeError(`${name} must be a real calendar date in YYYY-MM-DD form`); +} + +function format({ year, month, day }) { + if (year > 9999) return null; + const p = (n, w) => String(n).padStart(w, '0'); + return `${p(year, 4)}-${p(month, 2)}-${p(day, 2)}`; +} + +function addMonths({ year, month, day }, n) { + const index = year * 12 + (month - 1) + n; + const y = Math.floor(index / 12); + const mo = (index % 12) + 1; + return format({ year: y, month: mo, day: Math.min(day, daysInMonth(y, mo)) }); +} + +function addDays({ year, month, day }, n) { + const d = new Date(0); + d.setUTCFullYear(year, month - 1, day + n); + return format({ year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }); } diff --git a/tests/schedule.test.js b/tests/schedule.test.js index ee3d88f..1bd220f 100644 --- a/tests/schedule.test.js +++ b/tests/schedule.test.js @@ -2,6 +2,39 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { billingDates } from '../src/schedule.js'; +test('month-end clamps then returns to original day', () => { + assert.deepEqual(billingDates('2026-01-31', { every: 'month', count: 4 }), ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']); + assert.deepEqual(billingDates('2024-02-29', { every: 'month', interval: 12, count: 3 }), ['2024-02-29', '2025-02-28', '2026-02-28']); +}); + +test('weekly with until and skip across a year boundary', () => { + assert.deepEqual( + billingDates('2026-12-30', { every: 'week', until: '2027-01-20', skip: ['2027-01-06'] }), + ['2026-12-30', '2027-01-13', '2027-01-20'], + ); +}); + +test('skipped dates do not count toward count', () => { + assert.deepEqual(billingDates('2026-01-01', { every: 'week', count: 2, skip: ['2026-01-08'] }), ['2026-01-01', '2026-01-15']); +}); + +test('count and until stop at whichever comes first', () => { + assert.deepEqual(billingDates('2026-05-01', { every: 'month', count: 3, until: '2026-05-31' }), ['2026-05-01']); + assert.deepEqual(billingDates('2026-06-01', { every: 'month', count: 3, until: '2026-05-31' }), []); +}); + +test('invalid input throws RangeError', () => { + const bad = [ + ['2026-02-30', { every: 'week', count: 1 }], + ['2026-01-01', { every: 'week' }], + ['2026-01-01', { every: 'day', count: 1 }], + ['2026-01-01', { every: 'week', count: 0 }], + ['2026-01-01', { every: 'week', interval: 1.5, count: 1 }], + ['2026-01-01', { every: 'week', until: '2026-13-01' }], + ]; + for (const [s, r] of bad) assert.throws(() => billingDates(s, r), RangeError); +}); + test('monthly, mid-month start', () => { assert.deepEqual(billingDates('2026-01-15', { every: 'month', count: 3 }), ['2026-01-15', '2026-02-15', '2026-03-15']); });