PSA/packages/billing/tests/accounting/accountingExportValidation.rules.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

399 lines
14 KiB
TypeScript

/**
* Behavioral unit tests for AccountingExportValidation.ensureMappingsForBatch
* (packages/billing/src/services/accountingExportValidation.ts).
*
* Verifies the rules that decide whether an invoice export batch is 'ready'
* vs 'needs_attention': unmapped services/tax regions/payment terms, missing
* charge/service references, QuickBooks realm + client requirements, and the
* canonical service-period projection checks.
*
* Repository, mapping resolver and knex are all faked; no database.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const h = vi.hoisted(() => ({
repo: undefined as any,
resolver: undefined as any,
knex: undefined as any,
}));
vi.mock('../../src/repositories/accountingExportRepository', () => ({
AccountingExportRepository: { create: vi.fn(async () => h.repo) },
}));
vi.mock('../../src/services/accountingMappingResolver', () => ({
AccountingMappingResolver: { create: vi.fn(async () => h.resolver) },
}));
vi.mock('@alga-psa/db', () => ({
createTenantKnex: vi.fn(async () => ({ knex: h.knex, tenant: 'tenant-1' })),
}));
import { AccountingExportValidation } from '../../src/services/accountingExportValidation';
const TENANT = 'tenant-1';
const P1_START = '2025-01-01T00:00:00.000Z';
const P1_END = '2025-02-01T00:00:00.000Z';
const P2_START = '2025-02-01T00:00:00.000Z';
const P2_END = '2025-03-01T00:00:00.000Z';
type FakeState = {
batch: any;
lines: any[];
charges: any[];
chargeDetails: any[];
invoices: any[];
clients: any[];
services: any[];
};
function createFakeKnex(state: FakeState) {
const updates: Array<{ table: string; patch: any }> = [];
const tableData: Record<string, () => any[]> = {
invoice_charges: () => state.charges,
invoice_charge_details: () => state.chargeDetails,
invoices: () => state.invoices,
clients: () => state.clients,
service_catalog: () => state.services,
accounting_export_errors: () => [],
};
const knex: any = (table: string) => {
const builder: any = {};
for (const method of ['where', 'andWhere', 'whereIn', 'select', 'orderBy']) {
builder[method] = (...args: any[]) => {
for (const arg of args) {
if (typeof arg === 'function') arg.call(builder, builder);
}
return builder;
};
}
builder.update = (patch: any) => {
updates.push({ table, patch });
return Promise.resolve(1);
};
builder.then = (onFulfilled: any, onRejected: any) =>
Promise.resolve(tableData[table]?.() ?? []).then(onFulfilled, onRejected);
return builder;
};
knex.__updates = updates;
return knex;
}
function createFakeRepo(state: FakeState) {
const errors: any[] = [];
return {
errors,
getBatch: vi.fn(async () => state.batch),
listLines: vi.fn(async () => state.lines),
addError: vi.fn(async (error: any) => {
errors.push({ ...error, resolution_state: 'open' });
}),
listErrors: vi.fn(async () => errors),
updateBatchStatus: vi.fn(async () => {}),
};
}
function createFakeResolver(overrides: Partial<Record<string, any>> = {}) {
return {
resolveServiceMapping: vi.fn(async () => ({ external_entity_id: 'item-1', source: 'service' })),
resolveTaxCodeMapping: vi.fn(async () => ({ external_entity_id: 'tax-1', source: 'tax_code' })),
resolvePaymentTermMapping: vi.fn(async () => ({ external_entity_id: 'term-1', source: 'payment_term' })),
...overrides,
};
}
function makeCanonicalLine(overrides: Partial<any> = {}) {
return {
line_id: 'line-1',
invoice_id: 'inv-1',
invoice_charge_id: 'charge-1',
service_period_start: P1_START,
service_period_end: P1_END,
payload: {
service_period_source: 'canonical_detail_periods',
recurring_detail_periods: [
{ service_period_start: P1_START, service_period_end: P1_END, billing_timing: 'advance' },
],
},
...overrides,
};
}
function baseState(overrides: Partial<FakeState> = {}): FakeState {
return {
batch: { batch_id: 'batch-1', tenant: TENANT, adapter_type: 'xero_csv', target_realm: null },
lines: [makeCanonicalLine()],
charges: [{ item_id: 'charge-1', invoice_id: 'inv-1', service_id: 'svc-1', tax_region: null }],
chargeDetails: [
{ item_id: 'charge-1', service_period_start: P1_START, service_period_end: P1_END, billing_timing: 'advance' },
],
invoices: [{ invoice_id: 'inv-1', client_id: 'client-1', tax_source: 'internal' }],
clients: [{ client_id: 'client-1', payment_terms: 'net_30' }],
services: [{ service_id: 'svc-1', service_name: 'Managed Backup' }],
...overrides,
};
}
async function run(state: FakeState, resolver = createFakeResolver()) {
h.repo = createFakeRepo(state);
h.resolver = resolver;
h.knex = createFakeKnex(state);
await AccountingExportValidation.ensureMappingsForBatch('batch-1');
return { repo: h.repo, resolver, knex: h.knex };
}
describe('AccountingExportValidation.ensureMappingsForBatch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('throws when the batch does not exist', async () => {
h.repo = { getBatch: vi.fn(async () => null) };
await expect(AccountingExportValidation.ensureMappingsForBatch('missing')).rejects.toThrow(
'Export batch missing not found'
);
});
it('marks a fully mapped batch ready and resolves prior open errors', async () => {
const { repo, knex } = await run(baseState());
expect(repo.addError).not.toHaveBeenCalled();
expect(repo.updateBatchStatus).toHaveBeenCalledWith('batch-1', { status: 'ready' });
// Prior open validation errors are reset at the start of each run.
expect(knex.__updates).toContainEqual({
table: 'accounting_export_errors',
patch: expect.objectContaining({ resolution_state: 'resolved' }),
});
});
it('flags lines without an invoice charge id', async () => {
const state = baseState({
lines: [makeCanonicalLine({ invoice_charge_id: null })],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([
expect.objectContaining({ code: 'missing_charge_id', line_id: 'line-1' }),
]);
expect(repo.updateBatchStatus).toHaveBeenCalledWith('batch-1', { status: 'needs_attention' });
});
it('flags charges that have no associated service', async () => {
const state = baseState({
charges: [{ item_id: 'charge-1', invoice_id: 'inv-1', service_id: null, tax_region: null }],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([
expect.objectContaining({ code: 'missing_service', line_id: 'line-1' }),
]);
});
it('reports an unmapped service once per service+realm, with the service name', async () => {
const state = baseState({
lines: [
makeCanonicalLine({ line_id: 'line-1' }),
makeCanonicalLine({ line_id: 'line-2' }),
],
});
const resolver = createFakeResolver({ resolveServiceMapping: vi.fn(async () => null) });
const { repo } = await run(state, resolver);
const serviceErrors = repo.errors.filter((e: any) => e.code === 'missing_service_mapping');
expect(serviceErrors).toHaveLength(1);
expect(serviceErrors[0].message).toBe('No mapping for service "Managed Backup"');
expect(serviceErrors[0].metadata).toMatchObject({
service_id: 'svc-1',
service_name: 'Managed Backup',
service_period_start: P1_START,
service_period_end: P1_END,
});
expect(resolver.resolveServiceMapping).toHaveBeenCalledTimes(1);
expect(repo.updateBatchStatus).toHaveBeenCalledWith('batch-1', { status: 'needs_attention' });
});
it('rejects export lines whose projected service periods diverge from canonical charge details', async () => {
const state = baseState({
lines: [
makeCanonicalLine({
// Line claims a financial-document fallback even though canonical
// detail periods exist for the charge.
service_period_start: null,
service_period_end: null,
payload: { service_period_source: 'financial_document_fallback' },
}),
],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([
expect.objectContaining({
code: 'service_period_projection_mismatch',
metadata: expect.objectContaining({
expected_source: 'canonical_detail_periods',
actual_source: 'financial_document_fallback',
expected_summary: { service_period_start: P1_START, service_period_end: P1_END },
}),
}),
]);
});
it('uses first-start/last-end of multi-period charges for the expected summary', async () => {
const state = baseState({
chargeDetails: [
{ item_id: 'charge-1', service_period_start: P1_START, service_period_end: P1_END, billing_timing: 'arrears' },
{ item_id: 'charge-1', service_period_start: P2_START, service_period_end: P2_END, billing_timing: 'arrears' },
],
lines: [
makeCanonicalLine({
service_period_start: P1_START,
service_period_end: P2_END,
payload: {
service_period_source: 'canonical_detail_periods',
recurring_detail_periods: [
{ service_period_start: P1_START, service_period_end: P1_END, billing_timing: 'arrears' },
{ service_period_start: P2_START, service_period_end: P2_END, billing_timing: 'arrears' },
],
},
}),
],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([]);
expect(repo.updateBatchStatus).toHaveBeenCalledWith('batch-1', { status: 'ready' });
});
it('rejects historical/financial lines that claim canonical recurring detail periods', async () => {
const state = baseState({
chargeDetails: [],
lines: [
makeCanonicalLine({
service_period_start: null,
service_period_end: null,
payload: {
service_period_source: 'financial_document_fallback',
recurring_detail_periods: [
{ service_period_start: P1_START, service_period_end: P1_END, billing_timing: 'advance' },
],
},
}),
],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([
expect.objectContaining({
code: 'service_period_projection_mismatch',
message: 'Historical or financial export lines must not claim canonical recurring detail periods',
}),
]);
});
it('accepts invoice-header fallback lines that carry summary periods but no detail periods', async () => {
const state = baseState({
chargeDetails: [],
lines: [
makeCanonicalLine({
payload: { service_period_source: 'invoice_header_fallback' },
}),
],
});
const { repo } = await run(state);
expect(repo.errors).toEqual([]);
expect(repo.updateBatchStatus).toHaveBeenCalledWith('batch-1', { status: 'ready' });
});
describe('QuickBooks-specific rules', () => {
function qboState(overrides: Partial<FakeState> = {}): FakeState {
return baseState({
batch: { batch_id: 'batch-1', tenant: TENANT, adapter_type: 'quickbooks_online', target_realm: 'realm-9' },
...overrides,
});
}
it('requires a target realm for QuickBooks Online batches', async () => {
const state = qboState({
batch: { batch_id: 'batch-1', tenant: TENANT, adapter_type: 'quickbooks_online', target_realm: null },
});
const { repo } = await run(state);
expect(repo.errors).toContainEqual(
expect.objectContaining({ code: 'missing_target_realm' })
);
});
it('flags unmapped tax regions on internally-taxed invoices', async () => {
const state = qboState({
charges: [{ item_id: 'charge-1', invoice_id: 'inv-1', service_id: 'svc-1', tax_region: 'US-NY' }],
});
const resolver = createFakeResolver({ resolveTaxCodeMapping: vi.fn(async () => null) });
const { repo } = await run(state, resolver);
expect(repo.errors).toEqual([
expect.objectContaining({
code: 'missing_tax_mapping',
message: 'No tax code mapping for region US-NY',
}),
]);
});
it('skips tax-mapping checks when the invoice delegates tax externally', async () => {
const state = qboState({
charges: [{ item_id: 'charge-1', invoice_id: 'inv-1', service_id: 'svc-1', tax_region: 'US-NY' }],
invoices: [{ invoice_id: 'inv-1', client_id: 'client-1', tax_source: 'external' }],
});
const resolver = createFakeResolver({ resolveTaxCodeMapping: vi.fn(async () => null) });
const { repo } = await run(state, resolver);
expect(resolver.resolveTaxCodeMapping).not.toHaveBeenCalled();
expect(repo.errors).toEqual([]);
});
it('flags invoices without a client association', async () => {
const state = qboState({
invoices: [{ invoice_id: 'inv-1', client_id: null, tax_source: 'internal' }],
});
const { repo } = await run(state);
expect(repo.errors).toContainEqual(
expect.objectContaining({
code: 'missing_client_reference',
message: 'Invoice inv-1 is missing a client association',
})
);
});
it('flags unmapped client payment terms', async () => {
const resolver = createFakeResolver({ resolvePaymentTermMapping: vi.fn(async () => null) });
const { repo } = await run(qboState(), resolver);
expect(repo.errors).toContainEqual(
expect.objectContaining({
code: 'missing_payment_term_mapping',
message: 'No payment term mapping for net_30',
metadata: expect.objectContaining({ client_id: 'client-1', payment_terms: 'net_30' }),
})
);
});
it('does not apply QuickBooks rules to non-QuickBooks adapters', async () => {
const state = baseState({
batch: { batch_id: 'batch-1', tenant: TENANT, adapter_type: 'xero', target_realm: null },
charges: [{ item_id: 'charge-1', invoice_id: 'inv-1', service_id: 'svc-1', tax_region: 'US-NY' }],
});
const resolver = createFakeResolver({
resolveTaxCodeMapping: vi.fn(async () => null),
resolvePaymentTermMapping: vi.fn(async () => null),
});
const { repo } = await run(state, resolver);
expect(resolver.resolveTaxCodeMapping).not.toHaveBeenCalled();
expect(resolver.resolvePaymentTermMapping).not.toHaveBeenCalled();
expect(repo.errors).toEqual([]);
});
});
});