diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 3d2666120..90925f858 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -2170,6 +2170,7 @@
"Only delete if you know that this user will not be returning to any other missional organization that uses {{appName}}. You may need to confirm this with them.": "Only delete if you know that this user will not be returning to any other missional organization that uses {{appName}}. You may need to confirm this with them.",
"Only import contacts from certain groups": "Only import contacts from certain groups",
"Only include medical expenses that are not reimbursable through your staff account.": "Only include medical expenses that are not reimbursable through your staff account.",
+ "Only the last year of transfer history is shown.": "Only the last year of transfer history is shown.",
"Only the portion not reimbursed as ministry expense.": "Only the portion not reimbursed as ministry expense.",
"Open hours per week calculator": "Open hours per week calculator",
"Opening Balance": "Opening Balance",
diff --git a/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.test.tsx b/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.test.tsx
index 9a09c9f62..0030cf950 100644
--- a/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.test.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.test.tsx
@@ -110,19 +110,20 @@ const mockTransfer: Transfers = {
missingMonths: [DateTime.fromISO('2023-08-15')],
};
-const TestComponent: React.FC = () => {
+const TestComponent: React.FC<{ transfer?: Transfers }> = ({
+ transfer = mockTransfer,
+}) => {
return (
-
+
);
};
+const historyNote = 'Only the last year of transfer history is shown.';
+
describe('FailedTransferModal', () => {
it('renders the modal', () => {
const { getByText, getAllByRole, getByRole } = render();
@@ -137,6 +138,20 @@ describe('FailedTransferModal', () => {
expect(button[1]).toBeInTheDocument();
});
+ it('explains that only the last year of history is shown when the transfer started before the window', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText(historyNote)).toBeInTheDocument();
+ });
+
+ it('omits the history note when the whole transfer fits inside the window', () => {
+ const { queryByText } = render();
+
+ expect(queryByText(historyNote)).not.toBeInTheDocument();
+ });
+
it('renders the correct number of transfer rows', () => {
const { getByRole } = render();
diff --git a/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.tsx b/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.tsx
index daa874237..3a9b12cd6 100644
--- a/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/FailedTransferModal/FailedTransferModal.tsx
@@ -123,6 +123,11 @@ export const FailedTransferModal: React.FC = ({
+ {transfer.historyTruncated && (
+
+ {t('Only the last year of transfer history is shown.')}
+
+ )}
For more information about failed transfers, email{' '}
diff --git a/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.test.ts b/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.test.ts
index 2a8771424..17b53d2fe 100644
--- a/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.test.ts
+++ b/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.test.ts
@@ -2,6 +2,9 @@ import { DateTime, Settings } from 'luxon';
import { Transactions } from 'src/components/HrTools/SavingsFundTransfer/mockData';
import { filteredTransfers } from './filterTransfers';
+// Earlier than every fixture below, so the window never clips a scan unless a test wants it to.
+const historyStart = DateTime.fromISO('2023-01-01');
+
const mockTransactions: Transactions[] = [
{
transaction: {
@@ -152,20 +155,68 @@ const mockTransactions: Transactions[] = [
},
];
+// One positive $20 transaction per date, all tied to the same recurring transfer.
+const makeRecurringTransactions = ({
+ id,
+ recurringStart,
+ recurringEnd = null,
+ active = true,
+ dates,
+}: {
+ id: string;
+ recurringStart: string;
+ recurringEnd?: string | null;
+ active?: boolean;
+ dates: string[];
+}): Transactions[] => {
+ const recurringTransfer = {
+ id,
+ amount: 20,
+ recurringStart: DateTime.fromISO(recurringStart),
+ recurringEnd: recurringEnd ? DateTime.fromISO(recurringEnd) : null,
+ active,
+ };
+ return dates.map((date, index) => ({
+ transaction: {
+ id: `${id}-${index}`,
+ amount: 20,
+ description: null,
+ transactedAt: DateTime.fromISO(date),
+ },
+ subCategory: {
+ id: '1',
+ name: 'deposit',
+ },
+ transfer: {
+ sourceFundTypeName: 'Primary',
+ destinationFundTypeName: 'Savings',
+ },
+ recurringTransfer,
+ baseAmount: 20,
+ failedCount: 0,
+ }));
+};
+
describe('useFilteredTransfers', () => {
beforeEach(() => {
Settings.now = () => Date.parse('2024-01-15');
});
it('should return the correct number of transfers', () => {
- const { filtered, upcoming } = filteredTransfers(mockTransactions);
+ const { filtered, upcoming } = filteredTransfers(
+ mockTransactions,
+ historyStart,
+ );
expect(filtered).toHaveLength(2);
// upcoming holds the future-dated recurring transfer and the scheduled transfer
expect(upcoming).toHaveLength(2);
});
it('should route a pending scheduled transfer to upcoming without summarizing it', () => {
- const { filtered, upcoming } = filteredTransfers(mockTransactions);
+ const { filtered, upcoming } = filteredTransfers(
+ mockTransactions,
+ historyStart,
+ );
const scheduled = upcoming.filter((tx) => tx.scheduledTransfer);
expect(scheduled).toHaveLength(1);
@@ -175,7 +226,7 @@ describe('useFilteredTransfers', () => {
});
it('should correctly add amounts for recurring transfers', () => {
- const { filtered } = filteredTransfers(mockTransactions);
+ const { filtered } = filteredTransfers(mockTransactions, historyStart);
const recurringTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '1',
);
@@ -183,7 +234,7 @@ describe('useFilteredTransfers', () => {
});
it('should include one-time transfers', () => {
- const { filtered } = filteredTransfers(mockTransactions);
+ const { filtered } = filteredTransfers(mockTransactions, historyStart);
const oneTimeTransfer = filtered.find(
(tx) => tx.recurringTransfer === null,
);
@@ -192,7 +243,7 @@ describe('useFilteredTransfers', () => {
});
it('should exclude transfers with zero or negative amounts', () => {
- const { filtered } = filteredTransfers(mockTransactions);
+ const { filtered } = filteredTransfers(mockTransactions, historyStart);
const negativeAmountTransfer = filtered.find(
(tx) => tx.transaction!.amount < 0,
);
@@ -200,7 +251,7 @@ describe('useFilteredTransfers', () => {
});
it('should correctly calculate failedCount for recurring transfers', () => {
- const { filtered } = filteredTransfers(mockTransactions);
+ const { filtered } = filteredTransfers(mockTransactions, historyStart);
const recurringTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '1',
);
@@ -208,7 +259,7 @@ describe('useFilteredTransfers', () => {
});
it('should find missing months for recurring transfers', () => {
- const { filtered } = filteredTransfers(mockTransactions);
+ const { filtered } = filteredTransfers(mockTransactions, historyStart);
const recurringTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '1',
);
@@ -218,39 +269,21 @@ describe('useFilteredTransfers', () => {
});
describe('stopped recurring transfers', () => {
- const makeStoppedTransactions = (
- recurringEnd: DateTime | null,
- ): Transactions[] => {
- const recurringTransfer = {
+ // Started in September, ran September and November, then stopped.
+ const makeStoppedTransactions = (recurringEnd: string | null) =>
+ makeRecurringTransactions({
id: '3',
- amount: 20,
- recurringStart: DateTime.fromISO('2023-09-15'),
+ recurringStart: '2023-09-15',
recurringEnd,
active: false,
- };
- return ['2023-09-15', '2023-11-15'].map((date, index) => ({
- transaction: {
- id: `stopped-${index}`,
- amount: 20,
- description: null,
- transactedAt: DateTime.fromISO(date),
- },
- subCategory: {
- id: '1',
- name: 'deposit',
- },
- transfer: {
- sourceFundTypeName: 'Primary',
- destinationFundTypeName: 'Savings',
- },
- recurringTransfer,
- baseAmount: 20,
- failedCount: 0,
- }));
- };
+ dates: ['2023-09-15', '2023-11-15'],
+ });
it('should not count months after the last transaction as missing when stopped with no end date', () => {
- const { filtered } = filteredTransfers(makeStoppedTransactions(null));
+ const { filtered } = filteredTransfers(
+ makeStoppedTransactions(null),
+ historyStart,
+ );
const stoppedTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '3',
);
@@ -262,7 +295,8 @@ describe('useFilteredTransfers', () => {
it('should not count months after the last transaction as missing when stopped before a future end date', () => {
const { filtered } = filteredTransfers(
- makeStoppedTransactions(DateTime.fromISO('2024-06-15')),
+ makeStoppedTransactions('2024-06-15'),
+ historyStart,
);
const stoppedTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '3',
@@ -275,7 +309,8 @@ describe('useFilteredTransfers', () => {
it('should scan through the end date for inactive transfers that ended naturally', () => {
const { filtered } = filteredTransfers(
- makeStoppedTransactions(DateTime.fromISO('2023-12-15')),
+ makeStoppedTransactions('2023-12-15'),
+ historyStart,
);
const endedTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '3',
@@ -288,7 +323,8 @@ describe('useFilteredTransfers', () => {
it('should scan through the end date when a stopped transfer ends today', () => {
const { filtered } = filteredTransfers(
- makeStoppedTransactions(DateTime.fromISO('2024-01-15')),
+ makeStoppedTransactions('2024-01-15'),
+ historyStart,
);
const endedTransfer = filtered.find(
(tx) => tx.recurringTransfer?.id === '3',
@@ -299,4 +335,175 @@ describe('useFilteredTransfers', () => {
expect(endedTransfer?.failedCount).toBe(3);
});
});
+
+ describe('invalid dates', () => {
+ // Every comparison against an invalid DateTime is false, so the scan's `current > end`
+ // exit would never fire; the row has to be skipped before the scan starts.
+ it('should report no missed months when recurringStart cannot be parsed', () => {
+ const { filtered } = filteredTransfers(
+ makeRecurringTransactions({
+ id: '5',
+ recurringStart: '2023-13-01',
+ dates: ['2023-10-15', '2023-12-15'],
+ }),
+ historyStart,
+ );
+ const invalidStart = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '5',
+ );
+ expect(invalidStart?.missingMonths ?? []).toHaveLength(0);
+ expect(invalidStart?.failedCount).toBe(0);
+ });
+
+ it('should report no missed months when a stopped transfer has an unparseable transaction date', () => {
+ const { filtered } = filteredTransfers(
+ makeRecurringTransactions({
+ id: '6',
+ recurringStart: '2023-09-15',
+ active: false,
+ dates: ['2023-09-15', 'not-a-date'],
+ }),
+ historyStart,
+ );
+ const invalidEnd = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '6',
+ );
+ expect(invalidEnd?.missingMonths ?? []).toHaveLength(0);
+ expect(invalidEnd?.failedCount).toBe(0);
+ });
+ });
+
+ describe('history window', () => {
+ // The page only requests about the last year of transactions, so a long-running recurring
+ // transfer has no rows before the window even when every month ran successfully.
+ const makeLongRunningTransactions = (
+ dates: string[],
+ recurringStart = '2022-06-15',
+ ) => makeRecurringTransactions({ id: '4', recurringStart, dates });
+
+ it('should not count months before the history window as missing, and flag the history as truncated', () => {
+ const { filtered } = filteredTransfers(
+ makeLongRunningTransactions([
+ '2023-01-15',
+ '2023-02-15',
+ '2023-03-15',
+ '2023-04-15',
+ '2023-05-15',
+ '2023-06-15',
+ '2023-07-15',
+ '2023-08-15',
+ '2023-09-15',
+ '2023-10-15',
+ '2023-11-15',
+ '2023-12-15',
+ '2024-01-15',
+ ]),
+ historyStart,
+ );
+ const longRunning = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '4',
+ );
+ expect(longRunning?.missingMonths).toEqual([]);
+ expect(longRunning?.failedCount).toBe(0);
+ expect(longRunning?.historyTruncated).toBe(true);
+ });
+
+ it('should still flag a missed month inside the history window', () => {
+ const { filtered } = filteredTransfers(
+ makeLongRunningTransactions([
+ '2023-01-15',
+ '2023-02-15',
+ '2023-03-15',
+ '2023-04-15',
+ '2023-05-15',
+ '2023-06-15',
+ '2023-07-15',
+ '2023-08-15',
+ '2023-09-15',
+ '2023-10-15',
+ '2023-12-15',
+ '2024-01-15',
+ ]),
+ historyStart,
+ );
+ const longRunning = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '4',
+ );
+ expect(
+ longRunning?.missingMonths?.map((month) => month.toISODate()),
+ ).toEqual(['2023-11-15']);
+ expect(longRunning?.failedCount).toBe(1);
+ });
+
+ it('should keep the recurring day of month when the first scanned month is missing', () => {
+ const { filtered } = filteredTransfers(
+ makeLongRunningTransactions(
+ [
+ '2023-02-20',
+ '2023-03-20',
+ '2023-04-20',
+ '2023-05-20',
+ '2023-06-20',
+ '2023-07-20',
+ '2023-08-20',
+ '2023-09-20',
+ '2023-10-20',
+ '2023-11-20',
+ '2023-12-20',
+ '2024-01-20',
+ ],
+ '2022-06-20',
+ ),
+ historyStart,
+ );
+ const longRunning = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '4',
+ );
+ expect(
+ longRunning?.missingMonths?.map((month) => month.toISODate()),
+ ).toEqual(['2023-01-20']);
+ });
+
+ it('should keep an end-of-month schedule on the last day of each month', () => {
+ const { filtered } = filteredTransfers(
+ makeLongRunningTransactions(
+ [
+ '2023-01-31',
+ '2023-02-28',
+ '2023-04-30',
+ '2023-05-31',
+ '2023-06-30',
+ '2023-07-31',
+ '2023-08-31',
+ '2023-09-30',
+ '2023-10-31',
+ '2023-11-30',
+ '2023-12-31',
+ ],
+ '2022-01-31',
+ ),
+ historyStart,
+ );
+ const longRunning = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '4',
+ );
+ expect(
+ longRunning?.missingMonths?.map((month) => month.toISODate()),
+ ).toEqual(['2023-03-31']);
+ });
+
+ it('should scan from recurringStart, and not flag the history as truncated, when it is inside the window', () => {
+ const { filtered } = filteredTransfers(
+ makeLongRunningTransactions(['2023-10-15', '2024-01-15'], '2023-09-15'),
+ historyStart,
+ );
+ const longRunning = filtered.find(
+ (tx) => tx.recurringTransfer?.id === '4',
+ );
+ expect(
+ longRunning?.missingMonths?.map((month) => month.toISODate()),
+ ).toEqual(['2023-09-15', '2023-11-15', '2023-12-15']);
+ expect(longRunning?.historyTruncated).toBe(false);
+ });
+ });
});
diff --git a/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.ts b/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.ts
index 266e6a23b..8e31e6994 100644
--- a/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.ts
+++ b/src/components/HrTools/SavingsFundTransfer/Helper/filterTransfers.ts
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
import { Transactions } from 'src/components/HrTools/SavingsFundTransfer/mockData';
+import { monthsUntilOccurrenceOnOrAfter } from './monthsUntilOccurrenceOnOrAfter';
// index is the position of the summarized transfer in the filtered array
// seenMonths is a set of months that have been seen for the recurring transfer
@@ -13,7 +14,13 @@ interface Summary {
// Transfer history contains multiple transactions for recurring transfers.
// This hook summarizes those recurring transfers into a single transaction with the total amount.
// It also identifies any missed transfers and includes them as separate transactions with a failed status.
-export function filteredTransfers(transfers: Transactions[]) {
+//
+// historyStart is the start of the window the page requested the transfers for. Months before it
+// have no rows even when they ran, so the missed-month scan must not begin before it (MPDX-10044).
+export function filteredTransfers(
+ transfers: Transactions[],
+ historyStart: DateTime,
+) {
const filtered: Transactions[] = [];
const upcoming: Transactions[] = [];
const summary = new Map();
@@ -57,11 +64,13 @@ export function filteredTransfers(transfers: Transactions[]) {
}
}
+ const currentDate = DateTime.local().startOf('day');
+ const windowStart = historyStart.startOf('day');
+
for (const [, item] of summary) {
const { index, seenMonths, transactions } = item;
const transferRow = filtered[index];
- const currentDate = DateTime.local().startOf('day');
const recurring = transferRow.recurringTransfer;
const start = recurring?.recurringStart.startOf('day');
const recurringEnd = recurring?.recurringEnd?.startOf('day') ?? null;
@@ -79,19 +88,24 @@ export function filteredTransfers(transfers: Transactions[]) {
end = DateTime.min(end, lastTransactedAt);
}
- if (!start) {
+ // An invalid DateTime compares false against everything, which would keep the scan
+ // below from ever failing its `current <= end` check, so bail out before it starts.
+ if (!start?.isValid || !end.isValid || !windowStart.isValid) {
continue;
}
transferRow.missingMonths = [];
+ transferRow.historyTruncated = start < windowStart;
- let current = start;
+ // Add whole months to the start so an end-of-month day does not drift once clamped.
+ let months = monthsUntilOccurrenceOnOrAfter(start, windowStart);
+ let current = start.plus({ months });
while (current <= end) {
const key = `${current.year}-${current.month}`;
if (!seenMonths.has(key)) {
transferRow.missingMonths.push(current);
}
- current = current.plus({ months: 1 });
+ current = start.plus({ months: ++months });
}
transferRow.failedCount = transferRow.missingMonths.length;
diff --git a/src/components/HrTools/SavingsFundTransfer/Helper/getNextPaymentDate.ts b/src/components/HrTools/SavingsFundTransfer/Helper/getNextPaymentDate.ts
index 70a325c25..dfa720152 100644
--- a/src/components/HrTools/SavingsFundTransfer/Helper/getNextPaymentDate.ts
+++ b/src/components/HrTools/SavingsFundTransfer/Helper/getNextPaymentDate.ts
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
import { ScheduleEnum, StatusEnum, Transfers } from '../mockData';
+import { monthsUntilOccurrenceOnOrAfter } from './monthsUntilOccurrenceOnOrAfter';
// SAA does not report the next run, so derive it: monthly on the start day.
export function getNextPaymentDate(transfer: Transfers): DateTime | null {
@@ -30,13 +31,9 @@ export function getNextPaymentDate(transfer: Transfers): DateTime | null {
return null;
}
- // Add whole months to the start so an end-of-month day does not drift once clamped.
- let months = Math.floor(today.diff(start, 'months').months);
- let next = start.plus({ months });
- while (next < today) {
- months += 1;
- next = start.plus({ months });
- }
+ const next = start.plus({
+ months: monthsUntilOccurrenceOnOrAfter(start, today),
+ });
if (endDate?.isValid && next > toViewerDate(endDate)) {
return null;
diff --git a/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.test.ts b/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.test.ts
new file mode 100644
index 000000000..dd6c1097e
--- /dev/null
+++ b/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.test.ts
@@ -0,0 +1,129 @@
+import { DateTime } from 'luxon';
+import { monthsUntilOccurrenceOnOrAfter } from './monthsUntilOccurrenceOnOrAfter';
+
+const months = (start: string, floor: string) =>
+ monthsUntilOccurrenceOnOrAfter(
+ DateTime.fromISO(start),
+ DateTime.fromISO(floor),
+ );
+
+const landing = (start: string, floor: string) =>
+ DateTime.fromISO(start)
+ .plus({ months: months(start, floor) })
+ .toISODate();
+
+describe('monthsUntilOccurrenceOnOrAfter', () => {
+ it('returns 0 when the start equals the floor', () => {
+ expect(months('2023-09-15', '2023-09-15')).toBe(0);
+ expect(landing('2023-09-15', '2023-09-15')).toBe('2023-09-15');
+ });
+
+ it('returns 0 when the start is after the floor', () => {
+ expect(months('2023-09-15', '2023-01-01')).toBe(0);
+ expect(landing('2023-09-15', '2023-01-01')).toBe('2023-09-15');
+ });
+
+ it('returns 1 when the floor is one day after the start', () => {
+ expect(months('2023-09-15', '2023-09-16')).toBe(1);
+ expect(landing('2023-09-15', '2023-09-16')).toBe('2023-10-15');
+ });
+
+ it('returns 1 when the floor is exactly one month after the start', () => {
+ expect(months('2023-09-15', '2023-10-15')).toBe(1);
+ expect(landing('2023-09-15', '2023-10-15')).toBe('2023-10-15');
+ });
+
+ it('counts up to the first occurrence on or after a floor between occurrences', () => {
+ expect(months('2022-06-15', '2023-01-01')).toBe(7);
+ expect(landing('2022-06-15', '2023-01-01')).toBe('2023-01-15');
+ });
+
+ it('counts an occurrence that lands exactly on the floor', () => {
+ expect(months('2022-06-15', '2023-01-15')).toBe(7);
+ expect(landing('2022-06-15', '2023-01-15')).toBe('2023-01-15');
+ });
+
+ it('needs one more month when the floor is a day past an occurrence', () => {
+ expect(months('2022-06-15', '2023-01-16')).toBe(8);
+ expect(landing('2022-06-15', '2023-01-16')).toBe('2023-02-15');
+ });
+
+ it('lands on the clamped day when an end-of-month start hits a short month exactly', () => {
+ expect(months('2024-01-31', '2024-02-29')).toBe(1);
+ expect(landing('2024-01-31', '2024-02-29')).toBe('2024-02-29');
+ });
+
+ it('does not skip a month when the floor is before the clamped day', () => {
+ expect(months('2024-01-31', '2024-02-15')).toBe(1);
+ expect(landing('2024-01-31', '2024-02-15')).toBe('2024-02-29');
+ });
+
+ it('adds a month when the clamped occurrence falls short of the floor', () => {
+ // Jan 31, 2024 + 13 months clamps to Feb 28, 2025, which is before Mar 1.
+ expect(months('2024-01-31', '2025-03-01')).toBe(14);
+ expect(landing('2024-01-31', '2025-03-01')).toBe('2025-03-31');
+ });
+
+ it('lands a leap-day start on its clamped anniversary', () => {
+ expect(months('2024-02-29', '2025-02-28')).toBe(12);
+ expect(landing('2024-02-29', '2025-02-28')).toBe('2025-02-28');
+ });
+
+ it('adds a month when a leap-day anniversary falls short of the floor', () => {
+ expect(months('2024-02-29', '2025-03-01')).toBe(13);
+ expect(landing('2024-02-29', '2025-03-01')).toBe('2025-03-29');
+ });
+
+ it('lands exactly on the floor across a ten-year span', () => {
+ expect(months('2015-06-15', '2025-06-15')).toBe(120);
+ expect(landing('2015-06-15', '2025-06-15')).toBe('2025-06-15');
+ });
+
+ it('treats a later time on the same day as after the start, so callers should normalize first', () => {
+ const start = DateTime.fromISO('2023-09-15T00:00:00');
+ const laterThatDay = DateTime.fromISO('2023-09-15T10:00:00');
+
+ const unnormalized = monthsUntilOccurrenceOnOrAfter(start, laterThatDay);
+ expect(unnormalized).toBe(1);
+ expect(start.plus({ months: unnormalized }).toISODate()).toBe('2023-10-15');
+
+ const normalized = monthsUntilOccurrenceOnOrAfter(
+ start,
+ laterThatDay.startOf('day'),
+ );
+ expect(normalized).toBe(0);
+ expect(start.plus({ months: normalized }).toISODate()).toBe('2023-09-15');
+ });
+
+ it('expects callers to re-anchor a start from another zone before comparing', () => {
+ // A date parsed with setZone keeps the API's offset, so its year and month fields can
+ // disagree with a local floor near a month boundary. Re-anchoring keeps the wall-clock
+ // date and puts both operands in one zone.
+ const start = DateTime.fromISO('2023-09-15T00:00:00+04:00', {
+ setZone: true,
+ });
+ const floor = DateTime.fromISO('2023-09-15T00:00:00Z', { setZone: true });
+
+ const reanchored = start.setZone(floor.zone, { keepLocalTime: true });
+ const months = monthsUntilOccurrenceOnOrAfter(reanchored, floor);
+ expect(months).toBe(0);
+ expect(reanchored.plus({ months }).toISODate()).toBe('2023-09-15');
+ });
+
+ it('is unaffected by a daylight saving change between the start and the floor', () => {
+ const zone = 'America/New_York';
+ const start = DateTime.fromISO('2024-01-15', { zone });
+ const floor = DateTime.fromISO('2024-04-15', { zone });
+
+ const onFloor = monthsUntilOccurrenceOnOrAfter(start, floor);
+ expect(onFloor).toBe(3);
+ expect(start.plus({ months: onFloor }).toISODate()).toBe('2024-04-15');
+
+ const pastFloor = monthsUntilOccurrenceOnOrAfter(
+ start,
+ floor.plus({ days: 1 }),
+ );
+ expect(pastFloor).toBe(4);
+ expect(start.plus({ months: pastFloor }).toISODate()).toBe('2024-05-15');
+ });
+});
diff --git a/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.ts b/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.ts
new file mode 100644
index 000000000..c7acac450
--- /dev/null
+++ b/src/components/HrTools/SavingsFundTransfer/Helper/monthsUntilOccurrenceOnOrAfter.ts
@@ -0,0 +1,20 @@
+import { DateTime } from 'luxon';
+
+// Number of months to add to `start` so the monthly occurrence lands on or after `floor`.
+//
+// Add the result to `start` in one step rather than walking month by month: Luxon clamps an
+// end-of-month day in shorter months (Jan 31 + 1 month = Feb 28), and stepping on from the
+// clamped date would put every later occurrence on the 28th.
+export function monthsUntilOccurrenceOnOrAfter(
+ start: DateTime,
+ floor: DateTime,
+): number {
+ if (start >= floor) {
+ return 0;
+ }
+
+ const wholeMonths = Math.floor(floor.diff(start, 'months').months);
+ return start.plus({ months: wholeMonths }) >= floor
+ ? wholeMonths
+ : wholeMonths + 1;
+}
diff --git a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx
index 51653cab5..cdd84cf40 100644
--- a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.test.tsx
@@ -15,7 +15,7 @@ import {
Transfers,
mockData,
} from '../mockData';
-import { TransfersTable } from './TransfersTable';
+import { CreateTransferRows, TransfersTable } from './TransfersTable';
const mutationSpy = jest.fn();
const handleOpenMock = jest.fn();
@@ -371,4 +371,15 @@ describe('TransferHistoryTable', () => {
expect(nextPaymentCell(container)).toBe('');
});
});
+
+ describe('CreateTransferRows', () => {
+ it('carries the truncated-history flag through to the row the failed modal reads', () => {
+ const row = CreateTransferRows({
+ ...mockHistory[0],
+ historyTruncated: true,
+ });
+
+ expect(row.historyTruncated).toBe(true);
+ });
+ });
});
diff --git a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx
index 566552e91..1924c4ddf 100644
--- a/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/Table/TransfersTable.tsx
@@ -52,6 +52,7 @@ export const CreateTransferRows = (history: Transfers): Transfers => ({
baseAmount: history.baseAmount,
summarizedTransfers: history.summarizedTransfers ?? null,
missingMonths: history.missingMonths ?? null,
+ historyTruncated: history.historyTruncated ?? false,
});
const createToolbar = (history: Transfers[], type: TableTypeEnum) => {
diff --git a/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.test.tsx b/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.test.tsx
index df394ba4c..fc13db27e 100644
--- a/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.test.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.test.tsx
@@ -210,9 +210,11 @@ jest.mock('notistack', () => ({
const Components = ({
title = 'Staff Savings Fund Transfers',
usStaffGroup = UsStaffGroupEnum.SeniorStaff,
+ transfers,
}: {
title?: string;
usStaffGroup?: UsStaffGroupEnum;
+ transfers?: ReportsSavingsFundTransferQuery['reportsSavingsFundTransfer'];
}) => (
@@ -224,7 +226,15 @@ const Components = ({
FundBalances: FundBalancesQuery;
GetUser: GetUserQuery;
}>
- mocks={{ ...mock, GetUser: { user: { usStaffGroup } } }}
+ mocks={{
+ ...mock,
+ ...(transfers && {
+ ReportsSavingsFundTransfer: {
+ reportsSavingsFundTransfer: transfers,
+ },
+ }),
+ GetUser: { user: { usStaffGroup } },
+ }}
onCall={mutationSpy}
>
@@ -305,6 +315,65 @@ describe('TransfersPage', () => {
expect(within(tables[0]).getAllByRole('columnheader')).toHaveLength(9);
});
+ it('requests transfer history from the start of the month one year ago', async () => {
+ render();
+
+ // SAA would default to this same window; sending it explicitly lets the page
+ // know where the history begins so it does not flag earlier months as failed.
+ await waitFor(() =>
+ expect(mutationSpy).toHaveGraphqlOperation('ReportsSavingsFundTransfer', {
+ transactedAtStart: '2023-01-01',
+ }),
+ );
+ });
+
+ it('only lists failed months inside the history window for a long-running recurring transfer', async () => {
+ // Started in 2022, so everything before the 2023-01-01 window start has no rows.
+ const recurringTransfer = {
+ id: '4',
+ amount: 50,
+ recurringStart: '2022-06-15',
+ recurringEnd: null,
+ active: true,
+ };
+ const longRunning = ['2023-11-15', '2024-01-15'].map((date, index) => ({
+ transaction: {
+ id: `long-${index}`,
+ amount: 50,
+ description: null,
+ transactedAt: `${date}T00:00:00+00:00`,
+ },
+ subCategory: { id: '1', name: 'deposit' },
+ transfer: {
+ sourceFundTypeName: 'Primary',
+ destinationFundTypeName: 'Savings',
+ },
+ recurringTransfer,
+ scheduledTransfer: null,
+ }));
+ const { findByTitle, findByRole } = render(
+ ,
+ );
+
+ userEvent.click(
+ await findByTitle('Failed Transfers', {}, { timeout: 10000 }),
+ );
+
+ const dialog = await findByRole('dialog');
+ expect(
+ within(dialog).getByText(
+ 'Only the last year of transfer history is shown.',
+ ),
+ ).toBeInTheDocument();
+
+ const dates = within(dialog)
+ .getAllByRole('row')
+ .slice(1)
+ .map((row) => row.querySelectorAll('td')[2]?.textContent);
+ expect(dates[0]).toBe('Jan 15, 2023');
+ expect(dates.some((date) => date?.endsWith('2022'))).toBe(false);
+ });
+
it.each([
UsStaffGroupEnum.SeniorInternationalStaff,
UsStaffGroupEnum.NewInternationalStaff,
diff --git a/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.tsx b/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.tsx
index b28449e3e..914e911f0 100644
--- a/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.tsx
+++ b/src/components/HrTools/SavingsFundTransfer/TransfersPage/TransfersPage.tsx
@@ -105,8 +105,21 @@ export const TransfersPage: React.FC = ({ title }) => {
const { data: staffAccountData, error: staffAccountError } =
useStaffAccountQuery();
+ // The page shows about a year of history. SAA would default to this same window if none were
+ // sent, but sending it explicitly lets the missed-month scan know where the fetched history
+ // begins, so a recurring transfer that started earlier is not shown as failed for months that
+ // have no rows (MPDX-10044).
+ const historyStart = useMemo(
+ () => DateTime.local().minus({ years: 1 }).startOf('month'),
+ [],
+ );
+
const { data: reportData, loading: reportLoading } =
- useReportsSavingsFundTransferQuery();
+ useReportsSavingsFundTransferQuery({
+ variables: {
+ transactedAtStart: historyStart.toISODate(),
+ },
+ });
const { data: fundsData, error: fundsError } = useFundBalancesQuery({
variables: {
fundTypes,
@@ -166,14 +179,15 @@ export const TransfersPage: React.FC = ({ title }) => {
failedCount: 0,
summarizedTransfers: null,
missingMonths: null,
+ historyTruncated: false,
};
}),
[reportData],
);
const { filtered, upcoming } = useMemo(
- () => filteredTransfers(transactions),
- [transactions],
+ () => filteredTransfers(transactions, historyStart),
+ [transactions, historyStart],
);
const transferHistory: Transfers[] = filtered.map((tx) => {
@@ -202,6 +216,7 @@ export const TransfersPage: React.FC = ({ title }) => {
failedCount: tx.failedCount,
summarizedTransfers: tx.summarizedTransfers,
missingMonths: tx.missingMonths,
+ historyTruncated: tx.historyTruncated,
};
});
diff --git a/src/components/HrTools/SavingsFundTransfer/mockData.ts b/src/components/HrTools/SavingsFundTransfer/mockData.ts
index 8b780e4cb..bd37ca4f6 100644
--- a/src/components/HrTools/SavingsFundTransfer/mockData.ts
+++ b/src/components/HrTools/SavingsFundTransfer/mockData.ts
@@ -86,6 +86,7 @@ export interface Transactions {
failedCount?: number;
summarizedTransfers?: Map | null;
missingMonths?: DateTime[] | null;
+ historyTruncated?: boolean;
}
export interface Transfers {
@@ -106,6 +107,7 @@ export interface Transfers {
failedCount?: number;
summarizedTransfers?: Map | null;
missingMonths?: DateTime[] | null;
+ historyTruncated?: boolean;
}
export const incomingTransfers = [