Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('BalanceCard', () => {
});
});

it('should disable transfer from button when current balance goes beyond deficit limit', async () => {
it('should disable transfer from button when the fund has no available balance', async () => {
const { findByRole } = render(
<Components
fund={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getIconColorForFundType,
getIconForFundType,
} from '../../../Reports/StaffExpenseReport/Helpers/fundTypeHelpers';
import { availableBalance } from '../Helper/availableBalance';
import { FundFieldsFragment } from '../ReportsSavingsFund.generated';
import { TransferModalData } from '../mockData';

Expand Down Expand Up @@ -123,7 +124,7 @@ export const BalanceCard: React.FC<BalanceCardProps> = ({
>
<Button
onClick={handleTransferFrom}
disabled={fund.endBalance <= fund.deficitLimit}
disabled={availableBalance(fund) <= 0}
fullWidth
>
<Outbox fontSize="small" sx={{ mr: 0.5 }} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FundFieldsFragment } from '../ReportsSavingsFund.generated';

// The lowest a source account balance may go after a transfer. Per
// MPDX-10004 a transfer may never take an account below $0 — fund deficit
// limits apply to salary, not savings fund transfers. If that rule ever
// loosens to allow a deficit, confirm the sign convention of
// `deficitLimit` first: BalanceCard formerly treated it as a negative
// floor (endBalance <= deficitLimit) while TransferModal's old warning
// treated it as a positive magnitude, and mocks with both conventions
// exist in the repo.
export const minimumAllowedBalance = (_fund: FundFieldsFragment): number => 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use the deficitLimit

Suggested change
export const minimumAllowedBalance = (_fund: FundFieldsFragment): number => 0;
export const minimumAllowedBalance = (fund: FundFieldsFragment): number => {
fund.deficitLimit ?? 0;
}


// The most that can be transferred out of a fund right now.
export const availableBalance = (fund: FundFieldsFragment): number =>
fund.endBalance - minimumAllowedBalance(fund);

// Compare money at cent precision: fund balances are floats and can carry
// sub-cent noise that would otherwise reject the exact amount the UI
// displays as available.
export const toCents = (amount: number): number => Math.round(amount * 100);
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { render, waitFor, within } from '@testing-library/react';
import { RenderResult, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DateTime } from 'luxon';
import { SnackbarProvider } from 'notistack';
Expand Down Expand Up @@ -66,11 +66,13 @@ const transferDefaultData: TransferModalData['transfer'] = {
interface ComponentsProps {
transfer?: TransferModalData['transfer'];
type?: TransferTypeEnum;
funds?: typeof fundsMock;
}

const Components = ({
transfer = transferDefaultData,
type,
funds = fundsMock,
}: ComponentsProps) => (
<SnackbarProvider>
<ThemeProvider theme={theme}>
Expand All @@ -89,7 +91,7 @@ const Components = ({
type,
transfer,
}}
funds={fundsMock}
funds={funds}
lastName={lastName}
handleClose={handleClose}
/>
Expand All @@ -101,6 +103,21 @@ const Components = ({
</SnackbarProvider>
);

const enterTransferDetails = (
{ getByRole }: RenderResult,
amount: string,
): HTMLElement => {
userEvent.click(getByRole('combobox', { name: /to account/i }));
userEvent.click(getByRole('option', { name: /staff savings/i }));

const amountField = getByRole('spinbutton', { name: /amount/i });
userEvent.clear(amountField);
userEvent.type(amountField, amount);
userEvent.tab();

return amountField;
};

describe('TransferModal', () => {
it('should render the modal with correct inputs', () => {
const { getByRole, getByText } = render(<Components />);
Expand Down Expand Up @@ -653,27 +670,169 @@ describe('TransferModal', () => {
).toBeInTheDocument();
});

it('should show information box when amount exceeds limit', async () => {
const { getByRole, findByRole } = render(<Components />);
it('should show error and block submission when amount is one cent over the available balance', async () => {
const result = render(<Components />);
const { getByRole, findByText } = result;

const toAccount = getByRole('combobox', { name: /to account/i });
const amountField = enterTransferDetails(result, '15000.01');
expect(amountField).toHaveValue(15000.01);

userEvent.click(toAccount);
userEvent.click(getByRole('option', { name: /staff savings/i }));
expect(
await findByText(
'Amount cannot exceed the available balance of $15,000.00',
),
).toBeInTheDocument();

// user-event refuses to click a disabled button, so the disabled
// assertion above is what guarantees no mutation can fire.
await waitFor(() =>
expect(getByRole('button', { name: /submit/i })).toBeDisabled(),
);

const amount = getByRole('spinbutton', { name: /amount/i });
expect(mutationSpy).not.toHaveGraphqlOperation('CreateTransfer');
});

userEvent.clear(amount);
userEvent.type(amount, '20000');
userEvent.tab();
it('should allow a transfer of exactly the full available balance', async () => {
const result = render(<Components />);
const { getByRole } = result;

enterTransferDetails(result, '15000');

userEvent.click(getByRole('button', { name: /submit/i }));

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('CreateTransfer', {
amount: 15000,
sourceFundTypeName: 'Staff Account',
}),
);
});

it('should still block below a zero balance even when the fund has a deficit limit', async () => {
// Per MPDX-10004 the floor is $0 regardless of the fund's deficit
// limit. If the rule loosens to allow a deficit, this is the test to
// update.
const fundsWithDeficit = [
{ ...fundsMock[0], deficitLimit: -1000 },
fundsMock[1],
];

const result = render(<Components funds={fundsWithDeficit} />);
const { getByRole, findByText } = result;

enterTransferDetails(result, '15500');

const alert = await findByRole('alert');
expect(alert).toBeInTheDocument();
expect(
within(alert).getByText(
/this amount will cause your account balance to exceed the deficit limit/i,
await findByText(
'Amount cannot exceed the available balance of $15,000.00',
),
).toBeInTheDocument();

await waitFor(() =>
expect(getByRole('button', { name: /submit/i })).toBeDisabled(),
);
});

it('should allow winding down a recurring transfer whose amount exceeds the balance', async () => {
const overBalanceTransfer: TransferModalData['transfer'] = {
id: 'transfer-id',
transferFrom: 'Staff Account',
transferTo: 'Staff Savings',
amount: 20000,
schedule: ScheduleEnum.Monthly,
transferDate: DateTime.fromISO('2024-11-01'),
endDate: null,
note: '',
recurringId: 'recurring-id',
};

const { getByRole, getByLabelText } = render(
<Components
transfer={overBalanceTransfer}
type={TransferTypeEnum.Edit}
/>,
);

const endDate = getByLabelText(/end date/i);
userEvent.type(endDate, '12/01/2025');
userEvent.tab();

userEvent.click(getByRole('button', { name: /submit/i }));

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('UpdateRecurringTransfer', {
id: 'recurring-id',
amount: 20000,
}),
);
});

it('should warn but still allow increasing a transfer beyond the available balance when editing', async () => {
const transfer: TransferModalData['transfer'] = {
id: 'transfer-id',
transferFrom: 'Staff Account',
transferTo: 'Staff Savings',
amount: 500,
schedule: ScheduleEnum.Monthly,
transferDate: DateTime.fromISO('2024-11-01'),
endDate: null,
note: '',
recurringId: 'recurring-id',
};

const { getByRole, findByRole } = render(
<Components transfer={transfer} type={TransferTypeEnum.Edit} />,
);

const amountField = getByRole('spinbutton', { name: /amount/i });
userEvent.clear(amountField);
userEvent.type(amountField, '16000');
userEvent.tab();

const alert = await findByRole('alert');
expect(alert).toHaveTextContent(
/greater than your Staff Account account's available balance/i,
);
expect(alert).toHaveTextContent('-$1,000.00');

userEvent.click(getByRole('button', { name: /submit/i }));

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('UpdateRecurringTransfer', {
id: 'recurring-id',
amount: 16000,
}),
);
});

it('should warn but still allow a new recurring transfer over the available balance', async () => {
const { getByRole, getByLabelText, findByRole } = render(<Components />);

userEvent.click(getByRole('combobox', { name: /to account/i }));
userEvent.click(getByRole('option', { name: /staff savings/i }));

const amountField = getByRole('spinbutton', { name: /amount/i });
userEvent.clear(amountField);
userEvent.type(amountField, '20000');

userEvent.click(getByRole('radio', { name: /monthly/i }));

const transferDate = getByLabelText(/transfer date/i);
userEvent.clear(transferDate);
userEvent.type(transferDate, '12/01/2024');
userEvent.tab();

const alert = await findByRole('alert');
expect(alert).toHaveTextContent('-$5,000.00');

userEvent.click(getByRole('button', { name: /submit/i }));

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('CreateRecurringTransfer', {
amount: 20000,
sourceFundTypeName: 'Staff Account',
}),
);
});

it('should show proper currency symbol in amount field', () => {
Expand Down Expand Up @@ -822,8 +981,8 @@ describe('TransferModal', () => {
it('should update a transfer', async () => {
const dataWithValues: TransferModalData['transfer'] = {
id: 'transfer-id',
transferFrom: 'Primary',
transferTo: 'Savings',
transferFrom: 'Staff Account',
transferTo: 'Staff Savings',
amount: 500,
schedule: ScheduleEnum.Monthly,
transferDate: DateTime.fromISO('2024-11-01'),
Expand Down
Loading
Loading