import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hasPermission } from '@alga-psa/auth/rbac'; const CONTRACT_CADENCE_ROLLOUT_BLOCK_MESSAGE = 'Contract-owned cadence and mixed-cadence billing are not enabled during the client-cadence rollout.'; const createTenantKnex = vi.fn(); let currentTrx: any; vi.mock('@alga-psa/db', () => ({ createTenantKnex: (...args: any[]) => createTenantKnex(...args), withTransaction: async (_knex: unknown, fn: any) => fn(currentTrx), })); vi.mock('@alga-psa/auth', () => ({ withAuth: (fn: any) => (...args: any[]) => fn({ user_id: 'user-1' }, { tenant: 'tenant-1' }, ...args), })); vi.mock('@alga-psa/auth/rbac', () => ({ hasPermission: vi.fn(() => true), })); const analyticsCapture = vi.fn(); vi.mock('../src/lib/authHelpers', () => ({ getAnalyticsAsync: vi.fn(async () => ({ analytics: { capture: analyticsCapture }, AnalyticsEvents: { BILLING_RULE_CREATED: 'billing_rule_created', BILLING_RULE_UPDATED: 'billing_rule_updated', }, })), })); const contractLineCreate = vi.fn(); vi.mock('../src/models/contractLine', () => ({ default: { create: (...args: any[]) => contractLineCreate(...args), }, })); const presetFindById = vi.fn(); const presetCreate = vi.fn(); const presetUpdate = vi.fn(); vi.mock('../src/models/contractLinePreset', () => ({ default: { findById: (...args: any[]) => presetFindById(...args), create: (...args: any[]) => presetCreate(...args), update: (...args: any[]) => presetUpdate(...args), }, })); const presetServiceGetByPresetId = vi.fn(); vi.mock('../src/models/contractLinePresetService', () => ({ default: { getByPresetId: (...args: any[]) => presetServiceGetByPresetId(...args), }, })); const presetFixedConfigGetByPresetId = vi.fn(); vi.mock('../src/models/contractLinePresetFixedConfig', () => ({ default: { getByPresetId: (...args: any[]) => presetFixedConfigGetByPresetId(...args), }, })); const fixedConfigUpsert = vi.fn(); // The source instantiates these with `new`, so the mocks must be constructable classes. vi.mock('../src/models/contractLineFixedConfig', () => ({ default: class MockContractLineFixedConfig { upsert(...args: any[]) { return fixedConfigUpsert(...args); } }, })); const createConfiguration = vi.fn(); vi.mock('../src/services/contractLineServiceConfigurationService', () => ({ ContractLineServiceConfigurationService: class MockContractLineServiceConfigurationService { createConfiguration(...args: any[]) { return createConfiguration(...args); } }, })); // Post-creation service-period sync is a side effect outside the scope of // these persistence tests; stub it so the mock trx does not need the full // contract_lines/contracts join surface. const syncRecurringServicePeriods = vi.fn(async () => undefined); vi.mock('../src/actions/recurringServicePeriodSync', () => ({ syncRecurringServicePeriodsForContractLine: (...args: any[]) => syncRecurringServicePeriods(...args), })); const makeTrx = () => { const contractLinesBuilder: any = {}; contractLinesBuilder.where = vi.fn(() => contractLinesBuilder); contractLinesBuilder.count = vi.fn(() => contractLinesBuilder); contractLinesBuilder.first = vi.fn(async () => ({ count: '0' })); contractLinesBuilder.update = vi.fn(async () => 1); const contractLineServicesInsert = vi.fn(async () => []); const trx: any = vi.fn((table: string) => { if (table === 'contract_lines') { return contractLinesBuilder; } if (table === 'contract_line_services') { return { insert: contractLineServicesInsert, }; } throw new Error(`Unexpected table access: ${table}`); }); trx.fn = { now: vi.fn(() => 'now'), }; return { trx, contractLineServicesInsert, }; }; describe('contract line cadence_owner action persistence', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(hasPermission).mockReturnValue(true); const { trx } = makeTrx(); currentTrx = trx; createTenantKnex.mockResolvedValue({ knex: {} }); contractLineCreate.mockResolvedValue({ contract_line_id: 'line-1', contract_line_name: 'Managed Services', contract_line_type: 'Fixed', billing_frequency: 'monthly', }); presetCreate.mockImplementation(async (_trx: any, _tenant: string, preset: any) => ({ preset_id: 'preset-created', ...preset, })); presetUpdate.mockImplementation(async (_trx: any, _tenant: string, _presetId: string, preset: any) => ({ preset_id: 'preset-updated', ...preset, })); presetServiceGetByPresetId.mockResolvedValue([]); presetFixedConfigGetByPresetId.mockResolvedValue({ base_rate: 5000, enable_proration: false, billing_cycle_alignment: 'start', }); }); it('T106 and T233: createCustomContractLine persists explicit client cadence_owner and defaults missing cadence and timing values to the shared recurring authoring policy', async () => { const { createCustomContractLine } = await import('../src/actions/contractLinePresetActions'); await createCustomContractLine('contract-1', { contract_line_name: 'Managed Services', contract_line_type: 'Fixed', billing_frequency: 'monthly', cadence_owner: 'client', billing_timing: 'advance', enable_proration: true, base_rate: 15000, services: [{ service_id: 'svc-1', quantity: 1 }], }); expect(contractLineCreate.mock.calls[0]?.[1]).toMatchObject({ cadence_owner: 'client', billing_timing: 'advance', contract_line_name: 'Managed Services', }); expect(fixedConfigUpsert.mock.calls[0]?.[0]).toMatchObject({ enable_proration: true, billing_cycle_alignment: 'prorated', }); await createCustomContractLine('contract-1', { contract_line_name: 'Fallback Services', contract_line_type: 'Fixed', billing_frequency: 'monthly', services: [{ service_id: 'svc-2', quantity: 1 }], }); expect(contractLineCreate.mock.calls[1]?.[1]).toMatchObject({ cadence_owner: 'client', billing_timing: 'arrears', contract_line_name: 'Fallback Services', }); expect(fixedConfigUpsert.mock.calls[1]?.[0]).toMatchObject({ enable_proration: false, billing_cycle_alignment: 'start', }); }); it('preserves explicit client cadence overrides when copying presets into live contract lines', async () => { presetFindById.mockResolvedValue({ preset_id: 'preset-1', preset_name: 'Preset Services', contract_line_type: 'Fixed', billing_frequency: 'monthly', cadence_owner: 'contract', minimum_billable_time: null, round_up_to_nearest: null, }); const { copyPresetToContractLine } = await import('../src/actions/contractLinePresetActions'); await copyPresetToContractLine('contract-1', 'preset-1', { cadence_owner: 'client', }); expect(contractLineCreate).toHaveBeenCalledWith( currentTrx, expect.objectContaining({ cadence_owner: 'client', contract_line_name: 'Preset Services', }) ); }); it('T118: preset copies use stored cadence_owner defaults instead of inferring cadence from billing_cycle_alignment', async () => { const { copyPresetToContractLine } = await import('../src/actions/contractLinePresetActions'); presetFindById.mockResolvedValueOnce({ preset_id: 'preset-2', preset_name: 'Client Schedule Preset', contract_line_type: 'Fixed', billing_frequency: 'monthly', billing_timing: 'advance', cadence_owner: 'client', minimum_billable_time: null, round_up_to_nearest: null, }); await copyPresetToContractLine('contract-1', 'preset-2'); expect(contractLineCreate).toHaveBeenLastCalledWith( currentTrx, expect.objectContaining({ cadence_owner: 'client', billing_timing: 'advance', contract_line_name: 'Client Schedule Preset', }) ); expect(presetFixedConfigGetByPresetId).toHaveBeenLastCalledWith(currentTrx, 'preset-2'); }); it('T234: preset reuse replays stored recurring timing and partial-period defaults onto the created contract line', async () => { const { copyPresetToContractLine } = await import('../src/actions/contractLinePresetActions'); presetFindById.mockResolvedValueOnce({ preset_id: 'preset-recurring', preset_name: 'Advance Managed Services', contract_line_type: 'Fixed', billing_frequency: 'monthly', billing_timing: 'advance', cadence_owner: 'client', minimum_billable_time: null, round_up_to_nearest: null, }); presetFixedConfigGetByPresetId.mockResolvedValueOnce({ base_rate: 12500, enable_proration: true, billing_cycle_alignment: 'prorated', }); await copyPresetToContractLine('contract-1', 'preset-recurring'); expect(contractLineCreate).toHaveBeenLastCalledWith( currentTrx, expect.objectContaining({ contract_line_name: 'Advance Managed Services', cadence_owner: 'client', billing_timing: 'advance', }), ); expect(fixedConfigUpsert).toHaveBeenLastCalledWith( expect.objectContaining({ base_rate: 12500, enable_proration: true, billing_cycle_alignment: 'prorated', }), ); }); it('T143: action-layer contract line creation paths now allow contract cadence writes for supported frequencies', async () => { const { createCustomContractLine, copyPresetToContractLine } = await import('../src/actions/contractLinePresetActions'); await createCustomContractLine('contract-1', { contract_line_name: 'Allowed Contract Cadence', contract_line_type: 'Fixed', billing_frequency: 'monthly', cadence_owner: 'contract', services: [{ service_id: 'svc-1', quantity: 1 }], }); expect(contractLineCreate).toHaveBeenCalled(); presetFindById.mockResolvedValueOnce({ preset_id: 'preset-contract', preset_name: 'Contract Anniversary Preset', contract_line_type: 'Fixed', billing_frequency: 'monthly', cadence_owner: 'contract', minimum_billable_time: null, round_up_to_nearest: null, }); await copyPresetToContractLine('contract-1', 'preset-contract'); expect(contractLineCreate).toHaveBeenCalledWith( currentTrx, expect.objectContaining({ cadence_owner: 'contract', }), ); }); });