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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
334 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
}
|