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>
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||