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
245 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|