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

200 lines
6.6 KiB
TypeScript

import type {
IRecurringServicePeriodRecord,
ISO8601String,
RegeneratedRecurringServicePeriodReasonCode,
} from '@alga-psa/types';
import { regenerateRecurringServicePeriods } from './regenerateRecurringServicePeriods';
export interface BackfillRecurringServicePeriodsInput {
candidateRecords: IRecurringServicePeriodRecord[];
backfilledAt: ISO8601String;
sourceRuleVersion: string;
sourceRunKey: string;
existingRecords?: IRecurringServicePeriodRecord[];
legacyBilledThroughEnd?: ISO8601String | null;
regenerationReasonCode?: RegeneratedRecurringServicePeriodReasonCode;
recordIdFactory?: (input: {
scheduleKey: string;
periodKey: string;
revision: number;
}) => string;
}
export interface IRecurringServicePeriodBackfillPlan {
historicalBoundaryEnd: ISO8601String | null;
skippedHistoricalCandidates: IRecurringServicePeriodRecord[];
retainedRecords: IRecurringServicePeriodRecord[];
backfilledRecords: IRecurringServicePeriodRecord[];
realignedRecords: IRecurringServicePeriodRecord[];
supersededRecords: IRecurringServicePeriodRecord[];
activeRecords: IRecurringServicePeriodRecord[];
}
function toDateOnly(value: ISO8601String): ISO8601String {
return `${value.slice(0, 10)}` as ISO8601String;
}
function compareDateOnly(left: ISO8601String, right: ISO8601String) {
return toDateOnly(left).localeCompare(toDateOnly(right));
}
function sortRecords(records: IRecurringServicePeriodRecord[]) {
return [...records].sort((left, right) => {
if (left.servicePeriod.start !== right.servicePeriod.start) {
return left.servicePeriod.start.localeCompare(right.servicePeriod.start);
}
if (left.servicePeriod.end !== right.servicePeriod.end) {
return left.servicePeriod.end.localeCompare(right.servicePeriod.end);
}
if (left.sourceObligation.obligationId !== right.sourceObligation.obligationId) {
return left.sourceObligation.obligationId.localeCompare(right.sourceObligation.obligationId);
}
return left.revision - right.revision;
});
}
function resolveHistoricalBoundaryEnd(
records: IRecurringServicePeriodRecord[],
legacyBilledThroughEnd: ISO8601String | null | undefined,
) {
let boundary = legacyBilledThroughEnd ? toDateOnly(legacyBilledThroughEnd) : null;
for (const record of records) {
const isBilledHistory = record.lifecycleState === 'billed' || record.invoiceLinkage != null;
if (!isBilledHistory) {
continue;
}
const recordBoundary = toDateOnly(record.servicePeriod.end);
if (!boundary || compareDateOnly(recordBoundary, boundary) > 0) {
boundary = recordBoundary;
}
}
return boundary;
}
function normalizeBackfillCandidate(
candidate: IRecurringServicePeriodRecord,
input: Pick<BackfillRecurringServicePeriodsInput, 'backfilledAt' | 'sourceRuleVersion' | 'sourceRunKey'>,
): IRecurringServicePeriodRecord {
return {
...candidate,
lifecycleState: 'generated',
provenance: {
kind: 'generated',
reasonCode: 'backfill_materialization',
sourceRuleVersion: input.sourceRuleVersion,
sourceRunKey: input.sourceRunKey,
},
invoiceLinkage: null,
createdAt: input.backfilledAt,
updatedAt: input.backfilledAt,
};
}
function overlapsHistoricalBoundary(
record: IRecurringServicePeriodRecord,
historicalBoundaryEnd: ISO8601String,
) {
return (
compareDateOnly(record.servicePeriod.start, historicalBoundaryEnd) < 0
&& compareDateOnly(record.servicePeriod.end, historicalBoundaryEnd) > 0
);
}
function buildRecordIdSet(records: IRecurringServicePeriodRecord[]) {
return new Set(records.map((record) => record.recordId));
}
export function backfillRecurringServicePeriods(
input: BackfillRecurringServicePeriodsInput,
): IRecurringServicePeriodBackfillPlan {
const existingRecords = sortRecords(
(input.existingRecords ?? []).filter(
(record) => record.lifecycleState !== 'archived' && record.lifecycleState !== 'superseded',
),
);
const historicalBoundaryEnd = resolveHistoricalBoundaryEnd(
existingRecords,
input.legacyBilledThroughEnd,
);
const normalizedCandidates = sortRecords(
input.candidateRecords.map((candidate) =>
normalizeBackfillCandidate(candidate, {
backfilledAt: input.backfilledAt,
sourceRuleVersion: input.sourceRuleVersion,
sourceRunKey: input.sourceRunKey,
})),
);
const skippedHistoricalCandidates: IRecurringServicePeriodRecord[] = [];
const futureCandidates: IRecurringServicePeriodRecord[] = [];
for (const candidate of normalizedCandidates) {
if (!historicalBoundaryEnd) {
futureCandidates.push(candidate);
continue;
}
if (overlapsHistoricalBoundary(candidate, historicalBoundaryEnd)) {
throw new Error(
`Backfill candidate ${candidate.periodKey} overlaps billed-history boundary ${historicalBoundaryEnd}.`,
);
}
if (compareDateOnly(candidate.servicePeriod.end, historicalBoundaryEnd) <= 0) {
skippedHistoricalCandidates.push(candidate);
continue;
}
futureCandidates.push(candidate);
}
const retainedHistoricalRecords = sortRecords(
historicalBoundaryEnd
? existingRecords.filter(
(record) => compareDateOnly(record.servicePeriod.end, historicalBoundaryEnd) <= 0,
)
: [],
);
const futureScopeExistingRecords = historicalBoundaryEnd
? existingRecords.filter(
(record) => compareDateOnly(record.servicePeriod.end, historicalBoundaryEnd) > 0,
)
: existingRecords;
const regenerationPlan = regenerateRecurringServicePeriods({
existingRecords: futureScopeExistingRecords,
candidateRecords: futureCandidates,
regeneratedAt: input.backfilledAt,
sourceRuleVersion: input.sourceRuleVersion,
sourceRunKey: input.sourceRunKey,
regenerationReasonCode: input.regenerationReasonCode ?? 'backfill_realignment',
recordIdFactory: input.recordIdFactory,
});
const createdRecordIds = buildRecordIdSet([
...regenerationPlan.newRecords,
...regenerationPlan.regeneratedRecords,
]);
const retainedRecords = sortRecords([
...retainedHistoricalRecords,
...regenerationPlan.activeRecords.filter((record) => !createdRecordIds.has(record.recordId)),
]);
const activeRecords = sortRecords([
...retainedHistoricalRecords,
...regenerationPlan.activeRecords,
]);
return {
historicalBoundaryEnd,
skippedHistoricalCandidates,
retainedRecords,
backfilledRecords: sortRecords(regenerationPlan.newRecords),
realignedRecords: sortRecords(regenerationPlan.regeneratedRecords),
supersededRecords: sortRecords(regenerationPlan.supersededRecords),
activeRecords,
};
}