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

248 lines
7.3 KiB
TypeScript

import type {
IRecurringServicePeriodEditFailure,
IRecurringServicePeriodEditRequest,
IRecurringServicePeriodEditRequestContext,
IRecurringServicePeriodEditResponse,
IRecurringServicePeriodEditSuccess,
IRecurringServicePeriodEditValidationIssue,
IRecurringServicePeriodRecord,
} from '@alga-psa/types';
import { editRecurringServicePeriodBoundaries } from './editRecurringServicePeriodBoundaries';
import { skipOrDeferRecurringServicePeriod } from './skipOrDeferRecurringServicePeriod';
export interface ApplyRecurringServicePeriodEditRequestInput {
record: IRecurringServicePeriodRecord;
request: IRecurringServicePeriodEditRequest;
context: IRecurringServicePeriodEditRequestContext;
siblingRecords?: IRecurringServicePeriodRecord[];
recordIdFactory?: (input: {
scheduleKey: string;
periodKey: string;
revision: number;
}) => string;
}
function failureResponse(input: {
request: IRecurringServicePeriodEditRequest;
validationIssues: IRecurringServicePeriodEditValidationIssue[];
}): IRecurringServicePeriodEditFailure {
return {
ok: false,
operation: input.request.operation,
recordId: input.request.recordId,
validationIssues: input.validationIssues,
};
}
function successResponse(input: {
request: IRecurringServicePeriodEditRequest;
supersededRecord: IRecurringServicePeriodRecord;
editedRecord: IRecurringServicePeriodRecord;
}): IRecurringServicePeriodEditSuccess {
return {
ok: true,
operation: input.request.operation,
recordId: input.request.recordId,
supersededRecord: input.supersededRecord,
editedRecord: input.editedRecord,
provenance: input.editedRecord.provenance,
validationIssues: [],
};
}
function extractContinuityIssues(
message: string,
): IRecurringServicePeriodEditValidationIssue[] {
const matches = Array.from(
message.matchAll(/Edit would create a service-period (gap|overlap) (before|after) [^.]+\./g),
);
return matches.map((match) => {
const relation = `${match[1]}_${match[2]}` as
| 'gap_before'
| 'overlap_before'
| 'gap_after'
| 'overlap_after';
const code = {
gap_before: 'continuity_gap_before',
overlap_before: 'continuity_overlap_before',
gap_after: 'continuity_gap_after',
overlap_after: 'continuity_overlap_after',
}[relation] as IRecurringServicePeriodEditValidationIssue['code'];
return {
code,
field: 'servicePeriod',
message: match[0],
};
});
}
function mapRecurringServicePeriodEditError(
error: unknown,
): IRecurringServicePeriodEditValidationIssue[] {
const message = error instanceof Error ? error.message : String(error);
const continuityIssues = extractContinuityIssues(message);
if (continuityIssues.length > 0) {
return continuityIssues;
}
if (message.startsWith('Record mismatch: request targets')) {
return [{
code: 'record_mismatch',
field: 'recordId',
message,
}];
}
if (message.startsWith('Locked or billed service periods cannot be edited')) {
return [{
code: 'immutable_record',
field: 'operation',
message,
}];
}
if (message === 'Boundary adjustment must change at least one persisted boundary.') {
return [{
code: 'no_changes',
field: 'operation',
message,
}];
}
if (
message.startsWith('servicePeriod must keep half_open semantics.')
|| message.startsWith('servicePeriod must have an end date after its start date.')
) {
return [{
code: 'invalid_service_period_range',
field: 'servicePeriod',
message,
}];
}
if (
message.startsWith('invoiceWindow must keep half_open semantics.')
|| message.startsWith('invoiceWindow must have an end date after its start date.')
) {
return [{
code: 'invalid_invoice_window_range',
field: 'invoiceWindow',
message,
}];
}
if (
message.startsWith('activityWindow must keep half_open semantics.')
|| message.startsWith('activityWindow.start cannot be before the service period start.')
|| message.startsWith('activityWindow.end cannot be after the service period end.')
|| message.startsWith('activityWindow must have an end date after its start date.')
) {
return [{
code: 'invalid_activity_window_range',
field: 'activityWindow',
message,
}];
}
if (message === 'Deferring a service period requires an explicit deferred invoice window.') {
return [{
code: 'missing_deferred_invoice_window',
field: 'deferredInvoiceWindow',
message,
}];
}
if (
message === 'Deferred invoice window must keep half_open semantics.'
|| message === 'Deferred invoice window must have an end date after its start date.'
) {
return [{
code: 'invalid_deferred_invoice_window',
field: 'deferredInvoiceWindow',
message,
}];
}
if (message === 'Defer operation must move the invoice window.') {
return [{
code: 'unchanged_deferred_invoice_window',
field: 'deferredInvoiceWindow',
message,
}];
}
return [{
code: 'unknown_validation_error',
field: 'operation',
message,
}];
}
export function applyRecurringServicePeriodEditRequest(
input: ApplyRecurringServicePeriodEditRequestInput,
): IRecurringServicePeriodEditResponse {
if (input.request.recordId !== input.record.recordId) {
return failureResponse({
request: input.request,
validationIssues: mapRecurringServicePeriodEditError(
new Error(
`Record mismatch: request targets ${input.request.recordId} but loaded record is ${input.record.recordId}.`,
),
),
});
}
try {
switch (input.request.operation) {
case 'boundary_adjustment': {
const result = editRecurringServicePeriodBoundaries({
record: input.record,
editedAt: input.context.editedAt,
sourceRuleVersion: input.context.sourceRuleVersion,
sourceRunKey: input.context.sourceRunKey ?? null,
siblingRecords: input.siblingRecords,
recordIdFactory: input.recordIdFactory,
updatedServicePeriod: input.request.updatedServicePeriod,
updatedInvoiceWindow: input.request.updatedInvoiceWindow,
updatedActivityWindow: input.request.updatedActivityWindow,
});
return successResponse({
request: input.request,
supersededRecord: result.supersededRecord,
editedRecord: result.editedRecord,
});
}
case 'skip':
case 'defer': {
const result = skipOrDeferRecurringServicePeriod({
record: input.record,
operation: input.request.operation,
editedAt: input.context.editedAt,
sourceRuleVersion: input.context.sourceRuleVersion,
sourceRunKey: input.context.sourceRunKey ?? null,
siblingRecords: input.siblingRecords,
recordIdFactory: input.recordIdFactory,
deferredInvoiceWindow: input.request.operation === 'defer'
? input.request.deferredInvoiceWindow
: undefined,
});
return successResponse({
request: input.request,
supersededRecord: result.supersededRecord,
editedRecord: result.editedRecord,
});
}
}
} catch (error) {
return failureResponse({
request: input.request,
validationIssues: mapRecurringServicePeriodEditError(error),
});
}
}