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
924 lines
31 KiB
TypeScript
924 lines
31 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import React from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import '@testing-library/jest-dom/vitest';
|
|
|
|
// Stable t()/formatters so hooks that list `t` in effect dependencies do not
|
|
// re-run on every render (react-i18next's no-instance fallback returns an
|
|
// unstable t, which sends ContractWizard's mount effect into a render loop).
|
|
vi.mock('@alga-psa/ui/lib/i18n/client', () => {
|
|
const t = (key: string, opts?: string | { defaultValue?: string }) => {
|
|
if (typeof opts === 'string') return opts;
|
|
return typeof opts?.defaultValue === 'string' ? opts.defaultValue : key;
|
|
};
|
|
const translation = { t };
|
|
const formatters = {
|
|
formatDate: (value: unknown) => String(value),
|
|
formatCurrency: (value: number) => `$${value}`,
|
|
};
|
|
return {
|
|
useTranslation: () => translation,
|
|
useFormatters: () => formatters,
|
|
};
|
|
});
|
|
|
|
vi.mock('@alga-psa/ui/components/Dialog', () => ({
|
|
Dialog: ({ isOpen, children, footer }: { isOpen: boolean; children: React.ReactNode; footer?: React.ReactNode }) =>
|
|
isOpen ? (
|
|
<div data-testid="dialog">
|
|
{children}
|
|
{footer}
|
|
</div>
|
|
) : null,
|
|
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/onboarding/WizardProgress', () => ({
|
|
WizardProgress: ({ currentStep }: { currentStep: number }) => (
|
|
<div data-testid="wizard-progress" data-current-step={String(currentStep)} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/onboarding/WizardNavigation', () => ({
|
|
WizardNavigation: ({
|
|
onNext,
|
|
onBack,
|
|
onSaveDraft,
|
|
onFinish,
|
|
}: {
|
|
onNext: () => void;
|
|
onBack: () => void;
|
|
onSaveDraft: () => void;
|
|
onFinish: () => void;
|
|
}) => (
|
|
<div>
|
|
<button type="button" onClick={onBack}>
|
|
Back
|
|
</button>
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
<button type="button" onClick={onSaveDraft}>
|
|
Save Draft
|
|
</button>
|
|
<button type="button" onClick={onFinish}>
|
|
Finish
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ContractBasicsStep', () => ({
|
|
ContractBasicsStep: ({
|
|
data,
|
|
updateData,
|
|
onTemplateSelect,
|
|
}: {
|
|
data: any;
|
|
updateData: (next: Record<string, unknown>) => void;
|
|
onTemplateSelect?: (templateId: string | null) => void;
|
|
}) => (
|
|
<div
|
|
data-testid="step-contract-basics"
|
|
data-client-id={data.client_id ?? ''}
|
|
data-contract-name={data.contract_name ?? ''}
|
|
data-start-date={data.start_date ?? ''}
|
|
data-end-date={data.end_date ?? ''}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateData({
|
|
client_id: 'client-1',
|
|
contract_name: 'Seeded Contract',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
})
|
|
}
|
|
>
|
|
Seed valid basics
|
|
</button>
|
|
<button type="button" onClick={() => onTemplateSelect?.('template-1')}>
|
|
Apply Template
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/FixedFeeServicesStep', () => ({
|
|
FixedFeeServicesStep: ({
|
|
data,
|
|
updateData,
|
|
}: {
|
|
data: any;
|
|
updateData: (next: Record<string, unknown>) => void;
|
|
}) => (
|
|
<div
|
|
data-testid="step-fixed-fee"
|
|
data-fixed-services-count={String((data.fixed_services ?? []).length)}
|
|
data-cadence-owner={data.cadence_owner ?? 'client'}
|
|
data-billing-timing={data.billing_timing ?? 'arrears'}
|
|
data-enable-proration={String(Boolean(data.enable_proration))}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => updateData({ enable_proration: !data.enable_proration })}
|
|
>
|
|
Toggle Partial-Period Adjustment
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ProductsStep', () => ({
|
|
ProductsStep: ({ data }: { data: any }) => (
|
|
<div data-testid="step-products" data-product-services-count={String((data.product_services ?? []).length)} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/HourlyServicesStep', () => ({
|
|
HourlyServicesStep: ({ data }: { data: any }) => (
|
|
<div data-testid="step-hourly" data-hourly-services-count={String((data.hourly_services ?? []).length)} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/UsageBasedServicesStep', () => ({
|
|
UsageBasedServicesStep: ({ data }: { data: any }) => (
|
|
<div data-testid="step-usage" data-usage-services-count={String((data.usage_services ?? []).length)} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('../src/components/billing-dashboard/contracts/wizard-steps/ReviewContractStep', () => ({
|
|
ReviewContractStep: ({ data }: { data: any }) => (
|
|
<div
|
|
data-testid="step-review"
|
|
data-contract-name={data.contract_name ?? ''}
|
|
data-fixed-services-count={String((data.fixed_services ?? []).length)}
|
|
data-product-services-count={String((data.product_services ?? []).length)}
|
|
data-hourly-services-count={String((data.hourly_services ?? []).length)}
|
|
data-usage-services-count={String((data.usage_services ?? []).length)}
|
|
data-cadence-owner={data.cadence_owner ?? 'client'}
|
|
data-billing-timing={data.billing_timing ?? 'arrears'}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/contractWizardActions', () => ({
|
|
createClientContractFromWizard: vi.fn(),
|
|
listContractTemplatesForWizard: vi.fn(async () => []),
|
|
getContractTemplateSnapshotForClientWizard: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/billingSettingsActions', () => ({
|
|
getDefaultBillingSettings: vi.fn(async () => ({
|
|
defaultRenewalMode: 'manual',
|
|
defaultNoticePeriodDays: 30,
|
|
})),
|
|
}));
|
|
|
|
describe('ContractWizard resume behavior', () => {
|
|
beforeEach(() => {
|
|
document.body.removeAttribute('data-scroll-locked');
|
|
document.body.removeAttribute('style');
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('starts at Step 1 (Contract Basics) when opened (T033)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('step-contract-basics')).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByTestId('step-fixed-fee')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('step 1 shows pre-populated client selection from draft (T034)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-99',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const step = await screen.findByTestId('step-contract-basics');
|
|
expect(step).toHaveAttribute('data-client-id', 'client-99');
|
|
});
|
|
|
|
it('step 1 shows pre-populated contract name from draft (T035)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Name',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const step = await screen.findByTestId('step-contract-basics');
|
|
expect(step).toHaveAttribute('data-contract-name', 'Draft Name');
|
|
});
|
|
|
|
it('step 1 shows pre-populated dates from draft (T036)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
end_date: '2026-12-31',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const step = await screen.findByTestId('step-contract-basics');
|
|
expect(step).toHaveAttribute('data-start-date', '2026-01-01');
|
|
expect(step).toHaveAttribute('data-end-date', '2026-12-31');
|
|
});
|
|
|
|
it('step 2 (Fixed Fee) shows pre-populated services from draft (T037)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [
|
|
{ service_id: 'svc-1', quantity: 1 },
|
|
{ service_id: 'svc-2', quantity: 2 },
|
|
],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const step = await screen.findByTestId('step-fixed-fee');
|
|
expect(step).toHaveAttribute('data-fixed-services-count', '2');
|
|
});
|
|
|
|
it('preserves cadence_owner from resumed drafts across fixed-fee step transitions (T113)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
cadence_owner: 'contract',
|
|
billing_timing: 'advance',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const fixedStep = await screen.findByTestId('step-fixed-fee');
|
|
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Back'));
|
|
});
|
|
|
|
expect(await screen.findByTestId('step-fixed-fee')).toHaveAttribute('data-cadence-owner', 'contract');
|
|
});
|
|
|
|
it('step 3 (Products) shows pre-populated products from draft (T038)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [
|
|
{ service_id: 'prod-1', quantity: 1 },
|
|
{ service_id: 'prod-2', quantity: 2 },
|
|
],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const step = await screen.findByTestId('step-products');
|
|
expect(step).toHaveAttribute('data-product-services-count', '2');
|
|
});
|
|
|
|
it('step 4 (Hourly) shows pre-populated hourly services from draft (T039)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [
|
|
{ service_id: 'hr-1' },
|
|
{ service_id: 'hr-2' },
|
|
],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const step = await screen.findByTestId('step-hourly');
|
|
expect(step).toHaveAttribute('data-hourly-services-count', '2');
|
|
});
|
|
|
|
it('step 5 (Usage) shows pre-populated usage services from draft (T040)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [
|
|
{ service_id: 'u-1' },
|
|
{ service_id: 'u-2' },
|
|
{ service_id: 'u-3' },
|
|
],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
for (let i = 0; i < 4; i++) {
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
}
|
|
|
|
const step = await screen.findByTestId('step-usage');
|
|
expect(step).toHaveAttribute('data-usage-services-count', '3');
|
|
});
|
|
|
|
it('step 6 (Review) shows complete draft data for review (T041)', async () => {
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
product_services: [{ service_id: 'prod-1', quantity: 1 }],
|
|
hourly_services: [{ service_id: 'hr-1' }],
|
|
usage_services: [{ service_id: 'u-1' }],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
for (let i = 0; i < 5; i++) {
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
}
|
|
|
|
const step = await screen.findByTestId('step-review');
|
|
expect(step).toHaveAttribute('data-contract-name', 'Draft Alpha');
|
|
expect(step).toHaveAttribute('data-fixed-services-count', '1');
|
|
expect(step).toHaveAttribute('data-product-services-count', '1');
|
|
expect(step).toHaveAttribute('data-hourly-services-count', '1');
|
|
expect(step).toHaveAttribute('data-usage-services-count', '1');
|
|
});
|
|
|
|
it('clicking Save Draft in resumed wizard updates existing draft (T042)', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Save Draft'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalled();
|
|
});
|
|
|
|
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(submission.contract_id).toBe('contract-1');
|
|
expect(options).toEqual({ isDraft: true });
|
|
});
|
|
|
|
it('resaving a resumed draft preserves cadence_owner and partial-period defaults in the emitted draft payload', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
|
|
|
|
const onComplete = vi.fn();
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
onComplete={onComplete}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
cadence_owner: 'contract',
|
|
billing_timing: 'advance',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const fixedStep = await screen.findByTestId('step-fixed-fee');
|
|
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
|
|
expect(fixedStep).toHaveAttribute('data-billing-timing', 'advance');
|
|
expect(fixedStep).toHaveAttribute('data-enable-proration', 'false');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Toggle Partial-Period Adjustment'));
|
|
await user.click(screen.getByText('Save Draft'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onComplete).toHaveBeenCalled();
|
|
});
|
|
|
|
const [draftPayload] = onComplete.mock.calls.at(-1)!;
|
|
expect(draftPayload).toMatchObject({
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
cadence_owner: 'contract',
|
|
billing_timing: 'advance',
|
|
enable_proration: true,
|
|
});
|
|
});
|
|
|
|
it('applies template-authored cadence_owner and billing_timing to the client contract submission payload (T236)', async () => {
|
|
const {
|
|
createClientContractFromWizard,
|
|
getContractTemplateSnapshotForClientWizard,
|
|
} = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-template' });
|
|
(getContractTemplateSnapshotForClientWizard as any).mockResolvedValue({
|
|
contract_name: 'Template Contract',
|
|
billing_frequency: 'monthly',
|
|
cadence_owner: 'contract',
|
|
billing_timing: 'advance',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-template', quantity: 1 }],
|
|
});
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(<ContractWizard open={true} onOpenChange={vi.fn()} />);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Seed valid basics'));
|
|
await user.click(screen.getByText('Apply Template'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(getContractTemplateSnapshotForClientWizard).toHaveBeenCalledWith('template-1');
|
|
});
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
|
|
const fixedStep = await screen.findByTestId('step-fixed-fee');
|
|
expect(fixedStep).toHaveAttribute('data-cadence-owner', 'contract');
|
|
expect(fixedStep).toHaveAttribute('data-billing-timing', 'advance');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Save Draft'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(options).toEqual({ isDraft: true });
|
|
expect(submission).toMatchObject({
|
|
template_id: 'template-1',
|
|
cadence_owner: 'contract',
|
|
billing_timing: 'advance',
|
|
enable_proration: false,
|
|
fixed_services: [{ service_id: 'svc-template', quantity: 1 }],
|
|
});
|
|
});
|
|
|
|
it('save draft does not create a duplicate contract (T043)', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-99' });
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-99',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Save Draft'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const [submission] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(submission.contract_id).toBe('contract-99');
|
|
});
|
|
|
|
it('save draft preserves the original contract_id (T044)', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
|
|
|
|
const onComplete = vi.fn();
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
onComplete={onComplete}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_services: [],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Save Draft'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onComplete).toHaveBeenCalled();
|
|
});
|
|
|
|
const [data] = onComplete.mock.calls[0];
|
|
expect(data.contract_id).toBe('contract-1');
|
|
});
|
|
|
|
it("completing resumed wizard sets contract status to 'active' (T045)", async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
for (let i = 0; i < 5; i++) {
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
}
|
|
await screen.findByTestId('step-review');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Finish'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalled();
|
|
});
|
|
|
|
const [submission, options] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(submission.contract_id).toBe('contract-1');
|
|
expect(options).toBeUndefined();
|
|
});
|
|
|
|
it('completing resumed wizard sets is_active to true (T046)', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-1' });
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(
|
|
<ContractWizard
|
|
open={true}
|
|
onOpenChange={vi.fn()}
|
|
editingContract={{
|
|
contract_id: 'contract-1',
|
|
is_draft: true,
|
|
client_id: 'client-1',
|
|
contract_name: 'Draft Alpha',
|
|
start_date: '2026-01-01',
|
|
billing_frequency: 'monthly',
|
|
currency_code: 'USD',
|
|
enable_proration: false,
|
|
fixed_base_rate: 10000,
|
|
fixed_services: [{ service_id: 'svc-1', quantity: 1 }],
|
|
product_services: [],
|
|
hourly_services: [],
|
|
usage_services: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
for (let i = 0; i < 5; i++) {
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
}
|
|
await screen.findByTestId('step-review');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Finish'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalled();
|
|
});
|
|
|
|
const [_submission, options] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(options).toBeUndefined();
|
|
});
|
|
|
|
it('submits the client cadence default for newly created recurring lines (T112)', async () => {
|
|
const { createClientContractFromWizard } = await import('@alga-psa/billing/actions/contractWizardActions');
|
|
(createClientContractFromWizard as any).mockResolvedValue({ contract_id: 'contract-new' });
|
|
|
|
const { ContractWizard } = await import('../src/components/billing-dashboard/contracts/ContractWizard');
|
|
render(<ContractWizard open={true} onOpenChange={vi.fn()} />);
|
|
|
|
await screen.findByTestId('step-contract-basics');
|
|
const user = userEvent.setup();
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Seed valid basics'));
|
|
});
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Next'));
|
|
});
|
|
}
|
|
|
|
expect(await screen.findByTestId('step-review')).toHaveAttribute('data-cadence-owner', 'client');
|
|
|
|
await act(async () => {
|
|
await user.click(screen.getByText('Finish'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createClientContractFromWizard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const [submission] = (createClientContractFromWizard as any).mock.calls[0];
|
|
expect(submission.cadence_owner).toBe('client');
|
|
});
|
|
});
|