PSA/packages/billing/tests/automaticInvoices.groupedParentRows.test.tsx
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

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();
});
});