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
200 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|