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
731 lines
30 KiB
TypeScript
731 lines
30 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import React from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import '@testing-library/jest-dom/vitest';
|
|
|
|
let mockDueWorkResponse: any;
|
|
let mockRecurringInvoiceHistoryResponse: any;
|
|
const mockPreviewGroupedInvoicesForSelectionInputs = vi.fn(async (groups: Array<{ previewGroupKey: string; selectorInputs: any[] }>) => ({
|
|
success: true,
|
|
invoiceCount: groups.length,
|
|
previews: groups.map((group) => ({
|
|
previewGroupKey: group.previewGroupKey,
|
|
selectorInputs: group.selectorInputs,
|
|
data: {
|
|
invoiceNumber: 'PREVIEW',
|
|
issueDate: '2026-03-01',
|
|
dueDate: '2026-03-31',
|
|
customer: { name: 'Acme Co', address: '123 Main St' },
|
|
items: [],
|
|
subtotal: 0,
|
|
tax: 0,
|
|
total: 0,
|
|
},
|
|
})),
|
|
}));
|
|
const mockGenerateGroupedInvoicesAsRecurringBillingRun = vi.fn(async () => ({ failures: [] }));
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({
|
|
push: vi.fn(),
|
|
replace: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/lib/i18n/client', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, opts?: { defaultValue?: string } & Record<string, unknown>) =>
|
|
(opts && typeof opts.defaultValue === 'string' ? opts.defaultValue : key),
|
|
}),
|
|
useFormatters: () => ({
|
|
formatDate: (value: unknown) => String(value),
|
|
formatCurrency: (value: number) => `$${value}`,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/billingAndTax', () => ({
|
|
getAvailableRecurringDueWork: vi.fn(async () => mockDueWorkResponse),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/invoiceGeneration', () => ({
|
|
getPurchaseOrderOverageForSelectionInput: vi.fn(async () => ({ overage_cents: 0, po_number: null })),
|
|
previewGroupedInvoicesForSelectionInputs: mockPreviewGroupedInvoicesForSelectionInputs,
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/recurringBillingRunActions', () => ({
|
|
generateInvoicesAsRecurringBillingRun: vi.fn(async () => ({ failures: [] })),
|
|
generateGroupedInvoicesAsRecurringBillingRun: mockGenerateGroupedInvoicesAsRecurringBillingRun,
|
|
}));
|
|
|
|
vi.mock('@alga-psa/billing/actions/billingCycleActions', () => ({
|
|
getRecurringInvoiceHistoryPaginated: vi.fn(async () => mockRecurringInvoiceHistoryResponse),
|
|
reverseRecurringInvoice: vi.fn(async () => undefined),
|
|
hardDeleteRecurringInvoice: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/DataTable', () => ({
|
|
DataTable: ({
|
|
id,
|
|
data,
|
|
columns = [],
|
|
}: {
|
|
id: string;
|
|
data: any[];
|
|
columns?: Array<{ dataIndex?: string; render?: (value: unknown, row: any, index: number) => React.ReactNode }>;
|
|
}) => (
|
|
<div data-testid={id}>
|
|
<div data-testid={`${id}-header`}>
|
|
{columns.map((column, columnIndex) => (
|
|
<div key={`header-${columnIndex}`}>{(column as any).title ?? null}</div>
|
|
))}
|
|
</div>
|
|
<div data-testid={`${id}-row-count`}>{data.length}</div>
|
|
{data.map((row, index) => (
|
|
<div
|
|
key={row.parentSummary?.candidateKey ?? row.candidateKey ?? row.invoiceId}
|
|
data-testid={`${id}-row`}
|
|
>
|
|
{columns.map((column, columnIndex) => {
|
|
const value = column.dataIndex ? row[column.dataIndex] : undefined;
|
|
return (
|
|
<div key={`${row.parentSummary?.candidateKey ?? row.candidateKey ?? row.invoiceId}-${columnIndex}`}>
|
|
{column.render ? column.render(value, row, index) : String(value ?? '')}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/ui/components/Button', () => ({
|
|
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/Badge', () => ({
|
|
Badge: ({ children }: any) => <span>{children}</span>,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/Input', () => ({
|
|
Input: ({ containerClassName: _containerClassName, ...props }: any) => <input {...props} />,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/Checkbox', () => ({
|
|
// The component drives parent-row selection through onClick (for shift-range
|
|
// support) and calls event.preventDefault(). On a native jsdom checkbox that
|
|
// cancels the click activation and reverts `.checked`, so we hand the
|
|
// component a no-op preventDefault instead.
|
|
Checkbox: ({ indeterminate: _indeterminate, onClick, ...props }: any) => (
|
|
<input
|
|
type="checkbox"
|
|
data-indeterminate={_indeterminate ? 'true' : 'false'}
|
|
{...props}
|
|
onClick={
|
|
onClick
|
|
? (event: any) => {
|
|
onClick({
|
|
shiftKey: event.shiftKey,
|
|
metaKey: event.metaKey,
|
|
ctrlKey: event.ctrlKey,
|
|
stopPropagation: () => event.stopPropagation(),
|
|
preventDefault: () => {},
|
|
});
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
),
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/DateRangePicker', () => ({
|
|
DateRangePicker: () => <div data-testid="date-range-picker" />,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/Alert', () => ({
|
|
Alert: ({ children }: any) => <div>{children}</div>,
|
|
AlertDescription: ({ children }: any) => <div>{children}</div>,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/Dialog', () => ({
|
|
Dialog: ({ children }: any) => <div>{children}</div>,
|
|
DialogContent: ({ children }: any) => <div>{children}</div>,
|
|
DialogFooter: ({ children }: any) => <div>{children}</div>,
|
|
DialogDescription: ({ children }: any) => <div>{children}</div>,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/DropdownMenu', () => ({
|
|
DropdownMenu: ({ children }: any) => <div>{children}</div>,
|
|
DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
|
|
DropdownMenuItem: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
DropdownMenuSeparator: () => <hr />,
|
|
DropdownMenuTrigger: ({ children }: any) => <div>{children}</div>,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/ConfirmationDialog', () => ({
|
|
ConfirmationDialog: () => null,
|
|
}));
|
|
vi.mock('@alga-psa/ui/components/LoadingIndicator', () => ({
|
|
default: ({ text }: { text: string }) => <div>{text}</div>,
|
|
}));
|
|
|
|
describe('AutomaticInvoices grouped parent rows', () => {
|
|
beforeEach(() => {
|
|
mockPreviewGroupedInvoicesForSelectionInputs.mockClear();
|
|
mockGenerateGroupedInvoicesAsRecurringBillingRun.mockClear();
|
|
mockDueWorkResponse = {
|
|
invoiceCandidates: [
|
|
{
|
|
candidateKey: 'invoice-candidate:client-1:2026-03-01:2026-04-01',
|
|
clientId: 'client-1',
|
|
clientName: 'Acme Co',
|
|
windowStart: '2026-03-01',
|
|
windowEnd: '2026-04-01',
|
|
windowLabel: '2026-03-01 to 2026-04-01',
|
|
servicePeriodStart: '2026-03-01',
|
|
servicePeriodEnd: '2026-04-01',
|
|
servicePeriodLabel: '2026-03-01 to 2026-04-01',
|
|
cadenceOwners: ['contract'],
|
|
cadenceSources: ['contract_anniversary'],
|
|
contractId: 'contract-1',
|
|
contractName: 'Main Contract',
|
|
splitReasons: [],
|
|
memberCount: 2,
|
|
canGenerate: true,
|
|
blockedReason: null,
|
|
members: [
|
|
{
|
|
executionIdentityKey: 'exec-1',
|
|
canGenerate: true,
|
|
billingCycleId: 'bc-1',
|
|
clientId: 'client-1',
|
|
purchaseOrderScopeKey: 'po-1',
|
|
currencyCode: 'USD',
|
|
taxSource: 'exclusive',
|
|
exportShapeKey: 'shape-a',
|
|
cadenceSource: 'contract_anniversary',
|
|
duePosition: 'advance',
|
|
servicePeriodLabel: '2026-03-01 to 2026-04-01',
|
|
amountCents: 12500,
|
|
selectorInput: {
|
|
clientId: 'client-1',
|
|
windowStart: '2026-03-01',
|
|
windowEnd: '2026-04-01',
|
|
executionWindow: {
|
|
kind: 'contract_cadence_window',
|
|
identityKey: 'contract-window:line-1:2026-03-01:2026-04-01',
|
|
cadenceOwner: 'contract',
|
|
contractId: 'contract-1',
|
|
contractLineId: 'line-1',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
executionIdentityKey: 'exec-2',
|
|
canGenerate: true,
|
|
billingCycleId: 'bc-2',
|
|
clientId: 'client-1',
|
|
purchaseOrderScopeKey: 'po-1',
|
|
currencyCode: 'USD',
|
|
taxSource: 'exclusive',
|
|
exportShapeKey: 'shape-a',
|
|
cadenceSource: 'contract_anniversary',
|
|
duePosition: 'advance',
|
|
servicePeriodLabel: '2026-03-01 to 2026-04-01',
|
|
amountCents: 17500,
|
|
selectorInput: {
|
|
clientId: 'client-1',
|
|
windowStart: '2026-03-01',
|
|
windowEnd: '2026-04-01',
|
|
executionWindow: {
|
|
kind: 'contract_cadence_window',
|
|
identityKey: 'contract-window:line-2:2026-03-01:2026-04-01',
|
|
cadenceOwner: 'contract',
|
|
contractId: 'contract-1',
|
|
contractLineId: 'line-2',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
materializationGaps: [],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 10,
|
|
totalPages: 1,
|
|
};
|
|
mockRecurringInvoiceHistoryResponse = { rows: [], total: 0, page: 1, pageSize: 10 };
|
|
});
|
|
|
|
it('renders one parent group row for a shared client + invoice window instead of one top-level row per child (T001)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('automatic-invoices-table-row-count')).toHaveTextContent('1');
|
|
});
|
|
|
|
expect(screen.getByText('Each parent row groups due obligations by client and invoice window. Child obligations remain the atomic execution units.')).toBeInTheDocument();
|
|
expect(screen.getByTestId('automatic-invoices-table')).toBeInTheDocument();
|
|
expect(screen.getAllByTestId('automatic-invoices-table-row')).toHaveLength(1);
|
|
});
|
|
|
|
it('renders parent summary child count, aggregate amount, and invoice window (T002)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('2 obligations')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getAllByText('2026-03-01 to 2026-04-01').length).toBeGreaterThan(0);
|
|
expect(screen.getByText('$300.00')).toBeInTheDocument();
|
|
});
|
|
|
|
it('expands a parent row to reveal child candidate details (T003)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
expect(
|
|
await screen.findByTestId('child-row-parent-group:client-1:2026-03-01:2026-04-01-exec-1'),
|
|
).toBeInTheDocument();
|
|
expect(screen.getAllByText('Assigned work item').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Cadence: Contract anniversary').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Billing timing: Advance').length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText('Service period: 2026-03-01 to 2026-04-01').length).toBeGreaterThan(0);
|
|
expect(screen.getByText('$125.00')).toBeInTheDocument();
|
|
});
|
|
|
|
it('is combinable only when all ready children share client/currency/PO/tax/export scope (T004)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
expect(checkbox?.disabled).toBe(false);
|
|
});
|
|
|
|
expect(
|
|
screen.queryByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows PO incompatibility reason when child PO scope differs (T005)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].purchaseOrderScopeKey = 'po-2';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
expect(checkbox?.disabled).toBe(true);
|
|
});
|
|
|
|
expect(
|
|
screen.getByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).toHaveTextContent('PO scope differs');
|
|
});
|
|
|
|
it('shows currency incompatibility reason when child currency differs (T006)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].currencyCode = 'EUR';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
expect(checkbox?.disabled).toBe(true);
|
|
});
|
|
|
|
expect(
|
|
screen.getByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).toHaveTextContent('Currency differs');
|
|
});
|
|
|
|
it('shows tax incompatibility reason when child tax source differs (T007)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].taxSource = 'inclusive';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
expect(checkbox?.disabled).toBe(true);
|
|
});
|
|
|
|
expect(
|
|
screen.getByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).toHaveTextContent('Tax treatment differs');
|
|
});
|
|
|
|
it('shows export-shape incompatibility reason when child export shape differs (T008)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].exportShapeKey = 'shape-b';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
expect(checkbox?.disabled).toBe(true);
|
|
});
|
|
|
|
expect(
|
|
screen.getByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).toHaveTextContent('Export shape differs');
|
|
});
|
|
|
|
it('selecting a combinable parent selects the full group target (T009)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const parentCheckbox = await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
return checkbox as HTMLInputElement;
|
|
});
|
|
fireEvent.click(parentCheckbox);
|
|
|
|
await waitFor(() => {
|
|
expect(parentCheckbox.checked).toBe(true);
|
|
});
|
|
expect(screen.getByText('Generate Invoices for Selected Periods (2)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('non-combinable parent stays disabled while child rows remain selectable (T010)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].currencyCode = 'EUR';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const parentCheckbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement;
|
|
const childCheckbox = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
|
|
expect(parentCheckbox.disabled).toBe(true);
|
|
expect(childCheckbox.disabled).toBe(false);
|
|
});
|
|
|
|
it('partial child selection drives parent indeterminate state (T011)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const childCheckbox = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
fireEvent.click(childCheckbox);
|
|
|
|
const parentCheckbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement;
|
|
expect(parentCheckbox.checked).toBe(false);
|
|
expect(parentCheckbox.dataset.indeterminate).toBe('true');
|
|
});
|
|
|
|
it('select all selects combinable groups by parent row (T012)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const [selectAll] = await screen.findAllByRole('checkbox');
|
|
fireEvent.click(selectAll);
|
|
|
|
const parentCheckbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement;
|
|
expect(parentCheckbox.checked).toBe(true);
|
|
});
|
|
|
|
it('select all selects child rows for non-combinable groups (T013)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].taxSource = 'inclusive';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const [selectAll] = await screen.findAllByRole('checkbox');
|
|
fireEvent.click(selectAll);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const parentCheckbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement;
|
|
const childOne = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
const childTwo = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-2',
|
|
) as HTMLInputElement;
|
|
|
|
expect(parentCheckbox.checked).toBe(false);
|
|
expect(childOne.checked).toBe(true);
|
|
expect(childTwo.checked).toBe(true);
|
|
});
|
|
|
|
it('keeps blocked children visible but unselectable via child selection and select all (T014)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].canGenerate = false;
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].canGenerate = false;
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].currencyCode = 'EUR';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const [selectAll] = await screen.findAllByRole('checkbox');
|
|
fireEvent.click(selectAll);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const blockedChild = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-2',
|
|
) as HTMLInputElement;
|
|
const readyChild = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
|
|
expect(
|
|
screen.getByTestId('child-row-parent-group:client-1:2026-03-01:2026-04-01-exec-2'),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText('Contains blocked items')).toBeInTheDocument();
|
|
expect(screen.queryByText('Must invoice separately')).not.toBeInTheDocument();
|
|
expect(blockedChild.disabled).toBe(true);
|
|
expect(blockedChild.checked).toBe(false);
|
|
expect(readyChild.checked).toBe(true);
|
|
});
|
|
|
|
it('previewing a selected combinable parent renders one combined invoice preview count (T015)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const parentCheckbox = await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
return checkbox as HTMLInputElement;
|
|
});
|
|
fireEvent.click(parentCheckbox);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Preview Selected' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('preview-invoice-count-summary')).toHaveTextContent(
|
|
'This selection will generate one combined invoice.',
|
|
);
|
|
});
|
|
expect(mockPreviewGroupedInvoicesForSelectionInputs).toHaveBeenCalledTimes(1);
|
|
const previewPayload = mockPreviewGroupedInvoicesForSelectionInputs.mock.calls[0][0];
|
|
expect(previewPayload).toHaveLength(1);
|
|
expect(previewPayload[0].selectorInputs).toHaveLength(2);
|
|
});
|
|
|
|
it('previewing mixed child selection renders multi-invoice preview count (T016)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].currencyCode = 'EUR';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const childOne = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
const childTwo = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-2',
|
|
) as HTMLInputElement;
|
|
fireEvent.click(childOne);
|
|
fireEvent.click(childTwo);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Preview Selected' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('preview-invoice-count-summary')).toHaveTextContent(
|
|
'This selection will generate 2 separate invoices.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('preview request uses exact selected child scope without unselected siblings (T017)', async () => {
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const childOne = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
fireEvent.click(childOne);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Preview Selected' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockPreviewGroupedInvoicesForSelectionInputs).toHaveBeenCalledTimes(1);
|
|
});
|
|
const previewPayload = mockPreviewGroupedInvoicesForSelectionInputs.mock.calls[0][0];
|
|
expect(previewPayload).toHaveLength(1);
|
|
expect(previewPayload[0].selectorInputs).toHaveLength(1);
|
|
expect(previewPayload[0].selectorInputs[0].executionWindow.contractLineId).toBe('line-1');
|
|
});
|
|
|
|
it('generation payload does not re-expand into unselected siblings from the same group (T020)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members.push({
|
|
executionIdentityKey: 'exec-3',
|
|
canGenerate: true,
|
|
billingCycleId: 'bc-3',
|
|
clientId: 'client-1',
|
|
purchaseOrderScopeKey: 'po-1',
|
|
currencyCode: 'USD',
|
|
taxSource: 'exclusive',
|
|
exportShapeKey: 'shape-a',
|
|
cadenceSource: 'contract_anniversary',
|
|
duePosition: 'advance',
|
|
servicePeriodLabel: '2026-03-01 to 2026-04-01',
|
|
amountCents: 5000,
|
|
selectorInput: {
|
|
clientId: 'client-1',
|
|
windowStart: '2026-03-01',
|
|
windowEnd: '2026-04-01',
|
|
executionWindow: {
|
|
kind: 'contract_cadence_window',
|
|
identityKey: 'contract-window:line-3:2026-03-01:2026-04-01',
|
|
cadenceOwner: 'contract',
|
|
contractId: 'contract-1',
|
|
contractLineId: 'line-3',
|
|
},
|
|
},
|
|
});
|
|
mockDueWorkResponse.invoiceCandidates[0].memberCount = 3;
|
|
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const expandButton = await screen.findByRole('button', { name: 'Expand' });
|
|
fireEvent.click(expandButton);
|
|
|
|
const childOne = document.getElementById(
|
|
'select-child-parent-group:client-1:2026-03-01:2026-04-01-exec-1',
|
|
) as HTMLInputElement;
|
|
fireEvent.click(childOne);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Generate Invoices for Selected Periods (1)' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateGroupedInvoicesAsRecurringBillingRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
const generationPayload = mockGenerateGroupedInvoicesAsRecurringBillingRun.mock.calls[0][0];
|
|
expect(generationPayload.groupedTargets).toHaveLength(1);
|
|
expect(generationPayload.groupedTargets[0].selectorInputs).toHaveLength(1);
|
|
expect(generationPayload.groupedTargets[0].selectorInputs[0].executionWindow.contractLineId).toBe('line-1');
|
|
});
|
|
|
|
it('keeps parent non-combinable when PO scope differs across child candidates (T026)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members[1].purchaseOrderScopeKey = 'po-2';
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
const parentCheckbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(parentCheckbox).not.toBeNull();
|
|
expect(parentCheckbox?.disabled).toBe(true);
|
|
});
|
|
expect(
|
|
screen.getByTestId('combinability-reasons-parent-group:client-1:2026-03-01:2026-04-01'),
|
|
).toHaveTextContent('PO scope differs');
|
|
});
|
|
|
|
it('legacy single-assignment/single-child groups still generate through the existing flow (T028/T029)', async () => {
|
|
mockDueWorkResponse.invoiceCandidates[0].members = [mockDueWorkResponse.invoiceCandidates[0].members[0]];
|
|
mockDueWorkResponse.invoiceCandidates[0].memberCount = 1;
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
const parentCheckbox = await waitFor(() => {
|
|
const checkbox = document.getElementById(
|
|
'select-parent-group:client-1:2026-03-01:2026-04-01',
|
|
) as HTMLInputElement | null;
|
|
expect(checkbox).not.toBeNull();
|
|
return checkbox as HTMLInputElement;
|
|
});
|
|
fireEvent.click(parentCheckbox);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Preview Selected' }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('preview-invoice-count-summary')).toHaveTextContent(
|
|
'This selection will generate one combined invoice.',
|
|
);
|
|
});
|
|
expect(screen.queryByTestId('grouped-preview-unavailable-copy')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Generate Invoices for Selected Periods (1)' }));
|
|
await waitFor(() => {
|
|
expect(mockGenerateGroupedInvoicesAsRecurringBillingRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
const payload = mockGenerateGroupedInvoicesAsRecurringBillingRun.mock.calls[0][0];
|
|
expect(payload.groupedTargets).toHaveLength(1);
|
|
expect(payload.groupedTargets[0].selectorInputs).toHaveLength(1);
|
|
});
|
|
|
|
it('renders recurring history assignment scope summary and multi-contract badge for combined invoices (T024/T025)', async () => {
|
|
mockRecurringInvoiceHistoryResponse = {
|
|
rows: [
|
|
{
|
|
invoiceId: 'invoice-1',
|
|
invoiceNumber: 'INV-1001',
|
|
invoiceStatus: 'draft',
|
|
invoiceDate: '2026-03-08',
|
|
billingCycleId: null,
|
|
hasBillingCycleBridge: false,
|
|
clientId: 'client-1',
|
|
clientName: 'Acme Co',
|
|
cadenceSource: 'contract_anniversary',
|
|
servicePeriodStart: '2026-03-01',
|
|
servicePeriodEnd: '2026-04-01',
|
|
servicePeriodLabel: '2026-03-01 to 2026-04-01',
|
|
invoiceWindowStart: '2026-03-01',
|
|
invoiceWindowEnd: '2026-04-01',
|
|
invoiceWindowLabel: '2026-03-01 to 2026-04-01',
|
|
assignmentContractIds: ['assignment-1', 'assignment-2'],
|
|
isMultiAssignment: true,
|
|
assignmentSummary: 'Multi-assignment invoice (2)',
|
|
},
|
|
],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 10,
|
|
};
|
|
const AutomaticInvoices = (await import('../src/components/billing-dashboard/AutomaticInvoices')).default;
|
|
render(<AutomaticInvoices onGenerateSuccess={() => undefined} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('already-invoiced-table-row-count')).toHaveTextContent('1');
|
|
});
|
|
expect(screen.getByText('Multi-assignment invoice (2)')).toBeInTheDocument();
|
|
expect(screen.getByText('Multi-contract invoice')).toBeInTheDocument();
|
|
});
|
|
});
|