diff --git a/src/schedule.js b/src/schedule.js index 862dc55..5f10c42 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -1,5 +1,33 @@ // Billing schedule for subscriptions. +const DAY_MS = 24 * 60 * 60 * 1000; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function parseDate(value) { + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + throw new RangeError(`invalid calendar date: ${value}`); + } + + const time = Date.parse(`${value}T00:00:00.000Z`); + if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value) { + throw new RangeError(`invalid calendar date: ${value}`); + } + return time; +} + +function addMonths(time, months) { + const date = new Date(time); + const billingDay = date.getUTCDate(); + + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + months); + + const lastDay = new Date(date); + lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0); + date.setUTCDate(Math.min(billingDay, lastDay.getUTCDate())); + return date.getTime(); +} + /** * Returns the dates a subscription is billed on, in order, as 'YYYY-MM-DD' strings. * @@ -22,6 +50,36 @@ * positive integer, if `every` isn't 'week' or 'month', or if start or until isn't a real * calendar date in 'YYYY-MM-DD' form. */ -export function billingDates(start, rule) { - throw new Error('not implemented'); +export function billingDates(start, rule = {}) { + const startTime = parseDate(start); + const { every, interval = 1, count, until, skip = [] } = rule; + + if (every !== 'week' && every !== 'month') { + throw new RangeError(`invalid billing frequency: ${every}`); + } + if (!Number.isInteger(interval) || interval <= 0) { + throw new RangeError(`invalid billing interval: ${interval}`); + } + if (count === undefined && until === undefined) { + throw new RangeError('count or until is required'); + } + if (count !== undefined && (!Number.isInteger(count) || count <= 0)) { + throw new RangeError(`invalid billing count: ${count}`); + } + + const untilTime = until === undefined ? Infinity : parseDate(until); + const skipped = new Set(skip); + const dates = []; + + for (let index = 0; count === undefined || dates.length < count; index += 1) { + const time = every === 'week' + ? startTime + index * interval * 7 * DAY_MS + : addMonths(startTime, index * interval); + if (time > untilTime) break; + + const date = new Date(time).toISOString().slice(0, 10); + if (!skipped.has(date)) dates.push(date); + } + + return dates; }