PSA/packages/billing/tests/draftContractForResumeActions.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

848 lines
26 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { hasPermission } from '@alga-psa/auth/rbac';
const createTenantKnex = vi.fn();
vi.mock('@alga-psa/db', () => ({
createTenantKnex: (...args: any[]) => createTenantKnex(...args),
withTransaction: async (_knex: unknown, fn: any) => fn(_knex),
}));
vi.mock('@alga-psa/auth/withAuth', () => ({
withAuth:
(fn: any) =>
(...args: any[]) =>
fn({ id: 'user-1' }, { tenant: 'tenant-1' }, ...args),
}));
vi.mock('@alga-psa/auth/getCurrentUser', () => ({
getCurrentUser: vi.fn(async () => ({
id: 'user-1',
tenant: 'tenant-1',
roles: [],
})),
}));
vi.mock('@alga-psa/auth/rbac', () => ({
hasPermission: vi.fn(() => true),
}));
vi.mock('@alga-psa/event-bus/publishers', () => ({
publishWorkflowEvent: vi.fn(),
}));
vi.mock('@alga-psa/workflow-streams/domainEventBuilders/contractEventBuilders', () => ({
buildContractCreatedPayload: vi.fn(() => ({})),
buildContractRenewalUpcomingPayload: vi.fn(() => ({})),
computeContractRenewalUpcoming: vi.fn(() => null),
}));
const fetchDetailedContractLines = vi.fn();
vi.mock('../src/repositories/contractLineRepository', () => ({
fetchDetailedContractLines: (...args: any[]) => fetchDetailedContractLines(...args),
ensureTemplateLineSnapshot: vi.fn(),
}));
const getContractLineServicesWithConfigurations = vi.fn();
const getTemplateLineServicesWithConfigurations = vi.fn();
vi.mock('../src/actions/contractLineServiceActions', () => ({
getContractLineServicesWithConfigurations: (...args: any[]) =>
getContractLineServicesWithConfigurations(...args),
getTemplateLineServicesWithConfigurations: (...args: any[]) =>
getTemplateLineServicesWithConfigurations(...args),
}));
vi.mock('../src/actions/bucketOverlayActions', () => ({
upsertBucketOverlayInTransaction: vi.fn(),
}));
type KnexRow = Record<string, unknown> | null;
const makeKnex = (rows: {
contracts?: KnexRow;
client_contracts?: KnexRow;
contract_templates?: KnexRow;
service_catalog_mode_defaults?: Array<{ service_id: string; rate: number }>;
}) => {
const builderFor = (table: string) => {
const builder: any = {};
let modeDefaultFilter: { serviceIds?: string[]; billingMode?: string; currencyCode?: string } = {};
builder.where = vi.fn(() => builder);
builder.whereIn = vi.fn((column: string, values: string[]) => {
if (table === 'service_catalog_mode_defaults' && column === 'service_id') {
modeDefaultFilter = { ...modeDefaultFilter, serviceIds: values };
}
return builder;
});
builder.andWhere = vi.fn((...args: any[]) => {
if (typeof args[0] === 'function') {
const whereBuilder = {
whereNull: vi.fn(() => whereBuilder),
orWhere: vi.fn(() => whereBuilder),
};
args[0](whereBuilder);
}
return builder;
});
builder.select = vi.fn(async () => {
if (table !== 'service_catalog_mode_defaults') {
return [];
}
const rowsForModeDefaults = rows.service_catalog_mode_defaults ?? [];
return rowsForModeDefaults.filter((row) => {
if (modeDefaultFilter.serviceIds && !modeDefaultFilter.serviceIds.includes(row.service_id)) {
return false;
}
return true;
});
});
builder.first = vi.fn(async () => {
if (table === 'contracts') return rows.contracts ?? null;
if (table === 'client_contracts') return rows.client_contracts ?? null;
if (table === 'contract_templates') return rows.contract_templates ?? null;
return null;
});
builder.where = vi.fn((conditions?: Record<string, unknown>) => {
if (table === 'service_catalog_mode_defaults' && conditions) {
modeDefaultFilter = {
...modeDefaultFilter,
billingMode: typeof conditions.billing_mode === 'string' ? conditions.billing_mode : modeDefaultFilter.billingMode,
currencyCode:
typeof conditions.currency_code === 'string' ? conditions.currency_code : modeDefaultFilter.currencyCode,
};
}
return builder;
});
return builder;
};
const knex: any = vi.fn((table: string) => builderFor(table));
return knex;
};
describe('getDraftContractForResume action', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(hasPermission).mockReturnValue(true);
});
it('returns complete wizard data for a draft (T027)', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: 'Desc',
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([]);
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result).toMatchObject({
contract_id: 'contract-1',
is_draft: true,
client_id: 'client-1',
contract_name: 'Draft Alpha',
billing_frequency: 'monthly',
currency_code: 'USD',
});
expect(Array.isArray(result.fixed_services)).toBe(true);
expect(Array.isArray(result.product_services)).toBe(true);
expect(Array.isArray(result.hourly_services)).toBe(true);
expect(Array.isArray(result.usage_services)).toBe(true);
});
it('includes contract lines (T028)', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: null,
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'line-1',
contract_line_type: 'Fixed',
// Rates are stored in cents in the database.
rate: 1000,
enable_proration: false,
billing_frequency: 'monthly',
},
]);
getContractLineServicesWithConfigurations.mockResolvedValue([
{
service: { service_id: 'svc-1', service_name: 'Service 1', item_kind: 'service' },
configuration: { quantity: 2 },
bucketConfig: null,
},
]);
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result.fixed_base_rate).toBe(1000);
expect(result.fixed_services).toEqual([
{
service_id: 'svc-1',
service_name: 'Service 1',
quantity: 2,
bucket_overlay: undefined,
},
]);
});
it('returns cadence_owner from recurring draft lines and defaults missing values to client', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: null,
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'fixed-1',
contract_line_type: 'Fixed',
rate: 1000,
enable_proration: false,
billing_frequency: 'monthly',
cadence_owner: 'contract',
},
{
contract_line_id: 'hourly-1',
contract_line_type: 'Hourly',
billing_frequency: 'monthly',
},
]);
getContractLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'fixed-1') {
return [
{
service: { service_id: 'svc-fixed', service_name: 'Fixed Service', item_kind: 'service' },
configuration: { quantity: 1 },
bucketConfig: null,
},
];
}
if (lineId === 'hourly-1') {
return [
{
service: { service_id: 'svc-hourly', service_name: 'Hourly Service', item_kind: 'service' },
configuration: {},
typeConfig: {
hourly_rate: 12500,
minimum_billable_time: 15,
round_up_to_nearest: 5,
},
bucketConfig: null,
},
];
}
return [];
});
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result.cadence_owner).toBe('contract');
});
it('returns partial-period defaults alongside cadence_owner when resuming a recurring draft', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: null,
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'fixed-1',
contract_line_type: 'Fixed',
rate: 1000,
enable_proration: true,
billing_frequency: 'monthly',
cadence_owner: 'contract',
},
]);
getContractLineServicesWithConfigurations.mockResolvedValue([
{
service: { service_id: 'svc-fixed', service_name: 'Fixed Service', item_kind: 'service' },
configuration: { quantity: 1 },
bucketConfig: null,
},
]);
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result).toMatchObject({
cadence_owner: 'contract',
enable_proration: true,
fixed_base_rate: 1000,
});
});
it('includes service configurations (T029)', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: null,
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'fixed-1',
contract_line_type: 'Fixed',
// Rates are stored in cents in the database.
rate: 2500,
enable_proration: true,
billing_frequency: 'monthly',
},
{
contract_line_id: 'hourly-1',
contract_line_type: 'Hourly',
billing_frequency: 'monthly',
},
{
contract_line_id: 'usage-1',
contract_line_type: 'Usage',
billing_frequency: 'monthly',
},
]);
getContractLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'fixed-1') {
return [
{
service: { service_id: 'svc-fixed', service_name: 'Fixed Service', item_kind: 'service' },
configuration: { quantity: 1 },
bucketConfig: {
total_minutes: 120,
overage_rate: 1500,
allow_rollover: true,
billing_period: 'weekly',
},
},
];
}
if (lineId === 'hourly-1') {
return [
{
service: { service_id: 'svc-hourly', service_name: 'Hourly Service', item_kind: 'service' },
configuration: {},
typeConfig: {
hourly_rate: 12500,
minimum_billable_time: 15,
round_up_to_nearest: 5,
},
bucketConfig: {
total_minutes: 60,
overage_rate: 2000,
allow_rollover: false,
billing_period: 'monthly',
},
},
];
}
if (lineId === 'usage-1') {
return [
{
service: { service_id: 'svc-usage', service_name: 'Usage Service', item_kind: 'service', unit_of_measure: 'seat' },
configuration: {},
typeConfig: {
base_rate: 300,
unit_of_measure: 'seat',
enable_tiered_pricing: false,
},
bucketConfig: {
total_minutes: 30,
overage_rate: 2500,
allow_rollover: true,
billing_period: 'monthly',
},
},
];
}
return [];
});
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result.fixed_base_rate).toBe(2500);
expect(result.fixed_services[0]?.bucket_overlay).toEqual({
total_minutes: 120,
overage_rate: 1500,
allow_rollover: true,
billing_period: 'weekly',
});
expect(result.hourly_services[0]).toMatchObject({
service_id: 'svc-hourly',
hourly_rate: 12500,
bucket_overlay: {
total_minutes: 60,
overage_rate: 2000,
allow_rollover: false,
billing_period: 'monthly',
},
});
expect(result.minimum_billable_time).toBe(15);
expect(result.round_up_to_nearest).toBe(5);
expect(result.usage_services[0]).toMatchObject({
service_id: 'svc-usage',
unit_rate: 300,
unit_of_measure: 'seat',
bucket_overlay: {
total_minutes: 30,
overage_rate: 2500,
allow_rollover: true,
billing_period: 'monthly',
},
});
});
it('T026: resume preserves decoupled selections and mode-default prefills when stored rates are empty', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Draft Alpha',
contract_description: null,
status: 'draft',
billing_frequency: 'monthly',
currency_code: 'USD',
},
client_contracts: {
contract_id: 'contract-1',
client_id: 'client-1',
start_date: '2026-01-01T00:00:00.000Z',
end_date: null,
po_required: false,
po_number: null,
po_amount: null,
template_contract_id: null,
},
service_catalog_mode_defaults: [
{ service_id: 'svc-hourly', rate: 10100 },
{ service_id: 'svc-usage', rate: 575 },
],
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'hourly-1',
contract_line_type: 'Hourly',
billing_frequency: 'monthly',
},
{
contract_line_id: 'usage-1',
contract_line_type: 'Usage',
billing_frequency: 'monthly',
},
]);
getContractLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'hourly-1') {
return [
{
service: {
service_id: 'svc-hourly',
service_name: 'Hourly Service',
item_kind: 'service',
default_rate: 9200,
},
configuration: { custom_rate: 0 },
typeConfig: {
hourly_rate: 0,
minimum_billable_time: 15,
round_up_to_nearest: 5,
},
bucketConfig: null,
},
];
}
if (lineId === 'usage-1') {
return [
{
service: {
service_id: 'svc-usage',
service_name: 'Usage Service',
item_kind: 'service',
default_rate: 375,
unit_of_measure: 'seat',
},
configuration: { custom_rate: 0 },
typeConfig: {
base_rate: 0,
unit_of_measure: 'seat',
enable_tiered_pricing: false,
},
bucketConfig: null,
},
];
}
return [];
});
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
const result = await getDraftContractForResume('contract-1');
expect(result.hourly_services[0]).toMatchObject({
service_id: 'svc-hourly',
hourly_rate: 10100,
});
expect(result.usage_services[0]).toMatchObject({
service_id: 'svc-usage',
unit_rate: 575,
});
});
it('T027: template snapshot preserves decoupled selections and mode-default prefills when stored rates are empty', async () => {
const knex = makeKnex({
contract_templates: {
template_id: 'template-1',
template_name: 'Template Alpha',
template_description: 'Test',
default_billing_frequency: 'monthly',
},
service_catalog_mode_defaults: [
{ service_id: 'svc-hourly', rate: 14400 },
{ service_id: 'svc-usage', rate: 880 },
],
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'hourly-template-line',
contract_line_type: 'Hourly',
},
{
contract_line_id: 'usage-template-line',
contract_line_type: 'Usage',
},
]);
getTemplateLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'hourly-template-line') {
return [
{
service: {
service_id: 'svc-hourly',
service_name: 'Hourly Service',
item_kind: 'service',
default_rate: 11300,
},
configuration: { custom_rate: 0 },
typeConfig: {
hourly_rate: 0,
minimum_billable_time: 20,
round_up_to_nearest: 10,
},
bucketConfig: null,
},
];
}
if (lineId === 'usage-template-line') {
return [
{
service: {
service_id: 'svc-usage',
service_name: 'Usage Service',
item_kind: 'service',
default_rate: 640,
unit_of_measure: 'device',
},
configuration: { custom_rate: 0 },
typeConfig: {
base_rate: 0,
unit_of_measure: 'device',
enable_tiered_pricing: false,
},
bucketConfig: null,
},
];
}
return [];
});
const { getContractTemplateSnapshotForClientWizard } = await import('../src/actions/contractWizardActions');
const snapshot = await getContractTemplateSnapshotForClientWizard('template-1');
expect(snapshot.hourly_services?.[0]).toMatchObject({
service_id: 'svc-hourly',
hourly_rate: 14400,
});
expect(snapshot.usage_services?.[0]).toMatchObject({
service_id: 'svc-usage',
unit_rate: 880,
});
});
it('returns template snapshot bucket overlays for hourly and usage services', async () => {
const knex = makeKnex({
contract_templates: {
template_id: 'template-1',
template_name: 'Template Alpha',
template_description: 'Test',
default_billing_frequency: 'monthly',
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'hourly-template-line',
contract_line_type: 'Hourly',
},
{
contract_line_id: 'usage-template-line',
contract_line_type: 'Usage',
},
]);
getTemplateLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'hourly-template-line') {
return [
{
service: {
service_id: 'svc-hourly',
service_name: 'Hourly Service',
item_kind: 'service',
default_rate: 11300,
},
configuration: { custom_rate: 0 },
typeConfig: {
hourly_rate: 0,
minimum_billable_time: 20,
round_up_to_nearest: 10,
},
bucketConfig: {
total_minutes: 180,
overage_rate: 25000,
allow_rollover: true,
billing_period: 'weekly',
},
},
];
}
if (lineId === 'usage-template-line') {
return [
{
service: {
service_id: 'svc-usage',
service_name: 'Usage Service',
item_kind: 'service',
default_rate: 640,
unit_of_measure: 'device',
},
configuration: { custom_rate: 0 },
typeConfig: {
base_rate: 0,
unit_of_measure: 'device',
enable_tiered_pricing: false,
},
bucketConfig: {
total_minutes: 25,
overage_rate: 1500,
allow_rollover: false,
billing_period: 'monthly',
},
},
];
}
return [];
});
const { getContractTemplateSnapshotForClientWizard } = await import('../src/actions/contractWizardActions');
const snapshot = await getContractTemplateSnapshotForClientWizard('template-1');
expect(snapshot.hourly_services?.[0]?.bucket_overlay).toEqual({
total_minutes: 180,
overage_rate: 25000,
allow_rollover: true,
billing_period: 'weekly',
});
expect(snapshot.usage_services?.[0]?.bucket_overlay).toEqual({
total_minutes: 25,
overage_rate: 1500,
allow_rollover: false,
billing_period: 'monthly',
});
});
it('returns cadence_owner from template fixed lines and defaults missing values to client', async () => {
const knex = makeKnex({
contract_templates: {
template_id: 'template-1',
template_name: 'Template Alpha',
template_description: 'Test',
default_billing_frequency: 'monthly',
},
});
createTenantKnex.mockResolvedValue({ knex });
fetchDetailedContractLines.mockResolvedValue([
{
contract_line_id: 'fixed-template-line',
contract_line_type: 'Fixed',
cadence_owner: 'contract',
},
{
contract_line_id: 'hourly-template-line',
contract_line_type: 'Hourly',
},
]);
getTemplateLineServicesWithConfigurations.mockImplementation(async (lineId: string) => {
if (lineId === 'fixed-template-line') {
return [
{
service: {
service_id: 'svc-fixed',
service_name: 'Fixed Service',
item_kind: 'service',
},
configuration: { quantity: 1 },
typeConfig: null,
bucketConfig: null,
},
];
}
if (lineId === 'hourly-template-line') {
return [
{
service: {
service_id: 'svc-hourly',
service_name: 'Hourly Service',
item_kind: 'service',
default_rate: 11300,
},
configuration: { custom_rate: 0 },
typeConfig: {
hourly_rate: 0,
minimum_billable_time: 20,
round_up_to_nearest: 10,
},
bucketConfig: null,
},
];
}
return [];
});
const { getContractTemplateSnapshotForClientWizard } = await import('../src/actions/contractWizardActions');
const snapshot = await getContractTemplateSnapshotForClientWizard('template-1');
expect(snapshot.cadence_owner).toBe('contract');
});
it('throws error if contract is not a draft (T030)', async () => {
const knex = makeKnex({
contracts: {
contract_id: 'contract-1',
contract_name: 'Active Contract',
status: 'active',
},
client_contracts: null,
});
createTenantKnex.mockResolvedValue({ knex });
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
await expect(getDraftContractForResume('contract-1')).rejects.toThrow('Contract is not a draft');
});
it('user without contract create permission cannot resume drafts (T063)', async () => {
vi.mocked(hasPermission).mockImplementation((_user, _domain, action) => action !== 'create');
createTenantKnex.mockResolvedValue({ knex: makeKnex({ contracts: null, client_contracts: null }) });
const { getDraftContractForResume } = await import('../src/actions/contractWizardActions');
await expect(getDraftContractForResume('contract-1')).resolves.toMatchObject({
permissionError: 'Permission denied: Cannot resume billing contracts',
});
});
});