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
260 lines
7.8 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|