PSA/shared/billingClients/recurringServicePeriodGenerationHorizon.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

130 lines
4.3 KiB
TypeScript

import type { IRecurringDateRange, ISO8601String } from '@alga-psa/types';
export const DEFAULT_RECURRING_SERVICE_PERIOD_GENERATION_HORIZON_DAYS = 180;
export const DEFAULT_RECURRING_SERVICE_PERIOD_REPLENISHMENT_THRESHOLD_DAYS = 45;
export type RecurringServicePeriodContinuityIssueKind = 'gap' | 'overlap';
export interface IRecurringServicePeriodContinuityIssue {
kind: RecurringServicePeriodContinuityIssueKind;
previousEnd: ISO8601String;
nextStart: ISO8601String;
}
export interface IRecurringServicePeriodGenerationHorizonWindow {
asOf: ISO8601String;
targetHorizonEnd: ISO8601String;
replenishmentThresholdEnd: ISO8601String;
targetHorizonDays: number;
replenishmentThresholdDays: number;
}
export interface IRecurringServicePeriodGenerationCoverageStatus
extends IRecurringServicePeriodGenerationHorizonWindow {
furthestGeneratedEnd: ISO8601String | null;
meetsTargetHorizon: boolean;
needsReplenishment: boolean;
continuityIssues: IRecurringServicePeriodContinuityIssue[];
}
type ResolveGenerationHorizonInput = {
asOf: ISO8601String;
targetHorizonDays?: number;
replenishmentThresholdDays?: number;
};
type AssessGenerationCoverageInput = ResolveGenerationHorizonInput & {
futurePeriods: Pick<IRecurringDateRange, 'start' | 'end'>[];
};
const UTC_MIDNIGHT_SUFFIX = 'T00:00:00.000Z';
function addDays(date: ISO8601String, days: number): ISO8601String {
const next = new Date(`${date}${UTC_MIDNIGHT_SUFFIX}`);
next.setUTCDate(next.getUTCDate() + days);
return next.toISOString().slice(0, 10);
}
export function resolveRecurringServicePeriodGenerationHorizon(
input: ResolveGenerationHorizonInput,
): IRecurringServicePeriodGenerationHorizonWindow {
const targetHorizonDays = Number.isInteger(input.targetHorizonDays)
&& (input.targetHorizonDays as number) > 0
? Math.trunc(input.targetHorizonDays as number)
: DEFAULT_RECURRING_SERVICE_PERIOD_GENERATION_HORIZON_DAYS;
const replenishmentThresholdDays =
Number.isInteger(input.replenishmentThresholdDays)
&& (input.replenishmentThresholdDays as number) > 0
? Math.trunc(input.replenishmentThresholdDays as number)
: DEFAULT_RECURRING_SERVICE_PERIOD_REPLENISHMENT_THRESHOLD_DAYS;
if (replenishmentThresholdDays >= targetHorizonDays) {
throw new Error(
'Recurring service-period replenishment threshold must stay below the target horizon',
);
}
return {
asOf: input.asOf,
targetHorizonDays,
replenishmentThresholdDays,
targetHorizonEnd: addDays(input.asOf, targetHorizonDays),
replenishmentThresholdEnd: addDays(input.asOf, replenishmentThresholdDays),
};
}
export function findRecurringServicePeriodContinuityIssues(
futurePeriods: Pick<IRecurringDateRange, 'start' | 'end'>[],
): IRecurringServicePeriodContinuityIssue[] {
const sortedPeriods = [...futurePeriods].sort((left, right) =>
left.start === right.start ? left.end.localeCompare(right.end) : left.start.localeCompare(right.start),
);
const issues: IRecurringServicePeriodContinuityIssue[] = [];
for (let index = 1; index < sortedPeriods.length; index += 1) {
const previous = sortedPeriods[index - 1];
const current = sortedPeriods[index];
if (previous.end < current.start) {
issues.push({
kind: 'gap',
previousEnd: previous.end,
nextStart: current.start,
});
continue;
}
if (previous.end > current.start) {
issues.push({
kind: 'overlap',
previousEnd: previous.end,
nextStart: current.start,
});
}
}
return issues;
}
export function assessRecurringServicePeriodGenerationCoverage(
input: AssessGenerationCoverageInput,
): IRecurringServicePeriodGenerationCoverageStatus {
const horizon = resolveRecurringServicePeriodGenerationHorizon(input);
const continuityIssues = findRecurringServicePeriodContinuityIssues(input.futurePeriods);
const furthestGeneratedEnd = input.futurePeriods.length > 0
? [...input.futurePeriods]
.sort((left, right) => left.end.localeCompare(right.end))
.at(-1)?.end ?? null
: null;
return {
...horizon,
furthestGeneratedEnd,
meetsTargetHorizon: furthestGeneratedEnd != null
&& furthestGeneratedEnd >= horizon.targetHorizonEnd,
needsReplenishment: furthestGeneratedEnd == null
|| furthestGeneratedEnd <= horizon.replenishmentThresholdEnd,
continuityIssues,
};
}