PSA/packages/billing/tests/tax/taxService.thresholdsAndComposite.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

245 lines
9.4 KiB
TypeScript

/**
* Unit tests for TaxService threshold-bracket and composite (multi-component)
* tax calculation paths (packages/billing/src/services/taxService.ts).
*
* These paths run when the client's default tax rate has thresholds or is a
* composite rate: bracket-by-bracket application, compound component stacking,
* component date applicability, and tax holidays.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const h = vi.hoisted(() => ({
tenant: 'tenant-1' as string | null,
knex: undefined as any,
}));
vi.mock('@alga-psa/db', () => ({
createTenantKnex: vi.fn(async () => ({ knex: h.knex, tenant: h.tenant })),
}));
vi.mock('../../src/models/clientTaxSettings', () => ({
default: {
get: vi.fn(),
getCompositeTaxComponents: vi.fn(),
getTaxRateThresholds: vi.fn(),
getTaxHolidays: vi.fn(),
},
}));
import { TaxService } from '../../src/services/taxService';
import ClientTaxSettings from '../../src/models/clientTaxSettings';
const DATE = '2025-03-15T00:00:00.000Z';
function createFakeKnex(results: Record<string, any[]>) {
const queues: Record<string, any[]> = {};
for (const [table, value] of Object.entries(results)) {
queues[table] = [...value];
}
const knex: any = (table: string) => {
const queue = queues[table] ?? [];
const result = queue.length > 0 ? queue.shift() : [];
const builder: any = {};
for (const method of ['where', 'andWhere', 'orWhere', 'whereIn', 'whereNull', 'whereNotNull', 'select', 'orderBy']) {
builder[method] = (...args: any[]) => {
for (const arg of args) {
if (typeof arg === 'function') arg.call(builder, builder);
}
return builder;
};
}
builder.first = () => Promise.resolve(Array.isArray(result) ? result[0] : result);
builder.then = (onFulfilled: any, onRejected: any) =>
Promise.resolve(Array.isArray(result) ? result : [result]).then(onFulfilled, onRejected);
return builder;
};
knex.raw = (sql: string) => sql;
return knex;
}
/** Wires up the default-rate lookup so calculateTax reaches simple/composite calculation. */
function setupDefaultRate(rate: Record<string, unknown>) {
h.knex = createFakeKnex({
clients: [[{ is_tax_exempt: false }]],
client_tax_rates: [[{ tax_rate_id: rate.tax_rate_id }]],
tax_rates: [[rate]],
});
}
describe('TaxService threshold-based tax', () => {
beforeEach(() => {
vi.clearAllMocks();
h.tenant = 'tenant-1';
vi.mocked(ClientTaxSettings.get).mockResolvedValue({
client_id: 'client-1',
tenant: 'tenant-1',
is_reverse_charge_applicable: false,
} as any);
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([]);
vi.mocked(ClientTaxSettings.getTaxHolidays).mockResolvedValue([]);
});
it('applies progressive brackets: each bracket taxed at its own rate', async () => {
setupDefaultRate({ tax_rate_id: 'rate-1', tax_percentage: 0, is_composite: false });
vi.mocked(ClientTaxSettings.getTaxRateThresholds).mockResolvedValue([
{ tax_rate_threshold_id: 't1', tax_rate_id: 'rate-1', min_amount: 0, max_amount: 100000, rate: 5 },
{ tax_rate_threshold_id: 't2', tax_rate_id: 'rate-1', min_amount: 100000, max_amount: null, rate: 10 },
] as any);
// 150000 cents: 100000 @ 5% = 5000, remaining 50000 @ 10% = 5000
const result = await new TaxService().calculateTax('client-1', 150000, DATE);
expect(result.taxAmount).toBe(10000);
expect(result.taxRate).toBeCloseTo((10000 / 150000) * 100, 10);
expect((result as any).appliedThresholds).toHaveLength(2);
});
it('only applies the brackets the amount actually reaches', async () => {
setupDefaultRate({ tax_rate_id: 'rate-1', tax_percentage: 0, is_composite: false });
vi.mocked(ClientTaxSettings.getTaxRateThresholds).mockResolvedValue([
{ tax_rate_threshold_id: 't1', tax_rate_id: 'rate-1', min_amount: 0, max_amount: 100000, rate: 5 },
{ tax_rate_threshold_id: 't2', tax_rate_id: 'rate-1', min_amount: 100000, max_amount: null, rate: 10 },
] as any);
const result = await new TaxService().calculateTax('client-1', 50000, DATE);
expect(result.taxAmount).toBe(2500);
expect((result as any).appliedThresholds).toHaveLength(1);
});
it('ceils fractional cents within each bracket', async () => {
setupDefaultRate({ tax_rate_id: 'rate-1', tax_percentage: 0, is_composite: false });
vi.mocked(ClientTaxSettings.getTaxRateThresholds).mockResolvedValue([
{ tax_rate_threshold_id: 't1', tax_rate_id: 'rate-1', min_amount: 0, max_amount: null, rate: 7.5 },
] as any);
// 333 * 7.5% = 24.975 -> 25
const result = await new TaxService().calculateTax('client-1', 333, DATE);
expect(result.taxAmount).toBe(25);
});
// NOTE (suspected product gap, intentionally NOT asserted):
// taxService.ts:293 (`calculateThresholdBasedTax`) and taxService.ts:235
// (`calculateCompositeTax`) compute `effectiveTaxRate = (taxAmount / netAmount) * 100`
// without a zero guard, so a netAmount of 0 yields a NaN taxRate on these
// paths (the simple-rate path at :253 does guard netAmount <= 0).
});
describe('TaxService composite tax', () => {
beforeEach(() => {
vi.clearAllMocks();
h.tenant = 'tenant-1';
vi.mocked(ClientTaxSettings.get).mockResolvedValue({
client_id: 'client-1',
tenant: 'tenant-1',
is_reverse_charge_applicable: false,
} as any);
vi.mocked(ClientTaxSettings.getTaxRateThresholds).mockResolvedValue([]);
vi.mocked(ClientTaxSettings.getTaxHolidays).mockResolvedValue([]);
});
it('stacks compound components on the increased taxable base', async () => {
setupDefaultRate({ tax_rate_id: 'rate-c', tax_percentage: 7, is_composite: true });
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([
{ tax_component_id: 'c1', tax_rate_id: 'rate-c', name: 'GST', rate: 5, sequence: 1, is_compound: true },
{ tax_component_id: 'c2', tax_rate_id: 'rate-c', name: 'PST', rate: 2, sequence: 2, is_compound: false },
] as any);
// c1: 10000 * 5% = 500 (compound -> base becomes 10500)
// c2: 10500 * 2% = 210
const result = await new TaxService().calculateTax('client-1', 10000, DATE);
expect(result.taxAmount).toBe(710);
expect(result.taxRate).toBeCloseTo(7.1, 10);
expect(result.taxComponents).toHaveLength(2);
});
it('does not compound when components are independent', async () => {
setupDefaultRate({ tax_rate_id: 'rate-c', tax_percentage: 7, is_composite: true });
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([
{ tax_component_id: 'c1', tax_rate_id: 'rate-c', name: 'GST', rate: 5, sequence: 1, is_compound: false },
{ tax_component_id: 'c2', tax_rate_id: 'rate-c', name: 'PST', rate: 2, sequence: 2, is_compound: false },
] as any);
const result = await new TaxService().calculateTax('client-1', 10000, DATE);
expect(result.taxAmount).toBe(700);
});
it('skips components that are not yet (or no longer) effective on the calculation date', async () => {
setupDefaultRate({ tax_rate_id: 'rate-c', tax_percentage: 5, is_composite: true });
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([
{
tax_component_id: 'future',
tax_rate_id: 'rate-c',
name: 'Future levy',
rate: 3,
sequence: 1,
is_compound: false,
start_date: '2026-01-01T00:00:00.000Z',
},
{
tax_component_id: 'expired',
tax_rate_id: 'rate-c',
name: 'Expired levy',
rate: 4,
sequence: 2,
is_compound: false,
end_date: '2024-12-31T00:00:00.000Z',
},
{
tax_component_id: 'active',
tax_rate_id: 'rate-c',
name: 'Active levy',
rate: 5,
sequence: 3,
is_compound: false,
},
] as any);
const result = await new TaxService().calculateTax('client-1', 10000, DATE);
expect(result.taxAmount).toBe(500);
expect(result.taxComponents?.map((c) => c.tax_component_id)).toEqual(['active']);
});
it('zeroes a component during an applicable tax holiday', async () => {
setupDefaultRate({ tax_rate_id: 'rate-c', tax_percentage: 5, is_composite: true });
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([
{ tax_component_id: 'c1', tax_rate_id: 'rate-c', name: 'GST', rate: 5, sequence: 1, is_compound: false },
] as any);
vi.mocked(ClientTaxSettings.getTaxHolidays).mockResolvedValue([
{
tax_holiday_id: 'hol-1',
tax_rate_id: 'rate-c',
start_date: '2025-03-01T00:00:00.000Z',
end_date: '2025-03-31T00:00:00.000Z',
},
] as any);
const result = await new TaxService().calculateTax('client-1', 10000, DATE);
expect(result.taxAmount).toBe(0);
});
it('does not apply a holiday outside its window', async () => {
setupDefaultRate({ tax_rate_id: 'rate-c', tax_percentage: 5, is_composite: true });
vi.mocked(ClientTaxSettings.getCompositeTaxComponents).mockResolvedValue([
{ tax_component_id: 'c1', tax_rate_id: 'rate-c', name: 'GST', rate: 5, sequence: 1, is_compound: false },
] as any);
vi.mocked(ClientTaxSettings.getTaxHolidays).mockResolvedValue([
{
tax_holiday_id: 'hol-1',
tax_rate_id: 'rate-c',
start_date: '2025-04-01T00:00:00.000Z',
end_date: '2025-04-30T00:00:00.000Z',
},
] as any);
const result = await new TaxService().calculateTax('client-1', 10000, DATE);
expect(result.taxAmount).toBe(500);
});
});