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

294 lines
8.9 KiB
TypeScript

import { Knex } from 'knex';
import { Temporal } from '@js-temporal/polyfill';
import type { IClientContractLineCycle, IClient, ISO8601String, BillingCycleType } from '@alga-psa/types';
import { parseISO } from 'date-fns';
import {
ensureUtcMidnightIsoDate,
getAnchorDefaultsForCycle,
getBillingPeriodForDate,
getNextBillingBoundaryAfter,
normalizeAnchorSettingsForCycle,
type NormalizedBillingCycleAnchorSettings
} from './billingCycleAnchors';
export type BillingCycleCreationResult = {
success: boolean;
error?: 'duplicate' | 'invalid_date' | 'db_error';
message?: string;
suggestedDate?: ISO8601String;
};
function getNextCycleDate(
currentDate: ISO8601String,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): { effectiveDate: ISO8601String; periodStart: ISO8601String; periodEnd: ISO8601String } {
const effectiveDate = ensureUtcMidnightIsoDate(currentDate);
const periodStart = effectiveDate;
const periodEnd = getNextBillingBoundaryAfter(effectiveDate, billingCycle, anchor);
return { effectiveDate, periodStart, periodEnd };
}
function getStartOfCurrentCycle(
date: ISO8601String,
billingCycle: BillingCycleType,
anchor: NormalizedBillingCycleAnchorSettings
): { effectiveDate: ISO8601String; periodStart: ISO8601String; periodEnd: ISO8601String } {
const referenceDate = ensureUtcMidnightIsoDate(date);
const period = getBillingPeriodForDate(referenceDate, billingCycle, anchor);
return {
effectiveDate: period.periodStartDate,
periodStart: period.periodStartDate,
periodEnd: period.periodEndDate
};
}
async function createBillingCycle(
knex: Knex,
cycle: Partial<IClientContractLineCycle> & {
effective_date: ISO8601String;
period_start_date: ISO8601String;
period_end_date: ISO8601String;
}
): Promise<BillingCycleCreationResult> {
const effectiveDate = ensureUtcMidnightIsoDate(cycle.effective_date);
const periodStart = ensureUtcMidnightIsoDate(cycle.period_start_date);
const periodEnd = ensureUtcMidnightIsoDate(cycle.period_end_date);
if (
Temporal.PlainDate.compare(
Temporal.PlainDate.from(periodEnd.slice(0, 10)),
Temporal.PlainDate.from(periodStart.slice(0, 10))
) <= 0
) {
return {
success: false,
error: 'invalid_date',
message: 'Billing period end must be after the start date.'
};
}
const overlap = await knex('client_billing_cycles')
.where({
client_id: cycle.client_id,
tenant: cycle.tenant,
is_active: true
})
.whereNotNull('period_end_date')
.andWhere('period_start_date', '<', periodEnd)
.andWhere('period_end_date', '>', periodStart)
.first()
.select('period_end_date', 'period_start_date');
if (overlap) {
return {
success: false,
error: 'duplicate',
message: 'A billing period overlapping this date range already exists.'
};
}
const existingCycle = await knex('client_billing_cycles')
.where({
client_id: cycle.client_id,
tenant: cycle.tenant,
is_active: true,
effective_date: effectiveDate
})
.first()
.select('period_start_date', 'period_end_date');
if (existingCycle) {
const nextStart = existingCycle.period_end_date ? normalizeDbIsoUtcMidnight(existingCycle.period_end_date) : null;
return {
success: false,
error: 'duplicate',
message: 'A billing period for this start date already exists.',
suggestedDate: nextStart ?? undefined
};
}
const fullCycle: Partial<IClientContractLineCycle> = {
...cycle,
effective_date: effectiveDate,
period_start_date: periodStart,
period_end_date: periodEnd
};
try {
await knex('client_billing_cycles').insert(fullCycle);
return { success: true };
} catch (error: unknown) {
if (
error instanceof Error &&
'constraint' in error &&
(error as any).constraint === 'client_billing_cycles_client_id_effective_date_unique'
) {
const nextDate = new Date(cycle.effective_date);
nextDate.setDate(nextDate.getDate() + 1);
return {
success: false,
error: 'duplicate',
message: 'A billing period for this date already exists. Please select a different date.',
suggestedDate: (nextDate.toISOString().split('T')[0] + 'T00:00:00Z') as ISO8601String
};
}
throw error;
}
}
function isDateObject(val: unknown): val is Date {
return Object.prototype.toString.call(val) === '[object Date]';
}
function normalizeDbIsoUtcMidnight(value: unknown): ISO8601String {
if (typeof value === 'string') {
return ensureUtcMidnightIsoDate(value);
}
if (isDateObject(value)) {
return ensureUtcMidnightIsoDate(value.toISOString());
}
return ensureUtcMidnightIsoDate(String(value));
}
export async function createClientContractLineCycles(
knex: Knex,
client: IClient,
options: { manual?: boolean; effectiveDate?: string } = {}
): Promise<BillingCycleCreationResult> {
const billingCycle = client.billing_cycle as BillingCycleType;
const now = ensureUtcMidnightIsoDate(new Date().toISOString().split('T')[0] + 'T00:00:00Z');
const anchorSettings = await loadClientAnchorSettings(knex, client, billingCycle);
const lastCycle = (await knex('client_billing_cycles')
.where({
client_id: client.client_id,
tenant: client.tenant,
is_active: true
})
.orderBy('period_start_date', 'desc')
.first()
.select('period_start_date', 'period_end_date')) as IClientContractLineCycle | undefined;
const referenceDate = options.effectiveDate ? ensureUtcMidnightIsoDate(options.effectiveDate) : now;
if (!lastCycle) {
const initial = getStartOfCurrentCycle(referenceDate, billingCycle, anchorSettings);
const initialResult = await createBillingCycle(knex, {
client_id: client.client_id,
billing_cycle: billingCycle,
effective_date: initial.effectiveDate,
period_start_date: initial.periodStart,
period_end_date: initial.periodEnd,
tenant: client.tenant
});
if (!initialResult.success) {
return initialResult;
}
if (options.manual) {
return { success: true };
}
let start = initial.periodEnd;
let iterations = 0;
const MAX_ITERATIONS = 200;
while (parseISO(start) <= parseISO(now) && iterations < MAX_ITERATIONS) {
const end = getNextBillingBoundaryAfter(start, billingCycle, anchorSettings);
const result = await createBillingCycle(knex, {
client_id: client.client_id,
billing_cycle: billingCycle,
effective_date: start,
period_start_date: start,
period_end_date: end,
tenant: client.tenant
});
if (!result.success) {
return result;
}
iterations++;
start = end;
}
return { success: true };
}
if (!lastCycle.period_end_date) {
return {
success: false,
error: 'db_error',
message: 'Client has an active billing cycle without a period end date.'
};
}
let start = normalizeDbIsoUtcMidnight(lastCycle.period_end_date);
if (options.manual) {
const end = getNextBillingBoundaryAfter(start, billingCycle, anchorSettings);
return await createBillingCycle(knex, {
client_id: client.client_id,
billing_cycle: billingCycle,
effective_date: start,
period_start_date: start,
period_end_date: end,
tenant: client.tenant
});
}
let iterations = 0;
const MAX_ITERATIONS = 200;
while (parseISO(start) <= parseISO(now) && iterations < MAX_ITERATIONS) {
const end = getNextBillingBoundaryAfter(start, billingCycle, anchorSettings);
const result = await createBillingCycle(knex, {
client_id: client.client_id,
billing_cycle: billingCycle,
effective_date: start,
period_start_date: start,
period_end_date: end,
tenant: client.tenant
});
if (!result.success) {
return result;
}
iterations++;
start = end;
}
return { success: true };
}
async function loadClientAnchorSettings(
knex: Knex,
client: IClient,
billingCycle: BillingCycleType
): Promise<NormalizedBillingCycleAnchorSettings> {
const defaults = getAnchorDefaultsForCycle(billingCycle);
const settings = await knex('client_billing_settings')
.where({ tenant: client.tenant, client_id: client.client_id })
.first()
.select(
'billing_cycle_anchor_day_of_month',
'billing_cycle_anchor_month_of_year',
'billing_cycle_anchor_day_of_week',
'billing_cycle_anchor_reference_date'
);
return normalizeAnchorSettingsForCycle(billingCycle, {
dayOfMonth: settings?.billing_cycle_anchor_day_of_month ?? defaults.dayOfMonth,
monthOfYear: settings?.billing_cycle_anchor_month_of_year ?? defaults.monthOfYear,
dayOfWeek: settings?.billing_cycle_anchor_day_of_week ?? defaults.dayOfWeek,
referenceDate: settings?.billing_cycle_anchor_reference_date
? normalizeDbIsoUtcMidnight(settings.billing_cycle_anchor_reference_date)
: defaults.referenceDate
});
}
export { getNextCycleDate, getStartOfCurrentCycle };