PSA/shared/__tests__/billingSchedule.historyBootstrap.test.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

371 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ISO8601String } from '@alga-psa/types';
import { updateClientBillingSchedule } from '../billingClients/billingSchedule';
vi.mock('../billingClients/billingSettings', () => ({
ensureClientBillingSettingsRow: vi.fn(async () => undefined),
}));
type Row = Record<string, any>;
type DbState = {
clients: Row[];
client_billing_settings: Row[];
client_billing_cycles: Row[];
invoices: Row[];
};
function cloneState(state: DbState): DbState {
return {
clients: state.clients.map((row) => ({ ...row })),
client_billing_settings: state.client_billing_settings.map((row) => ({ ...row })),
client_billing_cycles: state.client_billing_cycles.map((row) => ({ ...row })),
invoices: state.invoices.map((row) => ({ ...row })),
};
}
function normalizeTableName(table: string): keyof DbState {
const base = table.split(/\s+as\s+/i)[0] as keyof DbState;
return base;
}
class FakeQuery {
private predicates: Array<(row: Row) => boolean> = [];
private sorters: Array<{ field: string; dir: 'asc' | 'desc' }> = [];
private firstMode = false;
private selectedFields: string[] | null = null;
private countAlias: string | null = null;
private requireInvoicedJoin = false;
private requireNoInvoice = false;
constructor(
private readonly state: DbState,
private readonly table: keyof DbState,
private readonly fnNow: () => string,
) {}
private normalizeFieldName(field: string): string {
return field.includes('.') ? field.split('.').pop()! : field;
}
where(arg1: any, arg2?: any, arg3?: any): this {
if (typeof arg1 === 'object' && arg1 !== null && arg2 === undefined) {
for (const [key, value] of Object.entries(arg1)) {
this.predicates.push((row) => row[key] === value);
}
return this;
}
if (typeof arg1 === 'string' && arg3 !== undefined) {
const field = this.normalizeFieldName(arg1);
const op = String(arg2);
const value = arg3;
this.predicates.push((row) => {
const left = row[field];
if (op === '>=') return left >= value;
if (op === '>') return left > value;
if (op === '<=') return left <= value;
if (op === '<') return left < value;
return left === value;
});
return this;
}
if (typeof arg1 === 'string' && arg2 !== undefined) {
const field = this.normalizeFieldName(arg1);
const value = arg2;
this.predicates.push((row) => row[field] === value);
}
return this;
}
andWhere(arg1: any, arg2?: any, arg3?: any): this {
return this.where(arg1, arg2, arg3);
}
join(table: string, _onFn: Function): this {
if (normalizeTableName(table) === 'invoices') {
this.requireInvoicedJoin = true;
}
return this;
}
leftJoin(): this {
return this;
}
whereNotExists(_subqueryFn: Function): this {
this.requireNoInvoice = true;
return this;
}
orderBy(field: string, dir: 'asc' | 'desc' = 'asc'): this {
this.sorters.push({ field, dir });
return this;
}
first(...fields: string[]): this | Promise<any> {
this.firstMode = true;
if (fields.length > 0) {
this.selectedFields = fields;
return this.execute();
}
return this;
}
count(...args: any[]): this {
const countArg = typeof args[0] === 'string' ? args[0] : null;
if (countArg && /\sas\s/i.test(countArg)) {
const [, alias] = countArg.split(/\s+as\s+/i);
this.countAlias = alias?.trim() ?? 'count';
} else {
this.countAlias = 'count';
}
return this;
}
select(...fields: string[]): Promise<any> {
this.selectedFields = fields;
return this.execute();
}
then<TResult1 = any, TResult2 = never>(
onfulfilled?: ((value: any) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected);
}
private async execute(): Promise<any> {
const rows = this.resolveRows();
const mapped = this.selectedFields?.length
? rows.map((row) => {
const out: Row = {};
for (const field of this.selectedFields ?? []) {
const [left, right] = field.split(/\s+as\s+/i);
const source = left.includes('.') ? left.split('.').pop()! : left;
const target = right ? right.trim() : source;
out[target] = row[source];
}
return out;
})
: rows;
if (this.countAlias) {
const payload = { [this.countAlias]: String(rows.length) };
return this.firstMode ? payload : [payload];
}
if (this.firstMode) {
return mapped[0] ?? null;
}
return mapped;
}
async update(payload: Row): Promise<number> {
const rows = this.resolveRows({ mutable: true });
for (const row of rows) {
Object.assign(row, payload, { updated_at: row.updated_at ?? this.fnNow() });
}
return rows.length;
}
async del(): Promise<number> {
const source = this.state[this.table];
const toDelete = new Set(this.resolveRows().map((row) => row.__row_id));
const before = source.length;
this.state[this.table] = source.filter((row) => !toDelete.has(row.__row_id));
return before - this.state[this.table].length;
}
async insert(payload: Row | Row[]): Promise<number> {
const rows = Array.isArray(payload) ? payload : [payload];
const target = this.state[this.table];
for (const row of rows) {
target.push({
billing_cycle_id: row.billing_cycle_id ?? `cycle-${target.length + 1}`,
...row,
__row_id: `${this.table}-${target.length + 1}-${Math.random()}`,
});
}
return rows.length;
}
private resolveRows(options: { mutable?: boolean } = {}): Row[] {
const base: Row[] = this.state[this.table].map((row, idx) => ({ ...row, __row_id: row.__row_id ?? `${this.table}-${idx}` }));
let filtered: Row[] = base.filter((row) => this.predicates.every((predicate) => predicate(row)));
if (this.requireInvoicedJoin) {
filtered = filtered.filter((row) => this.state.invoices.some((invoice) =>
invoice.tenant === row.tenant && invoice.billing_cycle_id === row.billing_cycle_id,
));
}
if (this.requireNoInvoice) {
filtered = filtered.filter((row) => !this.state.invoices.some((invoice) =>
invoice.tenant === row.tenant && invoice.billing_cycle_id === row.billing_cycle_id,
));
}
for (const sorter of this.sorters) {
filtered.sort((a, b) => {
const av = a[sorter.field.includes('.') ? sorter.field.split('.').pop()! : sorter.field];
const bv = b[sorter.field.includes('.') ? sorter.field.split('.').pop()! : sorter.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return sorter.dir === 'desc' ? -cmp : cmp;
});
}
if (options.mutable) {
return this.state[this.table].filter((row) =>
filtered.some((candidate) => candidate.__row_id === row.__row_id),
);
}
return filtered;
}
}
function makeFakeTrx(seed: DbState): any {
const state = cloneState(seed);
const trx: any = (table: string) => new FakeQuery(state, normalizeTableName(table), () => 'NOW');
trx.fn = { now: () => 'NOW' };
trx.schema = { hasColumn: vi.fn(async () => true) };
trx.commit = vi.fn(async () => undefined);
trx.rollback = vi.fn(async () => undefined);
trx.__state = state;
return trx;
}
function cycleStarts(state: DbState): string[] {
return state.client_billing_cycles
.map((row) => String(row.period_start_date).slice(0, 10))
.sort();
}
describe('billing history bootstrap cycle regeneration', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-21T12:00:00Z'));
});
it('T019: saving schedule with optional history date and no cycles creates cycles from normalized boundary through present', async () => {
const trx = makeFakeTrx({
clients: [{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle: 'monthly' }],
client_billing_settings: [{ tenant: 'tenant-1', client_id: 'client-1' }],
client_billing_cycles: [],
invoices: [],
});
await updateClientBillingSchedule(trx, 'tenant-1', {
clientId: 'client-1',
billingCycle: 'monthly',
anchor: { dayOfMonth: 1 },
billingHistoryStartDate: '2025-12-15T00:00:00Z' as ISO8601String,
});
expect(cycleStarts(trx.__state)).toEqual([
'2025-12-01',
'2026-01-01',
'2026-02-01',
'2026-03-01',
]);
});
it('T020: moving history earlier with only uninvoiced cycles deterministically regenerates contiguous historical cycles', async () => {
const trx = makeFakeTrx({
clients: [{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle: 'monthly' }],
client_billing_settings: [{ tenant: 'tenant-1', client_id: 'client-1' }],
client_billing_cycles: [
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c1', period_start_date: '2026-01-01T00:00:00Z', period_end_date: '2026-02-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c2', period_start_date: '2026-02-01T00:00:00Z', period_end_date: '2026-03-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c3', period_start_date: '2026-03-01T00:00:00Z', period_end_date: '2026-04-01T00:00:00Z' },
],
invoices: [],
});
await updateClientBillingSchedule(trx, 'tenant-1', {
clientId: 'client-1',
billingCycle: 'monthly',
anchor: { dayOfMonth: 1 },
billingHistoryStartDate: '2025-11-15T00:00:00Z' as ISO8601String,
});
expect(cycleStarts(trx.__state)).toEqual([
'2025-11-01',
'2025-12-01',
'2026-01-01',
'2026-02-01',
'2026-03-01',
]);
const sorted = [...trx.__state.client_billing_cycles].sort((a, b) =>
String(a.period_start_date).localeCompare(String(b.period_start_date)),
);
for (let i = 0; i < sorted.length - 1; i++) {
expect(String(sorted[i].period_end_date).slice(0, 10)).toBe(String(sorted[i + 1].period_start_date).slice(0, 10));
}
});
it('T021: moving history earlier than earliest invoiced boundary is blocked and does not mutate cycles', async () => {
const trx = makeFakeTrx({
clients: [{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle: 'monthly' }],
client_billing_settings: [{ tenant: 'tenant-1', client_id: 'client-1' }],
client_billing_cycles: [
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'invoiced-1', period_start_date: '2026-01-01T00:00:00Z', period_end_date: '2026-02-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'uninvoiced-2', period_start_date: '2026-02-01T00:00:00Z', period_end_date: '2026-03-01T00:00:00Z' },
],
invoices: [
{ tenant: 'tenant-1', billing_cycle_id: 'invoiced-1', invoice_id: 'inv-1' },
],
});
const before = JSON.stringify(trx.__state.client_billing_cycles);
await expect(updateClientBillingSchedule(trx, 'tenant-1', {
clientId: 'client-1',
billingCycle: 'monthly',
anchor: { dayOfMonth: 1 },
billingHistoryStartDate: '2025-12-15T00:00:00Z' as ISO8601String,
})).rejects.toThrow('Cannot move billing history earlier than invoiced history boundary');
expect(JSON.stringify(trx.__state.client_billing_cycles)).toBe(before);
});
it('preserves staged future uninvoiced cycles while backfilling history', async () => {
const trx = makeFakeTrx({
clients: [{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle: 'monthly' }],
client_billing_settings: [{ tenant: 'tenant-1', client_id: 'client-1' }],
client_billing_cycles: [
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c2', period_start_date: '2026-02-01T00:00:00Z', period_end_date: '2026-03-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c3', period_start_date: '2026-03-01T00:00:00Z', period_end_date: '2026-04-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c4', period_start_date: '2026-04-01T00:00:00Z', period_end_date: '2026-05-01T00:00:00Z' },
{ tenant: 'tenant-1', client_id: 'client-1', billing_cycle_id: 'c5', period_start_date: '2026-05-01T00:00:00Z', period_end_date: '2026-06-01T00:00:00Z' },
],
invoices: [],
});
await updateClientBillingSchedule(trx, 'tenant-1', {
clientId: 'client-1',
billingCycle: 'monthly',
anchor: { dayOfMonth: 1 },
billingHistoryStartDate: '2025-12-15T00:00:00Z' as ISO8601String,
});
expect(cycleStarts(trx.__state)).toEqual([
'2025-12-01',
'2026-01-01',
'2026-02-01',
'2026-03-01',
'2026-04-01',
'2026-05-01',
]);
});
});