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
272 lines
8.4 KiB
TypeScript
272 lines
8.4 KiB
TypeScript
import type {
|
|
BillingCycleType,
|
|
DuePosition,
|
|
IRecurringDueSelectionInput,
|
|
IRecurringObligationRef,
|
|
IRecurringRunExecutionWindowIdentity,
|
|
ISO8601String,
|
|
RecurringRunExecutionWindowKind,
|
|
} from '@alga-psa/types';
|
|
import { Temporal } from '@js-temporal/polyfill';
|
|
import {
|
|
generateAnnualContractCadenceServicePeriods,
|
|
generateMonthlyContractCadenceServicePeriods,
|
|
generateQuarterlyContractCadenceServicePeriods,
|
|
generateSemiAnnualContractCadenceServicePeriods,
|
|
resolveContractCadenceInvoiceWindowForServicePeriod,
|
|
} from './contractCadenceServicePeriods';
|
|
|
|
export interface RecurringRunSelectionIdentity {
|
|
executionIdentityKeys: string[];
|
|
selectionKey: string;
|
|
retryKey: string;
|
|
}
|
|
|
|
export type ContractCadenceSchedulableFrequency = Extract<
|
|
BillingCycleType,
|
|
'monthly' | 'quarterly' | 'semi-annually' | 'annually'
|
|
>;
|
|
|
|
export interface ContractCadenceRecurringRunTarget {
|
|
executionWindow: IRecurringRunExecutionWindowIdentity;
|
|
selectorInput: IRecurringDueSelectionInput;
|
|
servicePeriodStart: ISO8601String;
|
|
servicePeriodEnd: ISO8601String;
|
|
}
|
|
|
|
function compactIdentitySegments(segments: Array<unknown>): string[] {
|
|
return segments
|
|
.map((segment) => {
|
|
if (segment == null) {
|
|
return undefined;
|
|
}
|
|
return String(segment).trim();
|
|
})
|
|
.filter((segment): segment is string => Boolean(segment && segment.length > 0));
|
|
}
|
|
|
|
export function buildRecurringRunExecutionIdentityKey(
|
|
window: Omit<IRecurringRunExecutionWindowIdentity, 'identityKey'>,
|
|
): string {
|
|
return compactIdentitySegments([
|
|
window.kind,
|
|
window.cadenceOwner,
|
|
window.clientId,
|
|
window.scheduleKey ?? undefined,
|
|
window.periodKey ?? undefined,
|
|
window.contractId ?? undefined,
|
|
window.contractLineId ?? undefined,
|
|
window.windowStart ?? undefined,
|
|
window.windowEnd ?? undefined,
|
|
]).join(':');
|
|
}
|
|
|
|
export function buildClientCadenceExecutionWindow(input: {
|
|
clientId: string;
|
|
scheduleKey: string;
|
|
periodKey: string;
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
}): IRecurringRunExecutionWindowIdentity {
|
|
const baseWindow = {
|
|
kind: 'client_cadence_window' as const,
|
|
cadenceOwner: 'client' as const,
|
|
clientId: input.clientId,
|
|
scheduleKey: input.scheduleKey,
|
|
periodKey: input.periodKey,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
};
|
|
|
|
return {
|
|
...baseWindow,
|
|
identityKey: buildRecurringRunExecutionIdentityKey(baseWindow),
|
|
};
|
|
}
|
|
|
|
export function buildContractCadenceExecutionWindow(input: {
|
|
clientId: string;
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
contractId?: string | null;
|
|
contractLineId?: string | null;
|
|
}): IRecurringRunExecutionWindowIdentity {
|
|
const baseWindow = {
|
|
kind: 'contract_cadence_window' as const,
|
|
cadenceOwner: 'contract' as const,
|
|
clientId: input.clientId,
|
|
contractId: input.contractId ?? null,
|
|
contractLineId: input.contractLineId ?? null,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
};
|
|
|
|
return {
|
|
...baseWindow,
|
|
identityKey: buildRecurringRunExecutionIdentityKey(baseWindow),
|
|
};
|
|
}
|
|
|
|
export function listRecurringRunExecutionWindowKinds(
|
|
windows: Array<Pick<IRecurringRunExecutionWindowIdentity, 'kind'>>,
|
|
): RecurringRunExecutionWindowKind[] {
|
|
return Array.from(new Set(windows.map((window) => window.kind))).sort() as RecurringRunExecutionWindowKind[];
|
|
}
|
|
|
|
export function buildRecurringRunSelectionIdentity(
|
|
windows: Array<Pick<IRecurringRunExecutionWindowIdentity, 'identityKey'>>,
|
|
): RecurringRunSelectionIdentity {
|
|
const executionIdentityKeys = Array.from(
|
|
new Set(windows.map((window) => window.identityKey).filter(Boolean)),
|
|
).sort();
|
|
const keyBody = executionIdentityKeys.join('|');
|
|
|
|
return {
|
|
executionIdentityKeys,
|
|
selectionKey: `recurring-run-selection:${keyBody}`,
|
|
retryKey: `recurring-run-retry:${keyBody}`,
|
|
};
|
|
}
|
|
|
|
export function buildClientCadenceDueSelectionInput(input: {
|
|
clientId: string;
|
|
scheduleKey: string;
|
|
periodKey: string;
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
}): IRecurringDueSelectionInput {
|
|
return {
|
|
clientId: input.clientId,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
executionWindow: buildClientCadenceExecutionWindow({
|
|
clientId: input.clientId,
|
|
scheduleKey: input.scheduleKey,
|
|
periodKey: input.periodKey,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function buildContractCadenceDueSelectionInput(input: {
|
|
clientId: string;
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
contractId?: string | null;
|
|
contractLineId?: string | null;
|
|
}): IRecurringDueSelectionInput {
|
|
return {
|
|
clientId: input.clientId,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
executionWindow: buildContractCadenceExecutionWindow({
|
|
clientId: input.clientId,
|
|
contractId: input.contractId ?? null,
|
|
contractLineId: input.contractLineId ?? null,
|
|
windowStart: input.windowStart,
|
|
windowEnd: input.windowEnd,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const toPlainDate = (value: ISO8601String) => Temporal.PlainDate.from(value.slice(0, 10));
|
|
|
|
function rangesOverlap(
|
|
left: { windowStart: ISO8601String; windowEnd: ISO8601String },
|
|
right: { windowStart: ISO8601String; windowEnd: ISO8601String },
|
|
): boolean {
|
|
return (
|
|
Temporal.PlainDate.compare(toPlainDate(left.windowStart), toPlainDate(right.windowEnd)) < 0 &&
|
|
Temporal.PlainDate.compare(toPlainDate(right.windowStart), toPlainDate(left.windowEnd)) < 0
|
|
);
|
|
}
|
|
|
|
function getContractCadenceFrequencyDefinition(
|
|
frequency: ContractCadenceSchedulableFrequency,
|
|
): {
|
|
monthsPerPeriod: number;
|
|
generate: (input: {
|
|
rangeStart: ISO8601String;
|
|
rangeEnd: ISO8601String;
|
|
sourceObligation: IRecurringObligationRef;
|
|
duePosition: DuePosition;
|
|
anchorDate: ISO8601String;
|
|
}) => ReturnType<typeof generateMonthlyContractCadenceServicePeriods>;
|
|
} {
|
|
switch (frequency) {
|
|
case 'monthly':
|
|
return { monthsPerPeriod: 1, generate: generateMonthlyContractCadenceServicePeriods };
|
|
case 'quarterly':
|
|
return { monthsPerPeriod: 3, generate: generateQuarterlyContractCadenceServicePeriods };
|
|
case 'semi-annually':
|
|
return { monthsPerPeriod: 6, generate: generateSemiAnnualContractCadenceServicePeriods };
|
|
case 'annually':
|
|
return { monthsPerPeriod: 12, generate: generateAnnualContractCadenceServicePeriods };
|
|
}
|
|
}
|
|
|
|
export function selectContractCadenceRecurringRunTargets(input: {
|
|
clientId: string;
|
|
frequency: ContractCadenceSchedulableFrequency;
|
|
duePosition: DuePosition;
|
|
anchorDate: ISO8601String;
|
|
rangeStart: ISO8601String;
|
|
rangeEnd: ISO8601String;
|
|
sourceObligation: IRecurringObligationRef;
|
|
contractId?: string | null;
|
|
contractLineId?: string | null;
|
|
}): ContractCadenceRecurringRunTarget[] {
|
|
const definition = getContractCadenceFrequencyDefinition(input.frequency);
|
|
const servicePeriodSearchStart = `${toPlainDate(input.rangeStart).subtract({ months: definition.monthsPerPeriod }).toString()}T00:00:00Z` as ISO8601String;
|
|
const queryRange = {
|
|
windowStart: input.rangeStart,
|
|
windowEnd: input.rangeEnd,
|
|
};
|
|
|
|
return definition
|
|
.generate({
|
|
rangeStart: servicePeriodSearchStart,
|
|
rangeEnd: input.rangeEnd,
|
|
sourceObligation: input.sourceObligation,
|
|
duePosition: input.duePosition,
|
|
anchorDate: input.anchorDate,
|
|
})
|
|
.map((servicePeriod) => {
|
|
const invoiceWindow = resolveContractCadenceInvoiceWindowForServicePeriod({
|
|
servicePeriod,
|
|
anchorDate: input.anchorDate,
|
|
monthsPerPeriod: definition.monthsPerPeriod,
|
|
});
|
|
const selectorInput = buildContractCadenceDueSelectionInput({
|
|
clientId: input.clientId,
|
|
contractId: input.contractId ?? null,
|
|
contractLineId: input.contractLineId ?? null,
|
|
windowStart: invoiceWindow.start,
|
|
windowEnd: invoiceWindow.end,
|
|
});
|
|
|
|
return {
|
|
executionWindow: selectorInput.executionWindow,
|
|
selectorInput,
|
|
servicePeriodStart: servicePeriod.start,
|
|
servicePeriodEnd: servicePeriod.end,
|
|
};
|
|
})
|
|
.filter((target) =>
|
|
rangesOverlap(
|
|
{
|
|
windowStart: target.selectorInput.windowStart,
|
|
windowEnd: target.selectorInput.windowEnd,
|
|
},
|
|
queryRange,
|
|
),
|
|
)
|
|
.sort((left, right) => {
|
|
if (left.selectorInput.windowStart !== right.selectorInput.windowStart) {
|
|
return left.selectorInput.windowStart.localeCompare(right.selectorInput.windowStart);
|
|
}
|
|
return left.executionWindow.identityKey.localeCompare(right.executionWindow.identityKey);
|
|
});
|
|
}
|