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
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for AccountingExportInvoiceSelector (packages/billing/src/services/accountingExportInvoiceSelector.ts).
|
|
*
|
|
* Exercises the pure row-to-preview-line mapping (cents handling, credit/zero
|
|
* detection, service-period aggregation, date normalization) and the selection
|
|
* filter construction (status expansion, pending-external draft inclusion,
|
|
* already-synced exclusion) against a recording fake knex. No database.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@alga-psa/db', () => ({
|
|
createTenantKnex: vi.fn(async () => {
|
|
throw new Error('not used in these tests');
|
|
}),
|
|
}));
|
|
|
|
// accountingExportService transitively imports '@alga-psa/integrations/runtime'
|
|
// (email adapters etc.) which cannot load in a pure unit-test environment.
|
|
// Only createBatchFromFilters touches it, and that path is exercised solely
|
|
// for its pure pre-validation here.
|
|
vi.mock('../../src/services/accountingExportService', () => ({
|
|
AccountingExportService: {
|
|
createForTenant: vi.fn(async () => {
|
|
throw new Error('not used in these tests');
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { AccountingExportInvoiceSelector } from '../../src/services/accountingExportInvoiceSelector';
|
|
import { AppError } from '@alga-psa/core';
|
|
|
|
type Op = { method: string; args: any[] };
|
|
|
|
/**
|
|
* Recording fake knex: every chained call (including calls made inside
|
|
* where-callbacks) is recorded per top-level table invocation; awaiting the
|
|
* builder resolves the configured rows for that table.
|
|
*/
|
|
function createFakeKnex(rowsByTable: Record<string, any[]>) {
|
|
const invocations: Array<{ table: string; ops: Op[] }> = [];
|
|
|
|
const knex: any = (table: string) => {
|
|
const invocation = { table, ops: [] as Op[] };
|
|
invocations.push(invocation);
|
|
|
|
const builder: any = {};
|
|
const chainMethods = [
|
|
'join',
|
|
'leftJoin',
|
|
'on',
|
|
'andOn',
|
|
'select',
|
|
'where',
|
|
'andWhere',
|
|
'andWhereRaw',
|
|
'orWhere',
|
|
'whereIn',
|
|
'whereNull',
|
|
'whereNotNull',
|
|
'whereNotExists',
|
|
'orWhereNull',
|
|
'from',
|
|
'orderBy',
|
|
'orderByRaw',
|
|
];
|
|
for (const method of chainMethods) {
|
|
builder[method] = (...args: any[]) => {
|
|
invocation.ops.push({ method, args });
|
|
for (const arg of args) {
|
|
if (typeof arg === 'function') {
|
|
arg.call(builder, builder);
|
|
}
|
|
}
|
|
return builder;
|
|
};
|
|
}
|
|
builder.then = (onFulfilled: any, onRejected: any) =>
|
|
Promise.resolve(rowsByTable[table] ?? []).then(onFulfilled, onRejected);
|
|
return builder;
|
|
};
|
|
knex.raw = (sql: string, bindings?: any[]) => ({ sql, bindings });
|
|
knex.__invocations = invocations;
|
|
return knex;
|
|
}
|
|
|
|
function findOps(knex: any, table: string): Op[] {
|
|
return knex.__invocations
|
|
.filter((inv: any) => inv.table === table)
|
|
.flatMap((inv: any) => inv.ops);
|
|
}
|
|
|
|
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|
return {
|
|
invoice_id: 'inv-1',
|
|
invoice_number: 'INV-0001',
|
|
invoice_date: '2025-02-01T00:00:00.000Z',
|
|
invoice_status: 'sent',
|
|
tax_source: 'internal',
|
|
client_id: 'client-1',
|
|
client_name: 'Acme Co',
|
|
currency_code: 'EUR',
|
|
invoice_is_manual: false,
|
|
total_amount: 10000,
|
|
item_id: 'charge-1',
|
|
total_price: 10000,
|
|
charge_is_manual: false,
|
|
detail_service_period_start: null,
|
|
detail_service_period_end: null,
|
|
detail_billing_timing: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSelector(rowsByTable: Record<string, any[]>) {
|
|
const knex = createFakeKnex(rowsByTable);
|
|
const selector = new AccountingExportInvoiceSelector(knex as any, 'tenant-1');
|
|
return { knex, selector };
|
|
}
|
|
|
|
describe('AccountingExportInvoiceSelector.previewInvoiceLines', () => {
|
|
it('maps amounts, currency and flags for an ordinary charge', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [makeRow()],
|
|
transactions: [{ invoice_id: 'inv-1', transaction_id: 'txn-1' }],
|
|
});
|
|
|
|
const lines = await selector.previewInvoiceLines({});
|
|
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]).toMatchObject({
|
|
invoiceId: 'inv-1',
|
|
invoiceNumber: 'INV-0001',
|
|
chargeId: 'charge-1',
|
|
amountCents: 10000,
|
|
currencyCode: 'EUR',
|
|
isCredit: false,
|
|
isZeroAmount: false,
|
|
isManualInvoice: false,
|
|
isManualCharge: false,
|
|
servicePeriodSource: 'financial_document_fallback',
|
|
servicePeriodStart: null,
|
|
servicePeriodEnd: null,
|
|
transactionIds: ['txn-1'],
|
|
});
|
|
});
|
|
|
|
it('rounds numeric-string amounts from pg to integer cents and defaults missing currency to USD', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [
|
|
makeRow({ total_price: '1050.4', total_amount: '1050.4', currency_code: null }),
|
|
],
|
|
});
|
|
|
|
const [line] = await selector.previewInvoiceLines({});
|
|
|
|
expect(line.amountCents).toBe(1050);
|
|
expect(line.currencyCode).toBe('USD');
|
|
});
|
|
|
|
it('treats unparseable or missing amounts as zero cents', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [makeRow({ total_price: 'garbage', total_amount: null })],
|
|
});
|
|
|
|
const [line] = await selector.previewInvoiceLines({});
|
|
|
|
expect(line.amountCents).toBe(0);
|
|
expect(line.isZeroAmount).toBe(true);
|
|
});
|
|
|
|
it('marks credits when either the charge or the invoice total is negative', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [
|
|
makeRow({ item_id: 'charge-neg', total_price: -2500, total_amount: 10000 }),
|
|
makeRow({ item_id: 'charge-pos', total_price: 2500, total_amount: -10000 }),
|
|
],
|
|
});
|
|
|
|
const lines = await selector.previewInvoiceLines({});
|
|
|
|
expect(lines.map((l) => [l.chargeId, l.isCredit])).toEqual([
|
|
['charge-neg', true],
|
|
['charge-pos', true],
|
|
]);
|
|
});
|
|
|
|
it('aggregates multi-period charge details: sorted, deduplicated, first-start/last-end summary', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [
|
|
// Deliberately out of order + one duplicate detail row.
|
|
makeRow({
|
|
detail_service_period_start: '2025-02-01',
|
|
detail_service_period_end: '2025-03-01',
|
|
detail_billing_timing: 'arrears',
|
|
}),
|
|
makeRow({
|
|
detail_service_period_start: '2025-01-01',
|
|
detail_service_period_end: '2025-02-01',
|
|
detail_billing_timing: 'arrears',
|
|
}),
|
|
makeRow({
|
|
detail_service_period_start: '2025-02-01',
|
|
detail_service_period_end: '2025-03-01',
|
|
detail_billing_timing: 'arrears',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const lines = await selector.previewInvoiceLines({});
|
|
|
|
expect(lines).toHaveLength(1);
|
|
const [line] = lines;
|
|
expect(line.recurringDetailPeriods).toEqual([
|
|
{
|
|
service_period_start: '2025-01-01T00:00:00.000Z',
|
|
service_period_end: '2025-02-01T00:00:00.000Z',
|
|
billing_timing: 'arrears',
|
|
},
|
|
{
|
|
service_period_start: '2025-02-01T00:00:00.000Z',
|
|
service_period_end: '2025-03-01T00:00:00.000Z',
|
|
billing_timing: 'arrears',
|
|
},
|
|
]);
|
|
expect(line.servicePeriodStart).toBe('2025-01-01T00:00:00.000Z');
|
|
expect(line.servicePeriodEnd).toBe('2025-03-01T00:00:00.000Z');
|
|
expect(line.servicePeriodSource).toBe('canonical_detail_periods');
|
|
expect(line.isMultiPeriod).toBe(true);
|
|
});
|
|
|
|
it('normalizes date-only strings and local-midnight Date objects to UTC-midnight ISO strings', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [
|
|
makeRow({
|
|
// Local-midnight Date: must keep the calendar day, not shift by timezone.
|
|
detail_service_period_start: new Date(2025, 0, 1),
|
|
detail_service_period_end: '2025-02-01',
|
|
detail_billing_timing: 'advance',
|
|
}),
|
|
],
|
|
});
|
|
|
|
const [line] = await selector.previewInvoiceLines({});
|
|
|
|
expect(line.recurringDetailPeriods).toEqual([
|
|
{
|
|
service_period_start: '2025-01-01T00:00:00.000Z',
|
|
service_period_end: '2025-02-01T00:00:00.000Z',
|
|
billing_timing: 'advance',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('groups transaction ids per invoice', async () => {
|
|
const { selector } = makeSelector({
|
|
'invoices as inv': [
|
|
makeRow({ invoice_id: 'inv-1', item_id: 'charge-1' }),
|
|
makeRow({ invoice_id: 'inv-2', item_id: 'charge-2', invoice_number: 'INV-0002' }),
|
|
],
|
|
transactions: [
|
|
{ invoice_id: 'inv-1', transaction_id: 'txn-a' },
|
|
{ invoice_id: 'inv-1', transaction_id: 'txn-b' },
|
|
],
|
|
});
|
|
|
|
const lines = await selector.previewInvoiceLines({});
|
|
|
|
expect(lines.find((l) => l.invoiceId === 'inv-1')?.transactionIds).toEqual(['txn-a', 'txn-b']);
|
|
expect(lines.find((l) => l.invoiceId === 'inv-2')?.transactionIds).toEqual([]);
|
|
});
|
|
|
|
describe('selection filters', () => {
|
|
it('expands canonical status keys to include legacy Title Case statuses', async () => {
|
|
const { knex, selector } = makeSelector({ 'invoices as inv': [] });
|
|
|
|
await selector.previewInvoiceLines({ invoiceStatuses: ['sent', 'paid'] });
|
|
|
|
const whereInOp = findOps(knex, 'invoices as inv').find(
|
|
(op) => op.method === 'whereIn' && op.args[0] === 'inv.status'
|
|
);
|
|
expect(whereInOp).toBeDefined();
|
|
const statuses = whereInOp!.args[1] as string[];
|
|
expect(statuses).toEqual(expect.arrayContaining(['sent', 'paid', 'Unpaid', 'Paid']));
|
|
});
|
|
|
|
it('includes pending-external draft invoices for tax-delegating adapters when drafts are not requested', async () => {
|
|
const { knex, selector } = makeSelector({ 'invoices as inv': [] });
|
|
|
|
await selector.previewInvoiceLines({ adapterType: 'xero', invoiceStatuses: ['sent'] });
|
|
|
|
const ops = findOps(knex, 'invoices as inv');
|
|
expect(ops).toContainEqual({ method: 'where', args: ['inv.status', 'draft'] });
|
|
expect(ops).toContainEqual({ method: 'andWhere', args: ['inv.tax_source', 'pending_external'] });
|
|
});
|
|
|
|
it('does not add the pending-external branch when drafts are explicitly selected', async () => {
|
|
const { knex, selector } = makeSelector({ 'invoices as inv': [] });
|
|
|
|
await selector.previewInvoiceLines({ adapterType: 'xero', invoiceStatuses: ['draft'] });
|
|
|
|
const ops = findOps(knex, 'invoices as inv');
|
|
expect(ops).not.toContainEqual({ method: 'andWhere', args: ['inv.tax_source', 'pending_external'] });
|
|
});
|
|
|
|
it('does not add the pending-external branch for adapters without tax delegation', async () => {
|
|
const { knex, selector } = makeSelector({ 'invoices as inv': [] });
|
|
|
|
await selector.previewInvoiceLines({ adapterType: 'custom_csv', invoiceStatuses: ['sent'] });
|
|
|
|
const ops = findOps(knex, 'invoices as inv');
|
|
expect(ops).not.toContainEqual({ method: 'andWhere', args: ['inv.tax_source', 'pending_external'] });
|
|
});
|
|
|
|
it('excludes already-synced invoices only when an adapter type is provided', async () => {
|
|
const synced = makeSelector({ 'invoices as inv': [] });
|
|
await synced.selector.previewInvoiceLines({ adapterType: 'quickbooks_online' });
|
|
expect(
|
|
findOps(synced.knex, 'invoices as inv').some((op) => op.method === 'whereNotExists')
|
|
).toBe(true);
|
|
|
|
const unsynced = makeSelector({ 'invoices as inv': [] });
|
|
await unsynced.selector.previewInvoiceLines({});
|
|
expect(
|
|
findOps(unsynced.knex, 'invoices as inv').some((op) => op.method === 'whereNotExists')
|
|
).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AccountingExportInvoiceSelector.createBatchFromFilters', () => {
|
|
it('raises ACCOUNTING_EXPORT_EMPTY_BATCH when no invoices match the filters', async () => {
|
|
const { selector } = makeSelector({ 'invoices as inv': [] });
|
|
|
|
await expect(
|
|
selector.createBatchFromFilters({
|
|
adapterType: 'quickbooks_online',
|
|
targetRealm: 'realm-1',
|
|
filters: { invoiceStatuses: ['sent'] },
|
|
})
|
|
).rejects.toMatchObject({
|
|
constructor: AppError,
|
|
code: 'ACCOUNTING_EXPORT_EMPTY_BATCH',
|
|
});
|
|
});
|
|
});
|