PSA/shared/billingClients/billingCycleAnchors.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

334 lines
11 KiB
TypeScript

import { Temporal } from '@js-temporal/polyfill';
import { parseISO } from 'date-fns';
import type { BillingCycleType, ISO8601String } from '@alga-psa/types';
export type BillingCycleAnchorSettingsInput = {
dayOfMonth?: number | null;
monthOfYear?: number | null;
dayOfWeek?: number | null; // ISO 1=Mon..7=Sun
referenceDate?: ISO8601String | null; // UTC-midnight, establishes bi-weekly parity
};
export type NormalizedBillingCycleAnchorSettings = {
dayOfMonth: number | null;
monthOfYear: number | null;
dayOfWeek: number | null;
referenceDate: ISO8601String | null;
};
export function ensureUtcMidnightIsoDate(input: string): ISO8601String {
if (typeof input !== 'string') {
throw new Error('Date must be a string');
}
const parsed = parseISO(input);
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCHours() !== 0 ||
parsed.getUTCMinutes() !== 0 ||
parsed.getUTCSeconds() !== 0 ||
parsed.getUTCMilliseconds() !== 0
) {
throw new Error(`Date must be UTC midnight. Got: ${input}`);
}
// Normalize to a consistent `YYYY-MM-DDT00:00:00Z` format.
const iso = parsed.toISOString();
const datePart = iso.split('T')[0];
return `${datePart}T00:00:00Z` as ISO8601String;
}
export function getAnchorDefaultsForCycle(
billingCycle: BillingCycleType
): NormalizedBillingCycleAnchorSettings {
switch (billingCycle) {
case 'weekly':
case 'bi-weekly':
// Preserve historical "rolling" behavior unless an admin explicitly anchors.
return { dayOfMonth: null, monthOfYear: null, dayOfWeek: null, referenceDate: null };
case 'monthly':
return { dayOfMonth: 1, monthOfYear: null, dayOfWeek: null, referenceDate: null };
case 'quarterly':
case 'semi-annually':
case 'annually':
return { dayOfMonth: 1, monthOfYear: 1, dayOfWeek: null, referenceDate: null };
default:
return { dayOfMonth: 1, monthOfYear: null, dayOfWeek: null, referenceDate: null };
}
}
export function validateAnchorSettingsForCycle(
billingCycle: BillingCycleType,
input: BillingCycleAnchorSettingsInput
): void {
const defaults = getAnchorDefaultsForCycle(billingCycle);
const dayOfMonth = input.dayOfMonth ?? defaults.dayOfMonth;
const monthOfYear = input.monthOfYear ?? defaults.monthOfYear;
const dayOfWeek = input.dayOfWeek ?? defaults.dayOfWeek;
const referenceDate = input.referenceDate ?? defaults.referenceDate;
switch (billingCycle) {
case 'weekly': {
if (dayOfWeek === null || dayOfWeek === undefined) {
// Allow clearing weekly anchor back to rolling schedule.
return;
}
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) {
throw new Error('Weekly anchor must include dayOfWeek in range 1..7');
}
return;
}
case 'bi-weekly': {
if (referenceDate === null || referenceDate === undefined) {
// Allow clearing back to rolling schedule.
return;
}
ensureUtcMidnightIsoDate(referenceDate);
return;
}
case 'monthly': {
if (dayOfMonth === null || dayOfMonth === undefined) {
throw new Error('Monthly anchor must include dayOfMonth');
}
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 28) {
throw new Error('Monthly anchor dayOfMonth must be in range 1..28');
}
return;
}
case 'quarterly':
case 'semi-annually':
case 'annually': {
if (monthOfYear === null || monthOfYear === undefined) {
throw new Error('Anchored cycles must include monthOfYear');
}
if (!Number.isInteger(monthOfYear) || monthOfYear < 1 || monthOfYear > 12) {
throw new Error('monthOfYear must be in range 1..12');
}
if (dayOfMonth === null || dayOfMonth === undefined) {
throw new Error('Anchored cycles must include dayOfMonth');
}
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 28) {
throw new Error('dayOfMonth must be in range 1..28');
}
return;
}
}
}
export function normalizeAnchorSettingsForCycle(
billingCycle: BillingCycleType,
input: BillingCycleAnchorSettingsInput
): NormalizedBillingCycleAnchorSettings {
const defaults = getAnchorDefaultsForCycle(billingCycle);
const normalized: NormalizedBillingCycleAnchorSettings = {
dayOfMonth: input.dayOfMonth ?? defaults.dayOfMonth,
monthOfYear: input.monthOfYear ?? defaults.monthOfYear,
dayOfWeek: input.dayOfWeek ?? defaults.dayOfWeek,
referenceDate: input.referenceDate ?? defaults.referenceDate,
};
validateAnchorSettingsForCycle(billingCycle, normalized);
if (normalized.referenceDate) {
normalized.referenceDate = ensureUtcMidnightIsoDate(normalized.referenceDate);
}
return normalized;
}
function toPlainDate(date: ISO8601String): Temporal.PlainDate {
const d = ensureUtcMidnightIsoDate(date);
return Temporal.PlainDate.from(d.slice(0, 10));
}
function toUtcMidnightIso(date: Temporal.PlainDate): ISO8601String {
return `${date.toString()}T00:00:00Z` as ISO8601String;
}
function epochDay(date: Temporal.PlainDate): number {
return date.toZonedDateTime({ timeZone: 'UTC', plainTime: '00:00' }).epochSeconds / 86400;
}
function monthsPerCycleFor(cycle: BillingCycleType): number {
switch (cycle) {
case 'quarterly':
return 3;
case 'semi-annually':
return 6;
case 'annually':
return 12;
default:
return 1;
}
}
function addCycle(date: Temporal.PlainDate, cycle: BillingCycleType): Temporal.PlainDate {
switch (cycle) {
case 'weekly':
return date.add({ days: 7 });
case 'bi-weekly':
return date.add({ days: 14 });
case 'monthly':
return date.add({ months: 1 });
case 'quarterly':
return date.add({ months: 3 });
case 'semi-annually':
return date.add({ months: 6 });
case 'annually':
return date.add({ years: 1 });
}
}
function latestBoundaryInYearAtOrBefore(params: {
year: number;
date: Temporal.PlainDate;
baseMonth: number;
baseDay: number;
monthsPerCycle: number;
}): Temporal.PlainDate | null {
const { year, date, baseMonth, baseDay, monthsPerCycle } = params;
const boundaries: Temporal.PlainDate[] = [];
for (let month = baseMonth; month <= 12; month += monthsPerCycle) {
boundaries.push(Temporal.PlainDate.from({ year, month, day: baseDay }));
}
const eligible = boundaries.filter((b) => Temporal.PlainDate.compare(b, date) <= 0);
if (eligible.length === 0) return null;
eligible.sort((a, b) => Temporal.PlainDate.compare(a, b));
return eligible[eligible.length - 1];
}
export function getBillingPeriodForDate(
date: ISO8601String,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): { periodStartDate: ISO8601String; periodEndDate: ISO8601String } {
const datePlain = toPlainDate(date);
// For rolling schedules, we interpret the current "period" as starting at the provided date.
if ((billingCycle === 'weekly' && !anchor.dayOfWeek) || (billingCycle === 'bi-weekly' && !anchor.referenceDate)) {
const start = datePlain;
const end = addCycle(start, billingCycle);
return { periodStartDate: toUtcMidnightIso(start), periodEndDate: toUtcMidnightIso(end) };
}
const start = getPreviousBillingBoundaryAtOrBefore(datePlain, billingCycle, anchor);
const end = getNextBillingBoundaryAfterPlain(start, billingCycle, anchor);
return { periodStartDate: toUtcMidnightIso(start), periodEndDate: toUtcMidnightIso(end) };
}
export function getNextBillingBoundaryAfter(
fromDate: ISO8601String,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): ISO8601String {
const fromPlain = toPlainDate(fromDate);
// For rolling schedules, treat `fromDate` as a boundary and advance by the cycle length.
if ((billingCycle === 'weekly' && !anchor.dayOfWeek) || (billingCycle === 'bi-weekly' && !anchor.referenceDate)) {
return toUtcMidnightIso(addCycle(fromPlain, billingCycle));
}
return toUtcMidnightIso(getNextBillingBoundaryAfterPlain(fromPlain, billingCycle, anchor));
}
function getPreviousBillingBoundaryAtOrBefore(
date: Temporal.PlainDate,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): Temporal.PlainDate {
switch (billingCycle) {
case 'weekly': {
if (!anchor.dayOfWeek) {
return date;
}
const delta = (date.dayOfWeek - anchor.dayOfWeek + 7) % 7;
return date.subtract({ days: delta });
}
case 'bi-weekly': {
if (!anchor.referenceDate) {
return date;
}
const ref = toPlainDate(anchor.referenceDate);
const diff = epochDay(date) - epochDay(ref);
const steps = Math.floor(diff / 14);
return ref.add({ days: steps * 14 });
}
case 'monthly': {
const day = anchor.dayOfMonth ?? 1;
if (date.day >= day) {
return Temporal.PlainDate.from({ year: date.year, month: date.month, day });
}
const prevMonth = date.subtract({ months: 1 });
return Temporal.PlainDate.from({ year: prevMonth.year, month: prevMonth.month, day });
}
case 'quarterly':
case 'semi-annually':
case 'annually': {
const monthsPerCycle = monthsPerCycleFor(billingCycle);
const baseMonth = anchor.monthOfYear ?? 1;
const baseDay = anchor.dayOfMonth ?? 1;
const inYear = latestBoundaryInYearAtOrBefore({ year: date.year, date, baseMonth, baseDay, monthsPerCycle });
if (inYear) return inYear;
const previousYear = date.subtract({ years: 1 });
const prevYearLast = latestBoundaryInYearAtOrBefore({
year: previousYear.year,
date: Temporal.PlainDate.from({ year: previousYear.year, month: 12, day: 28 }),
baseMonth,
baseDay,
monthsPerCycle
});
if (!prevYearLast) {
return Temporal.PlainDate.from({ year: previousYear.year, month: baseMonth, day: baseDay });
}
return prevYearLast;
}
}
}
function getNextBillingBoundaryAfterPlain(
fromDate: Temporal.PlainDate,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): Temporal.PlainDate {
switch (billingCycle) {
case 'weekly': {
if (!anchor.dayOfWeek) {
return addCycle(fromDate, billingCycle);
}
const dayOfWeek = anchor.dayOfWeek;
const next = fromDate.add({ days: 1 });
const delta = (dayOfWeek - next.dayOfWeek + 7) % 7;
return next.add({ days: delta });
}
case 'bi-weekly': {
if (!anchor.referenceDate) {
return addCycle(fromDate, billingCycle);
}
return fromDate.add({ days: 14 });
}
case 'monthly': {
const day = anchor.dayOfMonth ?? 1;
const nextMonth = fromDate.add({ months: 1 });
return Temporal.PlainDate.from({ year: nextMonth.year, month: nextMonth.month, day });
}
case 'quarterly':
case 'semi-annually':
case 'annually': {
const monthsPerCycle = monthsPerCycleFor(billingCycle);
const baseDay = anchor.dayOfMonth ?? 1;
const baseMonth = anchor.monthOfYear ?? 1;
const current = fromDate;
const step = current.add({ months: monthsPerCycle });
// Ensure the boundary aligns to the baseMonth within the year for multi-month cycles.
const monthIndex = (step.month - baseMonth + 12) % monthsPerCycle;
const aligned = step.subtract({ months: monthIndex });
return Temporal.PlainDate.from({ year: aligned.year, month: aligned.month, day: baseDay });
}
}
}