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

163 lines
5.8 KiB
TypeScript

import type {
CadenceOwner,
IRecurringServicePeriodRegenerationDecision,
IRecurringServicePeriodRegenerationTriggerInput,
} from '@alga-psa/types';
const CONTRACT_LINE_REGENERATION_FIELDS = new Set([
'billing_frequency',
'billing_timing',
'start_date',
'end_date',
'service_start_date',
'service_end_date',
'cadence_owner',
]);
const CONTRACT_ASSIGNMENT_REGENERATION_FIELDS = new Set([
'assignment_start_date',
'assignment_end_date',
'service_start_date',
'service_end_date',
'start_date',
'end_date',
]);
const BILLING_SCHEDULE_REGENERATION_FIELDS = new Set([
'billing_frequency',
'billing_day_of_month',
'billing_month',
'billing_anchor_date',
'billing_cycle_anchor',
'next_billing_date',
]);
function uniqueCadenceOwners(owners: Array<CadenceOwner | undefined>) {
return Array.from(new Set(owners.filter((owner): owner is CadenceOwner => Boolean(owner))));
}
function collectMatchingFields(changedFields: string[], triggerFields: Set<string>) {
return changedFields.filter((field) => triggerFields.has(field));
}
export function resolveRecurringServicePeriodRegenerationDecision(
input: IRecurringServicePeriodRegenerationTriggerInput,
): IRecurringServicePeriodRegenerationDecision {
const changedFields = [...new Set(input.changedFields)];
const cadenceOwnerChanged = Boolean(
input.cadenceOwnerBefore
&& input.cadenceOwnerAfter
&& input.cadenceOwnerBefore !== input.cadenceOwnerAfter,
);
if (input.source === 'contract_line_edit' && cadenceOwnerChanged) {
return {
shouldRegenerate: true,
triggerKind: 'cadence_owner_change',
regenerationReasonCode: 'cadence_owner_changed',
scope: 'replace_schedule_identity',
changedFields,
affectedCadenceOwners: uniqueCadenceOwners([
input.cadenceOwnerBefore,
input.cadenceOwnerAfter,
]),
preserveEditedRows: true,
preserveBilledHistory: true,
reason: 'Changing cadence owner replaces the future schedule identity instead of mutating billed history in place.',
notes: [
'Supersede untouched future rows on the prior schedule key.',
'Materialize future rows on the new cadence-owner schedule key.',
'Preserve edited, locked, and billed rows under the existing override and immutability rules.',
],
};
}
if (input.source === 'contract_line_edit') {
const triggerFields = collectMatchingFields(changedFields, CONTRACT_LINE_REGENERATION_FIELDS);
if (triggerFields.length > 0) {
return {
shouldRegenerate: true,
triggerKind: 'contract_line_edit',
regenerationReasonCode: 'source_rule_changed',
scope: 'obligation_schedule_only',
changedFields,
affectedCadenceOwners: uniqueCadenceOwners([
input.cadenceOwnerAfter,
input.cadenceOwnerBefore,
]),
preserveEditedRows: true,
preserveBilledHistory: true,
reason: `Contract-line recurrence fields changed (${triggerFields.join(', ')}), so future service-period and invoice-window candidates must be rebuilt for that obligation.`,
notes: [
'Only recurrence-shaping fields trigger service-period regeneration.',
'Pure pricing changes still affect billing amounts later, but do not rebuild the persisted schedule.',
],
};
}
}
if (input.source === 'contract_assignment_edit') {
const triggerFields = collectMatchingFields(changedFields, CONTRACT_ASSIGNMENT_REGENERATION_FIELDS);
if (triggerFields.length > 0) {
return {
shouldRegenerate: true,
triggerKind: 'contract_assignment_edit',
regenerationReasonCode: 'activity_window_changed',
scope: 'obligation_schedule_only',
changedFields,
affectedCadenceOwners: uniqueCadenceOwners([
input.cadenceOwnerAfter,
input.cadenceOwnerBefore,
]),
preserveEditedRows: true,
preserveBilledHistory: true,
reason: `Assignment activity-window fields changed (${triggerFields.join(', ')}), so future coverage clipping must be regenerated for that obligation.`,
notes: [
'This changes activity-window intersection and may alter partial first or final coverage.',
'Billed history remains immutable and edited future overrides stay preserved.',
],
};
}
}
if (input.source === 'billing_schedule_edit') {
const triggerFields = collectMatchingFields(changedFields, BILLING_SCHEDULE_REGENERATION_FIELDS);
if (triggerFields.length > 0) {
return {
shouldRegenerate: true,
triggerKind: 'billing_schedule_change',
regenerationReasonCode: 'billing_schedule_changed',
scope: 'client_cadence_dependents',
changedFields,
affectedCadenceOwners: ['client'],
preserveEditedRows: true,
preserveBilledHistory: true,
reason: `Client billing-schedule fields changed (${triggerFields.join(', ')}), so client-cadence obligations that depend on that schedule need regenerated future invoice windows.`,
notes: [
'Only client-cadence obligations are in scope for a billing-schedule trigger.',
'Contract-cadence schedules keep their own anniversary-owned window identity.',
],
};
}
}
return {
shouldRegenerate: false,
triggerKind: null,
regenerationReasonCode: null,
scope: null,
changedFields,
affectedCadenceOwners: uniqueCadenceOwners([
input.cadenceOwnerAfter,
input.cadenceOwnerBefore,
]),
preserveEditedRows: true,
preserveBilledHistory: true,
reason: 'No service-period or invoice-window shaping fields changed, so regeneration is not required.',
notes: [
'Pricing-only changes do not rebuild persisted future periods.',
'Override-preservation and immutability rules still apply if a later regeneration is triggered by a different source change.',
],
};
}