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
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
import type { Knex } from 'knex';
|
|
import type { BillingCycleType, ISO8601String, IClient } from '@alga-psa/types';
|
|
import {
|
|
ensureUtcMidnightIsoDate,
|
|
getAnchorDefaultsForCycle,
|
|
getBillingPeriodForDate,
|
|
getNextBillingBoundaryAfter,
|
|
normalizeAnchorSettingsForCycle,
|
|
validateAnchorSettingsForCycle,
|
|
type BillingCycleAnchorSettingsInput,
|
|
type NormalizedBillingCycleAnchorSettings
|
|
} from './billingCycleAnchors';
|
|
import { createClientContractLineCycles, type BillingCycleCreationResult } from './createBillingCycles';
|
|
import {
|
|
CLIENT_CADENCE_SCHEDULE_CONTEXT,
|
|
type ClientCadenceScheduleContext,
|
|
} from './clientCadenceScheduleContext';
|
|
import { ensureClientBillingSettingsRow } from './billingSettings';
|
|
|
|
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 type ClientBillingCycleAnchorConfig = {
|
|
billingCycle: BillingCycleType;
|
|
anchor: NormalizedBillingCycleAnchorSettings;
|
|
cadenceContext: ClientCadenceScheduleContext;
|
|
};
|
|
|
|
export async function getClientBillingCycleAnchor(
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
clientId: string
|
|
): Promise<ClientBillingCycleAnchorConfig> {
|
|
const client = await knexOrTrx('clients').where({ tenant, client_id: clientId }).first().select('billing_cycle');
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
const settings = await knexOrTrx('client_billing_settings')
|
|
.where({ tenant, client_id: clientId })
|
|
.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'
|
|
);
|
|
|
|
const billingCycle = ((client as any).billing_cycle ?? 'monthly') as BillingCycleType;
|
|
const normalized = normalizeAnchorSettingsForCycle(billingCycle, {
|
|
dayOfMonth: settings?.billing_cycle_anchor_day_of_month ?? null,
|
|
monthOfYear: settings?.billing_cycle_anchor_month_of_year ?? null,
|
|
dayOfWeek: settings?.billing_cycle_anchor_day_of_week ?? null,
|
|
referenceDate: settings?.billing_cycle_anchor_reference_date
|
|
? normalizeDbIsoUtcMidnight(settings.billing_cycle_anchor_reference_date)
|
|
: null
|
|
});
|
|
|
|
return {
|
|
billingCycle,
|
|
anchor: normalized,
|
|
cadenceContext: CLIENT_CADENCE_SCHEDULE_CONTEXT,
|
|
};
|
|
}
|
|
|
|
export type UpdateClientBillingScheduleInput = {
|
|
clientId: string;
|
|
billingCycle: BillingCycleType;
|
|
anchor: BillingCycleAnchorSettingsInput;
|
|
billingHistoryStartDate?: ISO8601String | null;
|
|
};
|
|
|
|
export async function updateClientBillingSchedule(
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
input: UpdateClientBillingScheduleInput
|
|
): Promise<void> {
|
|
validateAnchorSettingsForCycle(input.billingCycle, input.anchor);
|
|
const normalized = normalizeAnchorSettingsForCycle(input.billingCycle, input.anchor);
|
|
|
|
if (isKnexTransaction(knexOrTrx)) {
|
|
await updateInTransaction(knexOrTrx, tenant, input, normalized);
|
|
return;
|
|
}
|
|
|
|
await (knexOrTrx as Knex).transaction(async (trx) => {
|
|
await updateInTransaction(trx, tenant, input, normalized);
|
|
});
|
|
}
|
|
|
|
async function updateInTransaction(
|
|
trx: Knex.Transaction,
|
|
tenant: string,
|
|
input: UpdateClientBillingScheduleInput,
|
|
normalized: NormalizedBillingCycleAnchorSettings
|
|
): Promise<void> {
|
|
const client = await trx('clients').where({ tenant, client_id: input.clientId }).first().select('client_id', 'billing_cycle');
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
|
|
if (((client as any).billing_cycle ?? 'monthly') !== input.billingCycle) {
|
|
await trx('clients')
|
|
.where({ tenant, client_id: input.clientId })
|
|
.update({ billing_cycle: input.billingCycle, updated_at: trx.fn.now() });
|
|
}
|
|
|
|
await ensureClientBillingSettingsRow(trx, { tenant, clientId: input.clientId });
|
|
|
|
await trx('client_billing_settings')
|
|
.where({ tenant, client_id: input.clientId })
|
|
.update({
|
|
billing_cycle_anchor_day_of_month: normalized.dayOfMonth,
|
|
billing_cycle_anchor_month_of_year: normalized.monthOfYear,
|
|
billing_cycle_anchor_day_of_week: normalized.dayOfWeek,
|
|
billing_cycle_anchor_reference_date: normalized.referenceDate,
|
|
updated_at: trx.fn.now()
|
|
});
|
|
|
|
if (input.billingHistoryStartDate) {
|
|
await regenerateHistoricalClientBillingCyclesFromBootstrap(trx, {
|
|
tenant,
|
|
clientId: input.clientId,
|
|
billingCycle: input.billingCycle,
|
|
anchor: normalized,
|
|
billingHistoryStartDate: input.billingHistoryStartDate,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const lastInvoiced = await trx('client_billing_cycles as cbc')
|
|
.join('invoices as i', function () {
|
|
this.on('i.billing_cycle_id', '=', 'cbc.billing_cycle_id').andOn('i.tenant', '=', 'cbc.tenant');
|
|
})
|
|
.where('cbc.tenant', tenant)
|
|
.andWhere('cbc.client_id', input.clientId)
|
|
.orderBy('cbc.period_end_date', 'desc')
|
|
.first()
|
|
.select('cbc.period_end_date');
|
|
|
|
const cutoverStart: ISO8601String | null = lastInvoiced?.period_end_date ? normalizeDbIsoUtcMidnight(lastInvoiced.period_end_date) : null;
|
|
|
|
const nonInvoicedCycleQuery = trx('client_billing_cycles')
|
|
.where({ tenant, client_id: input.clientId, is_active: true })
|
|
.whereNotExists(function () {
|
|
this.select(1)
|
|
.from('invoices')
|
|
.whereRaw('invoices.tenant = client_billing_cycles.tenant')
|
|
.andWhereRaw('invoices.billing_cycle_id = client_billing_cycles.billing_cycle_id');
|
|
});
|
|
|
|
if (cutoverStart) {
|
|
await nonInvoicedCycleQuery.andWhere('period_start_date', '>=', cutoverStart).update({
|
|
is_active: false,
|
|
updated_at: trx.fn.now()
|
|
});
|
|
} else {
|
|
await nonInvoicedCycleQuery.update({
|
|
is_active: false,
|
|
updated_at: trx.fn.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
function normalizeIsoDateOnly(value: ISO8601String): ISO8601String {
|
|
return `${value.slice(0, 10)}T00:00:00Z` as ISO8601String;
|
|
}
|
|
|
|
function getTodayUtcMidnightIso(): ISO8601String {
|
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00Z` as ISO8601String;
|
|
}
|
|
|
|
function resolveNormalizedBootstrapBoundary(input: {
|
|
billingHistoryStartDate: ISO8601String;
|
|
billingCycle: BillingCycleType;
|
|
anchor: NormalizedBillingCycleAnchorSettings;
|
|
}): ISO8601String {
|
|
const requested = ensureUtcMidnightIsoDate(input.billingHistoryStartDate);
|
|
const containingPeriod = getBillingPeriodForDate(requested, input.billingCycle, input.anchor);
|
|
return normalizeIsoDateOnly(containingPeriod.periodStartDate);
|
|
}
|
|
|
|
export type BillingHistoryBootstrapPreview = {
|
|
requestedHistoryStartDate: ISO8601String;
|
|
normalizedHistoryStartBoundary: ISO8601String;
|
|
earliestInvoicedCycleStartBoundary: ISO8601String | null;
|
|
status: 'eligible' | 'blocked_invoiced_history';
|
|
blockedReason: string | null;
|
|
affectedUninvoicedCycleCount: number;
|
|
};
|
|
|
|
export async function previewBillingHistoryBootstrap(
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
input: {
|
|
clientId: string;
|
|
billingCycle: BillingCycleType;
|
|
anchor: BillingCycleAnchorSettingsInput;
|
|
billingHistoryStartDate: ISO8601String;
|
|
},
|
|
): Promise<BillingHistoryBootstrapPreview> {
|
|
validateAnchorSettingsForCycle(input.billingCycle, input.anchor);
|
|
const normalizedAnchor = normalizeAnchorSettingsForCycle(input.billingCycle, input.anchor);
|
|
const normalizedBoundary = resolveNormalizedBootstrapBoundary({
|
|
billingHistoryStartDate: input.billingHistoryStartDate,
|
|
billingCycle: input.billingCycle,
|
|
anchor: normalizedAnchor,
|
|
});
|
|
|
|
const earliestInvoiced = await knexOrTrx('client_billing_cycles as cbc')
|
|
.join('invoices as i', function () {
|
|
this.on('i.billing_cycle_id', '=', 'cbc.billing_cycle_id').andOn('i.tenant', '=', 'cbc.tenant');
|
|
})
|
|
.where('cbc.tenant', tenant)
|
|
.andWhere('cbc.client_id', input.clientId)
|
|
.orderBy('cbc.period_start_date', 'asc')
|
|
.first()
|
|
.select('cbc.period_start_date');
|
|
|
|
const earliestInvoicedBoundary = earliestInvoiced?.period_start_date
|
|
? normalizeDbIsoUtcMidnight(earliestInvoiced.period_start_date)
|
|
: null;
|
|
|
|
const affectedUninvoicedRows = await knexOrTrx('client_billing_cycles')
|
|
.where({ tenant, client_id: input.clientId })
|
|
.andWhere('period_start_date', '>=', normalizedBoundary)
|
|
.whereNotExists(function () {
|
|
this.select(1)
|
|
.from('invoices')
|
|
.whereRaw('invoices.tenant = client_billing_cycles.tenant')
|
|
.andWhereRaw('invoices.billing_cycle_id = client_billing_cycles.billing_cycle_id');
|
|
})
|
|
.count<{ count: number | string }>('billing_cycle_id as count')
|
|
.first();
|
|
|
|
if (earliestInvoicedBoundary && normalizedBoundary < earliestInvoicedBoundary) {
|
|
return {
|
|
requestedHistoryStartDate: ensureUtcMidnightIsoDate(input.billingHistoryStartDate),
|
|
normalizedHistoryStartBoundary: normalizedBoundary,
|
|
earliestInvoicedCycleStartBoundary: earliestInvoicedBoundary,
|
|
status: 'blocked_invoiced_history',
|
|
blockedReason:
|
|
`Cannot move billing history earlier than invoiced history boundary (${earliestInvoicedBoundary.slice(0, 10)}).`,
|
|
affectedUninvoicedCycleCount: Number(affectedUninvoicedRows?.count ?? 0),
|
|
};
|
|
}
|
|
|
|
return {
|
|
requestedHistoryStartDate: ensureUtcMidnightIsoDate(input.billingHistoryStartDate),
|
|
normalizedHistoryStartBoundary: normalizedBoundary,
|
|
earliestInvoicedCycleStartBoundary: earliestInvoicedBoundary,
|
|
status: 'eligible',
|
|
blockedReason: null,
|
|
affectedUninvoicedCycleCount: Number(affectedUninvoicedRows?.count ?? 0),
|
|
};
|
|
}
|
|
|
|
async function regenerateHistoricalClientBillingCyclesFromBootstrap(
|
|
trx: Knex.Transaction,
|
|
input: {
|
|
tenant: string;
|
|
clientId: string;
|
|
billingCycle: BillingCycleType;
|
|
anchor: NormalizedBillingCycleAnchorSettings;
|
|
billingHistoryStartDate: ISO8601String;
|
|
},
|
|
): Promise<void> {
|
|
const preview = await previewBillingHistoryBootstrap(trx, input.tenant, {
|
|
clientId: input.clientId,
|
|
billingCycle: input.billingCycle,
|
|
anchor: input.anchor,
|
|
billingHistoryStartDate: input.billingHistoryStartDate,
|
|
});
|
|
|
|
if (preview.status === 'blocked_invoiced_history') {
|
|
throw new Error(preview.blockedReason ?? 'Billing history bootstrap is blocked by invoiced history.');
|
|
}
|
|
|
|
const nonInvoicedCyclesFromBoundary = await trx('client_billing_cycles')
|
|
.where({ tenant: input.tenant, client_id: input.clientId })
|
|
.andWhere('period_start_date', '>=', preview.normalizedHistoryStartBoundary)
|
|
.whereNotExists(function () {
|
|
this.select(1)
|
|
.from('invoices')
|
|
.whereRaw('invoices.tenant = client_billing_cycles.tenant')
|
|
.andWhereRaw('invoices.billing_cycle_id = client_billing_cycles.billing_cycle_id');
|
|
})
|
|
.select('period_end_date');
|
|
|
|
const furthestExistingNonInvoicedBoundary = nonInvoicedCyclesFromBoundary
|
|
.map((row) => normalizeDbIsoUtcMidnight(row.period_end_date))
|
|
.sort()
|
|
.slice(-1)[0] ?? null;
|
|
|
|
await trx('client_billing_cycles')
|
|
.where({ tenant: input.tenant, client_id: input.clientId })
|
|
.andWhere('period_start_date', '>=', preview.normalizedHistoryStartBoundary)
|
|
.whereNotExists(function () {
|
|
this.select(1)
|
|
.from('invoices')
|
|
.whereRaw('invoices.tenant = client_billing_cycles.tenant')
|
|
.andWhereRaw('invoices.billing_cycle_id = client_billing_cycles.billing_cycle_id');
|
|
})
|
|
.del();
|
|
|
|
const today = getTodayUtcMidnightIso();
|
|
const currentCoverageExclusive = normalizeIsoDateOnly(
|
|
getBillingPeriodForDate(today, input.billingCycle, input.anchor).periodEndDate,
|
|
);
|
|
const rebuildThroughExclusive = [currentCoverageExclusive, furthestExistingNonInvoicedBoundary]
|
|
.filter((value): value is ISO8601String => value != null)
|
|
.sort()
|
|
.slice(-1)[0] ?? currentCoverageExclusive;
|
|
let cursor = preview.normalizedHistoryStartBoundary;
|
|
|
|
while (cursor < rebuildThroughExclusive) {
|
|
const nextBoundary = normalizeIsoDateOnly(
|
|
getNextBillingBoundaryAfter(cursor, input.billingCycle, input.anchor),
|
|
);
|
|
|
|
const existingInvoicedCycle = await trx('client_billing_cycles as cbc')
|
|
.join('invoices as i', function () {
|
|
this.on('i.billing_cycle_id', '=', 'cbc.billing_cycle_id').andOn('i.tenant', '=', 'cbc.tenant');
|
|
})
|
|
.where('cbc.tenant', input.tenant)
|
|
.andWhere('cbc.client_id', input.clientId)
|
|
.andWhere('cbc.period_start_date', cursor)
|
|
.first('cbc.billing_cycle_id');
|
|
|
|
if (!existingInvoicedCycle) {
|
|
const existingCycle = await trx('client_billing_cycles')
|
|
.where({
|
|
tenant: input.tenant,
|
|
client_id: input.clientId,
|
|
period_start_date: cursor,
|
|
})
|
|
.first('billing_cycle_id');
|
|
|
|
if (!existingCycle) {
|
|
await trx('client_billing_cycles').insert({
|
|
tenant: input.tenant,
|
|
client_id: input.clientId,
|
|
billing_cycle: input.billingCycle,
|
|
effective_date: cursor,
|
|
period_start_date: cursor,
|
|
period_end_date: nextBoundary,
|
|
is_active: true,
|
|
created_at: trx.fn.now(),
|
|
updated_at: trx.fn.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
cursor = nextBoundary;
|
|
}
|
|
}
|
|
|
|
export type BillingCyclePeriodPreview = {
|
|
periodStartDate: ISO8601String;
|
|
periodEndDate: ISO8601String;
|
|
};
|
|
|
|
export type BillingCyclePeriodPreviewResult = {
|
|
cadenceContext: ClientCadenceScheduleContext;
|
|
periods: BillingCyclePeriodPreview[];
|
|
};
|
|
|
|
export function previewBillingPeriodsForSchedule(
|
|
billingCycle: BillingCycleType,
|
|
anchor: BillingCycleAnchorSettingsInput,
|
|
options: { count?: number; referenceDate?: ISO8601String } = {}
|
|
): BillingCyclePeriodPreviewResult {
|
|
validateAnchorSettingsForCycle(billingCycle, anchor);
|
|
const normalized = normalizeAnchorSettingsForCycle(billingCycle, anchor);
|
|
|
|
const count = Math.max(1, Math.min(options.count ?? 3, 12));
|
|
const referenceDate = ensureUtcMidnightIsoDate(
|
|
options.referenceDate ?? (new Date().toISOString().split('T')[0] + 'T00:00:00Z')
|
|
);
|
|
|
|
const firstPeriod = getBillingPeriodForDate(referenceDate, billingCycle, normalized);
|
|
const periods: BillingCyclePeriodPreview[] = [{ periodStartDate: firstPeriod.periodStartDate, periodEndDate: firstPeriod.periodEndDate }];
|
|
|
|
for (let i = 1; i < count; i++) {
|
|
const previous = periods[periods.length - 1];
|
|
const nextEnd = getNextBillingBoundaryAfter(previous.periodStartDate, billingCycle, normalized);
|
|
const nextNext = getNextBillingBoundaryAfter(nextEnd, billingCycle, normalized);
|
|
periods.push({ periodStartDate: nextEnd, periodEndDate: nextNext });
|
|
}
|
|
|
|
return {
|
|
cadenceContext: CLIENT_CADENCE_SCHEDULE_CONTEXT,
|
|
periods,
|
|
};
|
|
}
|
|
|
|
export async function createNextBillingCycle(
|
|
knexOrTrx: Knex | Knex.Transaction,
|
|
tenant: string,
|
|
clientId: string,
|
|
effectiveDate?: string
|
|
): Promise<BillingCycleCreationResult> {
|
|
const client = await knexOrTrx<IClient>('clients').where({ client_id: clientId, tenant }).first();
|
|
if (!client) {
|
|
throw new Error('Client not found');
|
|
}
|
|
if (!effectiveDate) {
|
|
return createClientContractLineCycles(knexOrTrx as any, client as IClient, { manual: true });
|
|
}
|
|
|
|
const schedule = await getClientBillingCycleAnchor(knexOrTrx, tenant, clientId);
|
|
const normalizedEffectiveBoundary = resolveNormalizedBootstrapBoundary({
|
|
billingHistoryStartDate: ensureUtcMidnightIsoDate(effectiveDate),
|
|
billingCycle: schedule.billingCycle,
|
|
anchor: schedule.anchor,
|
|
});
|
|
|
|
return createClientContractLineCycles(knexOrTrx as any, client as IClient, {
|
|
manual: true,
|
|
effectiveDate: normalizedEffectiveBoundary,
|
|
});
|
|
}
|
|
|
|
function isKnexTransaction(knexOrTrx: Knex | Knex.Transaction): knexOrTrx is Knex.Transaction {
|
|
return typeof (knexOrTrx as any).commit === 'function' && typeof (knexOrTrx as any).rollback === 'function';
|
|
}
|