diff --git a/src/schedule.js b/src/schedule.js index 862dc55..e607628 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,69 @@ * 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, interval = 1, count, until, skip = [] } = rule; + + if (every !== 'week' && every !== 'month') throw new RangeError(`invalid every: ${every}`); + if (!isPositiveInt(interval)) throw new RangeError(`invalid interval: ${interval}`); + const hasCount = count !== undefined && count !== null; + const hasUntil = until !== undefined && until !== null; + if (!hasCount && !hasUntil) throw new RangeError('count or until is required'); + if (hasCount && !isPositiveInt(count)) throw new RangeError(`invalid count: ${count}`); + + const s = parseDate(start, 'start'); + const untilKey = hasUntil ? parseDate(until, 'until') && until : null; + const skipped = new Set(skip); + + const result = []; + for (let k = 0; ; k++) { + const date = every === 'week' ? addDays(s, 7 * interval * k) : addMonths(s, interval * k); + const text = format(date); + // 'YYYY-MM-DD' strings compare chronologically. + if (untilKey !== null && text > untilKey) break; + if (!skipped.has(text)) { + result.push(text); + if (hasCount && result.length === count) break; + } + } + return result; +} + +function isPositiveInt(n) { + return Number.isSafeInteger(n) && n > 0; +} + +function parseDate(text, name) { + const m = typeof text === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); + if (!m) throw new RangeError(`invalid ${name}: ${text}`); + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (mo < 1 || mo > 12 || d < 1 || d > daysInMonth(y, mo)) { + throw new RangeError(`invalid ${name}: ${text}`); + } + return { y, mo, d }; +} + +function daysInMonth(y, mo) { + const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; + return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][mo - 1]; +} + +// Month arithmetic from the original start day, clamped to the month's length. +function addMonths({ y, mo, d }, n) { + const idx = y * 12 + (mo - 1) + n; + const ny = Math.floor(idx / 12); + const nmo = (idx % 12) + 1; + return { y: ny, mo: nmo, d: Math.min(d, daysInMonth(ny, nmo)) }; +} + +function addDays({ y, mo, d }, n) { + // setUTCFullYear avoids Date.UTC's mapping of years 0-99 to 1900-1999. + const dt = new Date(0); + dt.setUTCFullYear(y, mo - 1, d + n); + return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }; +} + +function format({ y, mo, d }) { + if (y > 9999) throw new RangeError('billing date beyond year 9999'); + return `${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`; } diff --git a/tests/schedule.test.js b/tests/schedule.test.js index ee3d88f..bb74abd 100644 --- a/tests/schedule.test.js +++ b/tests/schedule.test.js @@ -5,3 +5,31 @@ import { billingDates } from '../src/schedule.js'; test('monthly, mid-month start', () => { assert.deepEqual(billingDates('2026-01-15', { every: 'month', count: 3 }), ['2026-01-15', '2026-02-15', '2026-03-15']); }); + +test('monthly clamps to month end and 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-01-31', { every: 'month', count: 2 }), ['2024-01-31', '2024-02-29']); +}); + +test('interval, weeks, until, skip', () => { + assert.deepEqual(billingDates('2026-11-30', { every: 'month', interval: 3, count: 3 }), ['2026-11-30', '2027-02-28', '2027-05-30']); + assert.deepEqual(billingDates('2026-12-28', { every: 'week', count: 3 }), ['2026-12-28', '2027-01-04', '2027-01-11']); + assert.deepEqual(billingDates('2026-01-01', { every: 'week', interval: 2, until: '2026-02-12' }), ['2026-01-01', '2026-01-15', '2026-01-29', '2026-02-12']); + assert.deepEqual(billingDates('2026-01-15', { every: 'month', count: 2, skip: ['2026-02-15'] }), ['2026-01-15', '2026-03-15']); + assert.deepEqual(billingDates('2026-01-15', { every: 'month', count: 5, until: '2026-03-15', skip: ['2026-01-15'] }), ['2026-02-15', '2026-03-15']); + assert.deepEqual(billingDates('2026-05-01', { every: 'month', until: '2026-04-01' }), []); +}); + +test('invalid input throws RangeError', () => { + const bad = [ + ['2026-01-01', { every: 'month' }], + ['2026-01-01', { every: 'day', count: 1 }], + ['2026-01-01', { every: 'month', count: 0 }], + ['2026-01-01', { every: 'month', count: 1.5 }], + ['2026-01-01', { every: 'month', count: 1, interval: 0 }], + ['2026-02-30', { every: 'month', count: 1 }], + ['2026-1-01', { every: 'month', count: 1 }], + ['2026-01-01', { every: 'month', until: '2026-13-01' }], + ]; + for (const [s, r] of bad) assert.throws(() => billingDates(s, r), RangeError); +});