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

366 lines
13 KiB
TypeScript

/**
* Canonical recurring timing architecture
*
* Recurring billing now treats cadence ownership as the source of truth:
* - cadence owner chooses the service-period boundaries
* - service periods are the recurring obligation that runtime logic settles
* - invoice windows group due service periods, but do not redefine them
* - invoice detail rows persist the canonical service-period metadata used at runtime
*
* Rollout default:
* - existing rows continue to resolve to `client` cadence unless they explicitly opt into a later mode
* - client billing schedule previews are therefore invoice-window previews for client-cadence lines, not universal recurring truth
*/
import { Temporal } from '@js-temporal/polyfill';
import type {
CadenceOwner,
DuePosition,
ICadenceBoundaryGenerator,
IRecurringInvoiceCandidateGroup,
IRecurringActivityWindow,
IRecurringCoverage,
IRecurringDateRange,
IRecurringDuePeriodSelection,
IRecurringInvoiceDetailTiming,
IRecurringInvoiceWindow,
IResolvedRecurringSettlement,
RecurringInvoiceSplitReason,
IRecurringScopedDuePeriodSelection,
IRecurringScopedInvoiceCandidateGroup,
IRecurringServicePeriod,
} from '@alga-psa/types';
import { RECURRING_RANGE_SEMANTICS } from '@alga-psa/types';
export const DEFAULT_CADENCE_OWNER: CadenceOwner = 'client';
const toPlainDate = (value: string) => Temporal.PlainDate.from(value.slice(0, 10));
export function assertHalfOpenDateRange(range: Pick<IRecurringDateRange, 'start' | 'end'>): void {
if (Temporal.PlainDate.compare(toPlainDate(range.end), toPlainDate(range.start)) <= 0) {
throw new Error('Recurring timing ranges must use [start, end) semantics with end after start.');
}
}
export function resolveCadenceOwner(owner?: CadenceOwner | null): CadenceOwner {
return owner ?? DEFAULT_CADENCE_OWNER;
}
export function selectCadenceBoundaryGenerator(
generators: Record<CadenceOwner, ICadenceBoundaryGenerator>,
owner?: CadenceOwner | null
): ICadenceBoundaryGenerator {
return generators[resolveCadenceOwner(owner)];
}
export function intersectActivityWindow(
servicePeriod: IRecurringServicePeriod,
activityWindow: IRecurringActivityWindow
): IRecurringServicePeriod | null {
assertHalfOpenDateRange(servicePeriod);
const nextStart = activityWindow.start && activityWindow.start > servicePeriod.start ? activityWindow.start : servicePeriod.start;
const nextEnd = activityWindow.end && activityWindow.end < servicePeriod.end ? activityWindow.end : servicePeriod.end;
if (Temporal.PlainDate.compare(toPlainDate(nextEnd), toPlainDate(nextStart)) <= 0) {
return null;
}
return {
...servicePeriod,
start: nextStart,
end: nextEnd,
};
}
export function calculateServicePeriodCoverage(
servicePeriod: IRecurringServicePeriod,
coveredPeriod: Pick<IRecurringDateRange, 'start' | 'end'>
): IRecurringCoverage {
assertHalfOpenDateRange(servicePeriod);
assertHalfOpenDateRange(coveredPeriod);
const totalDays = toPlainDate(servicePeriod.end).since(toPlainDate(servicePeriod.start)).days;
const coveredDays = toPlainDate(coveredPeriod.end).since(toPlainDate(coveredPeriod.start)).days;
if (coveredDays > totalDays) {
throw new Error('Covered period cannot exceed its parent service period.');
}
return {
coveredPeriod: {
start: coveredPeriod.start,
end: coveredPeriod.end,
semantics: RECURRING_RANGE_SEMANTICS,
},
coveredDays,
totalDays,
coverageRatio: coveredDays / totalDays,
};
}
export function mapServicePeriodToInvoiceWindow(
servicePeriod: IRecurringServicePeriod,
options: {
duePosition: DuePosition;
currentInvoiceWindow: IRecurringInvoiceWindow;
nextInvoiceWindow?: IRecurringInvoiceWindow;
}
): { servicePeriod: IRecurringServicePeriod; invoiceWindow: IRecurringInvoiceWindow } {
return {
servicePeriod,
invoiceWindow: options.duePosition === 'arrears'
? (options.nextInvoiceWindow ?? options.currentInvoiceWindow)
: options.currentInvoiceWindow,
};
}
function rangesOverlap(
left: Pick<IRecurringDateRange, 'start' | 'end'>,
right: Pick<IRecurringDateRange, 'start' | 'end'>,
): boolean {
return (
Temporal.PlainDate.compare(toPlainDate(left.start), toPlainDate(right.end)) < 0 &&
Temporal.PlainDate.compare(toPlainDate(right.start), toPlainDate(left.end)) < 0
);
}
export function selectDueServicePeriodsForInvoiceWindow(
servicePeriods: IRecurringServicePeriod[],
options: {
duePosition: DuePosition;
invoiceWindow: IRecurringInvoiceWindow;
},
): IRecurringDuePeriodSelection[] {
return servicePeriods
.filter((servicePeriod) => servicePeriod.duePosition === options.duePosition)
.filter((servicePeriod) => {
if (options.duePosition === 'advance') {
return rangesOverlap(servicePeriod, options.invoiceWindow);
}
return Temporal.PlainDate.compare(
toPlainDate(servicePeriod.end),
toPlainDate(options.invoiceWindow.start),
) === 0;
})
.map((servicePeriod) => ({
servicePeriod,
invoiceWindow: options.invoiceWindow,
}));
}
export function resolveRecurringSettlementsForInvoiceWindow(input: {
servicePeriods: IRecurringServicePeriod[];
invoiceWindow: IRecurringInvoiceWindow;
activityWindow: IRecurringActivityWindow;
duePosition: DuePosition;
}): IResolvedRecurringSettlement[] {
return selectDueServicePeriodsForInvoiceWindow(input.servicePeriods, {
duePosition: input.duePosition,
invoiceWindow: input.invoiceWindow,
}).flatMap(({ servicePeriod, invoiceWindow }) => {
const coveredServicePeriod = intersectActivityWindow(servicePeriod, input.activityWindow);
if (!coveredServicePeriod) {
return [];
}
const coverage = calculateServicePeriodCoverage(servicePeriod, coveredServicePeriod);
if (coverage.coveredDays <= 0) {
return [];
}
return [
{
servicePeriod,
coveredServicePeriod,
invoiceWindow,
coverage,
},
];
});
}
export function groupDueServicePeriodsByInvoiceWindow(
dueSelections: IRecurringDuePeriodSelection[],
): IRecurringInvoiceCandidateGroup[] {
const grouped = new Map<string, IRecurringInvoiceCandidateGroup>();
for (const selection of dueSelections) {
const key = `${selection.invoiceWindow.start}:${selection.invoiceWindow.end}`;
const existing = grouped.get(key);
if (existing) {
existing.dueSelections.push(selection);
if (!existing.cadenceOwners.includes(selection.servicePeriod.cadenceOwner)) {
existing.cadenceOwners.push(selection.servicePeriod.cadenceOwner);
existing.cadenceOwners.sort();
}
continue;
}
grouped.set(key, {
groupKey: key,
windowStart: selection.invoiceWindow.start,
windowEnd: selection.invoiceWindow.end,
semantics: selection.invoiceWindow.semantics,
cadenceOwners: [selection.servicePeriod.cadenceOwner],
dueSelections: [selection],
});
}
return Array.from(grouped.values())
.map((group) => ({
...group,
dueSelections: group.dueSelections.sort((left, right) => {
if (left.servicePeriod.start !== right.servicePeriod.start) {
return left.servicePeriod.start.localeCompare(right.servicePeriod.start);
}
return left.servicePeriod.sourceObligation.obligationId.localeCompare(
right.servicePeriod.sourceObligation.obligationId,
);
}),
}))
.sort((left, right) => {
if (left.windowStart !== right.windowStart) {
return left.windowStart.localeCompare(right.windowStart);
}
return left.windowEnd.localeCompare(right.windowEnd);
});
}
export function groupDueServicePeriodsByInvoiceWindowAndContract(
dueSelections: IRecurringScopedDuePeriodSelection[],
): IRecurringScopedInvoiceCandidateGroup[] {
return groupDueServicePeriodsForInvoiceCandidates(dueSelections);
}
export function groupDueServicePeriodsForInvoiceCandidates(
dueSelections: IRecurringScopedDuePeriodSelection[],
): IRecurringScopedInvoiceCandidateGroup[] {
const grouped = new Map<string, IRecurringScopedInvoiceCandidateGroup>();
const windowScopeSummary = new Map<
string,
{
clientIds: Set<string>;
contractIds: Set<string>;
purchaseOrderScopeKeys: Set<string>;
financialScopeKeys: Set<string>;
}
>();
for (const selection of dueSelections) {
const clientScope = selection.clientId ?? '__no_client_scope__';
const contractScope = selection.clientContractId ?? '__no_contract_scope__';
const purchaseOrderScope = selection.purchaseOrderScopeKey ?? '__no_po_scope__';
const financialScope = [
selection.currencyCode ?? '__no_currency__',
selection.taxSource ?? '__no_tax_source__',
selection.exportShapeKey ?? '__no_export_shape__',
].join(':');
const windowKey = `${clientScope}:${selection.invoiceWindow.start}:${selection.invoiceWindow.end}`;
const key = windowKey;
const existing = grouped.get(key);
const windowSummary = windowScopeSummary.get(windowKey) ?? {
clientIds: new Set<string>(),
contractIds: new Set<string>(),
purchaseOrderScopeKeys: new Set<string>(),
financialScopeKeys: new Set<string>(),
};
windowSummary.clientIds.add(clientScope);
windowSummary.contractIds.add(contractScope);
windowSummary.purchaseOrderScopeKeys.add(purchaseOrderScope);
windowSummary.financialScopeKeys.add(financialScope);
windowScopeSummary.set(windowKey, windowSummary);
if (existing) {
existing.dueSelections.push(selection);
if (!existing.cadenceOwners.includes(selection.servicePeriod.cadenceOwner)) {
existing.cadenceOwners.push(selection.servicePeriod.cadenceOwner);
existing.cadenceOwners.sort();
}
continue;
}
grouped.set(key, {
groupKey: key,
windowStart: selection.invoiceWindow.start,
windowEnd: selection.invoiceWindow.end,
semantics: selection.invoiceWindow.semantics,
cadenceOwners: [selection.servicePeriod.cadenceOwner],
clientContractId: selection.clientContractId ?? null,
purchaseOrderScopeKey: selection.purchaseOrderScopeKey ?? null,
currencyCode: selection.currencyCode ?? null,
taxSource: selection.taxSource ?? null,
exportShapeKey: selection.exportShapeKey ?? null,
splitReasons: [],
dueSelections: [selection],
});
}
return Array.from(grouped.values())
.map((group) => ({
...group,
dueSelections: group.dueSelections.sort((left, right) => {
if (left.servicePeriod.start !== right.servicePeriod.start) {
return left.servicePeriod.start.localeCompare(right.servicePeriod.start);
}
return left.servicePeriod.sourceObligation.obligationId.localeCompare(
right.servicePeriod.sourceObligation.obligationId,
);
}),
splitReasons: (() => {
const windowSummary = windowScopeSummary.get(
`${group.dueSelections[0]?.clientId ?? '__no_client_scope__'}:${group.windowStart}:${group.windowEnd}`,
);
const splitReasons: RecurringInvoiceSplitReason[] = [];
if ((windowSummary?.contractIds.size ?? 0) > 1) {
splitReasons.push('single_contract');
}
if ((windowSummary?.purchaseOrderScopeKeys.size ?? 0) > 1) {
splitReasons.push('purchase_order_scope');
}
if ((windowSummary?.financialScopeKeys.size ?? 0) > 1) {
splitReasons.push('financial_constraint');
}
return splitReasons;
})(),
}))
.sort((left, right) => {
if (left.windowStart !== right.windowStart) {
return left.windowStart.localeCompare(right.windowStart);
}
const leftClientId = left.dueSelections[0]?.clientId ?? '';
const rightClientId = right.dueSelections[0]?.clientId ?? '';
if (leftClientId !== rightClientId) {
return leftClientId.localeCompare(rightClientId);
}
if ((left.clientContractId ?? '') !== (right.clientContractId ?? '')) {
return (left.clientContractId ?? '').localeCompare(right.clientContractId ?? '');
}
if ((left.purchaseOrderScopeKey ?? '') !== (right.purchaseOrderScopeKey ?? '')) {
return (left.purchaseOrderScopeKey ?? '').localeCompare(right.purchaseOrderScopeKey ?? '');
}
return left.windowEnd.localeCompare(right.windowEnd);
});
}
export function buildRecurringInvoiceDetailTiming(input: {
servicePeriod: IRecurringServicePeriod;
invoiceWindow: IRecurringInvoiceWindow;
}): IRecurringInvoiceDetailTiming {
return {
cadenceOwner: input.servicePeriod.cadenceOwner,
duePosition: input.servicePeriod.duePosition,
sourceObligation: input.servicePeriod.sourceObligation,
servicePeriodStart: input.servicePeriod.start,
servicePeriodEnd: input.servicePeriod.end,
invoiceWindowStart: input.invoiceWindow.start,
invoiceWindowEnd: input.invoiceWindow.end,
};
}