Add "only import transactions since" (#7139)

* Add "only import transactions since"

If specified, we filter out transactions before the given date.

* Address 🐰 comments

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7139

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Asherah Connor
2026-03-11 21:52:21 +11:00
committed by GitHub
parent 0b21b572fe
commit c06f96f015
10 changed files with 201 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -25,6 +25,7 @@ import { Transaction } from './Transaction';
import {
applyFieldMappings,
dateFormats,
filterByStartDate,
isDateFormat,
parseAmountFields,
parseDate,
@@ -218,6 +219,7 @@ export function ImportTransactionsModal({
);
const [clearOnImport, setClearOnImport] = useState(true);
const [startDate, setStartDate] = useState('');
const getImportPreview = useCallback(
async (
@@ -672,9 +674,19 @@ export function ImportTransactionsModal({
const importPreviewTransactions = useImportPreviewTransactionsMutation();
const onImportPreview = useEffectEvent(async () => {
// Filter by start date before preview and deduplication
const isPreParsed = isOfxFile(filetype) || isCamtFile(filetype);
const filteredTransactions = filterByStartDate(
parsedTransactions,
startDate,
isPreParsed,
fieldMappings,
parseDateFormat,
);
// always start from the original parsed transactions, not the previewed ones to ensure rules run
const previewTransactionsToImport = await getImportPreview(
parsedTransactions,
filteredTransactions,
filetype,
flipAmount,
fieldMappings,
@@ -699,7 +711,7 @@ export function ImportTransactionsModal({
return map;
}, {});
const previewTransactions = parsedTransactions
const previewTransactions = filteredTransactions
.filter(trans => !trans.isMatchedTransaction)
.reduce((previous, currentTrx) => {
let next = previous;
@@ -750,7 +762,13 @@ export function ImportTransactionsModal({
}
void onImportPreview();
}, [loadingState, parsedTransactions.length]);
}, [
loadingState,
parsedTransactions.length,
startDate,
fieldMappings,
parseDateFormat,
]);
const headers: ComponentProps<typeof TableHeader>['headers'] = [
{ name: t('Date'), width: 200 },
@@ -884,6 +902,39 @@ export function ImportTransactionsModal({
</View>
)}
<View
style={{
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 5,
}}
>
<label
htmlFor="start-date-filter"
style={{
display: 'flex',
flexDirection: 'row',
gap: 5,
alignItems: 'baseline',
}}
>
<Trans>Only import transactions since:</Trans>
<Input
id="start-date-filter"
type="date"
value={startDate}
onChangeValue={value => setStartDate(value)}
style={{ width: 150 }}
/>
</label>
{startDate && (
<Button onPress={() => setStartDate('')}>
<Trans>Clear</Trans>
</Button>
)}
</View>
{filetype === 'csv' && (
<View style={{ marginTop: 10 }}>
<FieldMappings

View File

@@ -1,4 +1,5 @@
import { parseDate } from './utils';
import { filterByStartDate, parseDate } from './utils';
import type { ImportTransaction } from './utils';
describe('Import transactions', () => {
describe('date parsing', () => {
@@ -164,4 +165,121 @@ describe('Import transactions', () => {
},
);
});
describe('filterByStartDate', () => {
function makeTrans(
overrides: Partial<ImportTransaction>,
): ImportTransaction {
return {
trx_id: '0',
existing: false,
ignored: false,
selected: true,
selected_merge: false,
amount: 0,
inflow: 0,
outflow: 0,
inOut: '',
...overrides,
};
}
it('returns all transactions when startDate is empty', () => {
const transactions = [
makeTrans({ trx_id: '0', date: '2024-01-15' }),
makeTrans({ trx_id: '1', date: '2024-02-20' }),
];
const result = filterByStartDate(transactions, '', true, null, null);
expect(result).toHaveLength(2);
});
it('filters out transactions before startDate for pre-parsed dates', () => {
const transactions = [
makeTrans({ trx_id: '0', date: '2024-01-15' }),
makeTrans({ trx_id: '1', date: '2024-02-20' }),
makeTrans({ trx_id: '2', date: '2024-03-01' }),
];
const result = filterByStartDate(
transactions,
'2024-02-01',
true,
null,
null,
);
expect(result).toHaveLength(2);
expect(result.map(t => t.trx_id)).toEqual(['1', '2']);
});
it('includes transactions on the exact startDate', () => {
const transactions = [makeTrans({ trx_id: '0', date: '2024-02-01' })];
const result = filterByStartDate(
transactions,
'2024-02-01',
true,
null,
null,
);
expect(result).toHaveLength(1);
});
it('keeps transactions with unparseable dates', () => {
const transactions = [makeTrans({ trx_id: '0', date: 'invalid' })];
const result = filterByStartDate(
transactions,
'2024-02-01',
false,
null,
'mm dd yyyy',
);
expect(result).toHaveLength(1);
});
it('works with CSV dates requiring date format parsing', () => {
const transactions = [
makeTrans({ trx_id: '0', date: '01/15/2024' }),
makeTrans({ trx_id: '1', date: '03/01/2024' }),
];
const result = filterByStartDate(
transactions,
'2024-02-01',
false,
null,
'mm dd yyyy',
);
expect(result).toHaveLength(1);
expect(result[0].trx_id).toBe('1');
});
it('works with field mappings for CSV', () => {
const transactions = [
makeTrans({
trx_id: '0',
Date: '01/15/2024',
} satisfies Partial<ImportTransaction>),
makeTrans({
trx_id: '1',
Date: '03/01/2024',
} satisfies Partial<ImportTransaction>),
];
const fieldMappings = {
date: 'Date',
amount: null,
payee: null,
notes: null,
inOut: null,
category: null,
outflow: null,
inflow: null,
};
const result = filterByStartDate(
transactions,
'2024-02-01',
false,
fieldMappings,
'mm dd yyyy',
);
expect(result).toHaveLength(1);
expect(result[0].trx_id).toBe('1');
});
});
});

View File

@@ -263,6 +263,28 @@ export function parseAmountFields(
}
}
export function filterByStartDate(
transactions: ImportTransaction[],
startDate: string,
isPreParsedDate: boolean,
fieldMappings: FieldMapping | null,
parseDateFormat: DateFormat | null,
): ImportTransaction[] {
if (!startDate) return transactions;
return transactions.filter(trans => {
const mapped = fieldMappings
? applyFieldMappings(trans, fieldMappings)
: trans;
const date = isPreParsedDate
? (mapped.date ?? null)
: parseDateFormat
? parseDate(mapped.date ?? null, parseDateFormat)
: null;
// Keep transactions with unparseable dates (they'll error later in the normal flow)
return date == null || date >= startDate;
});
}
export function stripCsvImportTransaction(transaction: ImportTransaction) {
const {
existing: _existing,