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

260 lines
7.8 KiB
TypeScript

import { Temporal } from '@js-temporal/polyfill';
import type {
DuePosition,
ICadenceBoundaryGenerator,
IRecurringInvoiceWindow,
IRecurringObligationRef,
IRecurringServicePeriod,
ISO8601String,
} from '@alga-psa/types';
import { RECURRING_RANGE_SEMANTICS } from '@alga-psa/types';
import { ensureUtcMidnightIsoDate } from './billingCycleAnchors';
export const CONTRACT_CADENCE_LIFECYCLE_MODES = [
'new_assignment',
'renew_in_place',
'renewed_contract',
] as const;
export type ContractCadenceLifecycleMode = (typeof CONTRACT_CADENCE_LIFECYCLE_MODES)[number];
export interface ContractCadenceServicePeriodGenerationInput {
rangeStart: ISO8601String;
rangeEnd: ISO8601String;
sourceObligation: IRecurringObligationRef;
duePosition: DuePosition;
anchorDate: ISO8601String;
}
export interface ResolveContractCadenceAnchorDateInput {
assignmentStartDate: ISO8601String;
lifecycleMode?: ContractCadenceLifecycleMode;
previousAnchorDate?: ISO8601String | null;
}
export interface ResolveContractCadenceInvoiceWindowForServicePeriodInput {
servicePeriod: IRecurringServicePeriod;
anchorDate: ISO8601String;
monthsPerPeriod: number;
windowId?: string;
}
const toPlainDate = (value: ISO8601String) => Temporal.PlainDate.from(value.slice(0, 10));
const compareIsoDates = (left: ISO8601String, right: ISO8601String) =>
Temporal.PlainDate.compare(toPlainDate(left), toPlainDate(right));
function toUtcMidnightIsoDate(date: Temporal.PlainDate): ISO8601String {
return `${date.toString()}T00:00:00Z` as ISO8601String;
}
export function resolveContractCadenceAnchorDate(
input: ResolveContractCadenceAnchorDateInput,
): ISO8601String {
const assignmentStartDate = ensureUtcMidnightIsoDate(input.assignmentStartDate);
if (input.lifecycleMode === 'renew_in_place' && input.previousAnchorDate) {
return ensureUtcMidnightIsoDate(input.previousAnchorDate);
}
return assignmentStartDate;
}
export function resolveContractCadenceInvoiceWindowForServicePeriod(
input: ResolveContractCadenceInvoiceWindowForServicePeriodInput,
): IRecurringInvoiceWindow {
if (input.servicePeriod.cadenceOwner !== 'contract') {
throw new Error('Contract cadence invoice windows require a contract-owned service period.');
}
if (input.monthsPerPeriod <= 0) {
throw new Error('Contract cadence invoice windows require a positive monthsPerPeriod.');
}
const anchorDate = ensureUtcMidnightIsoDate(input.anchorDate);
if (input.servicePeriod.duePosition === 'advance') {
return {
kind: 'invoice_window',
cadenceOwner: 'contract',
duePosition: 'advance',
start: input.servicePeriod.start,
end: input.servicePeriod.end,
semantics: RECURRING_RANGE_SEMANTICS,
windowId: input.windowId,
};
}
const periodIndex = resolveBoundaryIndexAtOrBefore(
toPlainDate(anchorDate),
toPlainDate(input.servicePeriod.start),
input.monthsPerPeriod,
);
const invoiceWindowEnd = toUtcMidnightIsoDate(
toPlainDate(anchorDate).add({ months: (periodIndex + 2) * input.monthsPerPeriod }),
);
if (compareIsoDates(invoiceWindowEnd, input.servicePeriod.end) <= 0) {
throw new Error('Contract cadence arrears invoice windows must advance beyond the service period end.');
}
return {
kind: 'invoice_window',
cadenceOwner: 'contract',
duePosition: 'arrears',
start: input.servicePeriod.end,
end: invoiceWindowEnd,
semantics: RECURRING_RANGE_SEMANTICS,
windowId: input.windowId,
};
}
function toServicePeriod(input: {
start: ISO8601String;
end: ISO8601String;
sourceObligation: IRecurringObligationRef;
duePosition: DuePosition;
anchorDate: ISO8601String;
}): IRecurringServicePeriod {
return {
kind: 'service_period',
cadenceOwner: 'contract',
duePosition: input.duePosition,
sourceObligation: input.sourceObligation,
start: input.start,
end: input.end,
semantics: RECURRING_RANGE_SEMANTICS,
timingMetadata: {
anchorDate: input.anchorDate,
boundarySource: 'assignment_start_date',
},
};
}
function resolveBoundaryIndexAtOrBefore(
anchor: Temporal.PlainDate,
target: Temporal.PlainDate,
monthsPerPeriod: number,
): number {
if (Temporal.PlainDate.compare(target, anchor) <= 0) {
return 0;
}
let index = Math.floor(((target.year - anchor.year) * 12 + (target.month - anchor.month)) / monthsPerPeriod);
let boundary = anchor.add({ months: index * monthsPerPeriod });
while (Temporal.PlainDate.compare(boundary, target) > 0) {
index -= 1;
boundary = anchor.add({ months: index * monthsPerPeriod });
}
while (Temporal.PlainDate.compare(anchor.add({ months: (index + 1) * monthsPerPeriod }), target) <= 0) {
index += 1;
}
return Math.max(index, 0);
}
function buildContractCadenceServicePeriods(
input: ContractCadenceServicePeriodGenerationInput & { monthsPerPeriod: number },
): IRecurringServicePeriod[] {
const rangeStart = ensureUtcMidnightIsoDate(input.rangeStart);
const rangeEnd = ensureUtcMidnightIsoDate(input.rangeEnd);
const anchorDate = ensureUtcMidnightIsoDate(input.anchorDate);
if (compareIsoDates(rangeEnd, rangeStart) <= 0) {
throw new Error('Contract cadence generation requires rangeEnd to be after rangeStart.');
}
if (compareIsoDates(anchorDate, rangeEnd) >= 0) {
return [];
}
const anchor = toPlainDate(anchorDate);
const rangeStartDate = toPlainDate(rangeStart);
const rangeEndDate = toPlainDate(rangeEnd);
const startIndex = resolveBoundaryIndexAtOrBefore(anchor, rangeStartDate, input.monthsPerPeriod);
const periods: IRecurringServicePeriod[] = [];
for (let index = startIndex; index < startIndex + 100; index += 1) {
const offsetMonths = index * input.monthsPerPeriod;
const periodStartDate = anchor.add({ months: offsetMonths });
if (Temporal.PlainDate.compare(periodStartDate, rangeEndDate) >= 0) {
break;
}
const periodEndDate = anchor.add({ months: offsetMonths + input.monthsPerPeriod });
const periodStart = toUtcMidnightIsoDate(periodStartDate);
const periodEnd = toUtcMidnightIsoDate(periodEndDate);
if (compareIsoDates(periodEnd, periodStart) <= 0) {
throw new Error('Contract cadence generation did not advance to the next service period.');
}
periods.push(
toServicePeriod({
start: periodStart,
end: periodEnd,
sourceObligation: input.sourceObligation,
duePosition: input.duePosition,
anchorDate,
}),
);
}
return periods;
}
export function generateMonthlyContractCadenceServicePeriods(
input: ContractCadenceServicePeriodGenerationInput,
): IRecurringServicePeriod[] {
return buildContractCadenceServicePeriods({
...input,
monthsPerPeriod: 1,
});
}
export function generateQuarterlyContractCadenceServicePeriods(
input: ContractCadenceServicePeriodGenerationInput,
): IRecurringServicePeriod[] {
return buildContractCadenceServicePeriods({
...input,
monthsPerPeriod: 3,
});
}
export function generateSemiAnnualContractCadenceServicePeriods(
input: ContractCadenceServicePeriodGenerationInput,
): IRecurringServicePeriod[] {
return buildContractCadenceServicePeriods({
...input,
monthsPerPeriod: 6,
});
}
export function generateAnnualContractCadenceServicePeriods(
input: ContractCadenceServicePeriodGenerationInput,
): IRecurringServicePeriod[] {
return buildContractCadenceServicePeriods({
...input,
monthsPerPeriod: 12,
});
}
export const contractCadenceMonthlyBoundaryGenerator: ICadenceBoundaryGenerator = {
owner: 'contract',
generate: (input) => {
if (!input.anchorDate) {
throw new Error('Contract cadence generation requires anchorDate.');
}
return generateMonthlyContractCadenceServicePeriods({
rangeStart: input.rangeStart,
rangeEnd: input.rangeEnd,
sourceObligation: input.sourceObligation,
duePosition: input.duePosition,
anchorDate: input.anchorDate,
});
},
};