Skip to content

Commit 939b870

Browse files
authored
Merge pull request #1 from lelemm/DetectInstallments
Create schedules from csv import
2 parents 914106e + 9e4f185 commit 939b870

File tree

8 files changed

+403
-11
lines changed

8 files changed

+403
-11
lines changed

packages/api/methods.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ export function addTransactions(
8282
}
8383

8484
export function importTransactions(accountId, transactions) {
85-
return send('api/transactions-import', { accountId, transactions });
85+
return send('api/transactions-import', {
86+
accountId,
87+
transactions,
88+
detectInstallments: false,
89+
updateDetectInstallmentDate: false,
90+
ignoreAlreadyDetectedInstallments: false,
91+
});
8692
}
8793

8894
export function getTransactions(accountId, startDate, endDate) {

packages/desktop-client/src/components/modals/ImportTransactions.jsx

+153-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
22

33
import * as d from 'date-fns';
44

5+
import * as monthUtils from 'loot-core/src/shared/months';
56
import { format as formatDate_ } from 'loot-core/src/shared/months';
67
import {
78
amountToCurrency,
@@ -133,13 +134,24 @@ function getFileType(filepath) {
133134
return rawType;
134135
}
135136

136-
function ParsedDate({ parseDateFormat, dateFormat, date }) {
137+
function ParsedDate({ parseDateFormat, dateFormat, date, installmentParcel }) {
137138
const parsed =
138139
date &&
139140
formatDate(
140141
parseDateFormat ? parseDate(date, parseDateFormat) : date,
141142
dateFormat,
142143
);
144+
const overrideDate =
145+
parsed != null
146+
? d.format(
147+
d.addMonths(
148+
monthUtils._parse(parseDate(parsed)),
149+
installmentParcel - 1,
150+
),
151+
dateFormat,
152+
)
153+
: null;
154+
143155
return (
144156
<Text>
145157
<Text>
@@ -153,6 +165,18 @@ function ParsedDate({ parseDateFormat, dateFormat, date }) {
153165
<Text style={{ color: parsed ? theme.noticeTextLight : theme.errorText }}>
154166
{parsed || 'Invalid'}
155167
</Text>
168+
{installmentParcel && (
169+
<Text>
170+
&rarr;{' '}
171+
<Text
172+
style={{
173+
color: overrideDate ? theme.noticeTextLight : theme.errorText,
174+
}}
175+
>
176+
{overrideDate || 'Invalid'}
177+
</Text>
178+
</Text>
179+
)}
156180
</Text>
157181
);
158182
}
@@ -323,6 +347,17 @@ function parseCategoryFields(trans, categories) {
323347
return match;
324348
}
325349

350+
function markInstallment(transaction, mapping) {
351+
return (
352+
<Checkbox
353+
checked={
354+
transaction[mapping['notes']]?.match(/\((\d{2})\/(\d{2})\)/) ?? false
355+
}
356+
readOnly={true}
357+
/>
358+
);
359+
}
360+
326361
function Transaction({
327362
transaction: rawTransaction,
328363
fieldMappings,
@@ -335,6 +370,8 @@ function Transaction({
335370
flipAmount,
336371
multiplierAmount,
337372
categories,
373+
detectInstallments,
374+
updateDetectedInstallmentDate,
338375
}) {
339376
const categoryList = categories.map(category => category.name);
340377
const transaction = useMemo(
@@ -354,18 +391,33 @@ function Transaction({
354391
multiplierAmount,
355392
);
356393

394+
let installmentParcel = null;
395+
if (
396+
detectInstallments &&
397+
updateDetectedInstallmentDate &&
398+
transaction.date !== null &&
399+
transaction.date !== undefined
400+
) {
401+
const matches =
402+
rawTransaction[fieldMappings['notes']]?.match(/\((\d{2})\/(\d{2})\)/);
403+
if (matches) {
404+
installmentParcel = parseInt(matches[1]);
405+
}
406+
}
407+
357408
return (
358409
<Row
359410
style={{
360411
backgroundColor: theme.tableBackground,
361412
}}
362413
>
363-
<Field width={200}>
414+
<Field width={updateDetectedInstallmentDate ? 300 : 200}>
364415
{showParsed ? (
365416
<ParsedDate
366417
parseDateFormat={parseDateFormat}
367418
dateFormat={dateFormat}
368419
date={transaction.date}
420+
installmentParcel={installmentParcel}
369421
/>
370422
) : (
371423
formatDate(transaction.date, dateFormat)
@@ -455,6 +507,18 @@ function Transaction({
455507
</Field>
456508
</>
457509
)}
510+
{detectInstallments && (
511+
<Field
512+
width={90}
513+
title="Installment"
514+
style={{ display: 'flex', alignItems: 'flex-end' }}
515+
contentStyle={{
516+
textAlign: 'right',
517+
}}
518+
>
519+
{markInstallment(rawTransaction, fieldMappings)}
520+
</Field>
521+
)}
458522
</Row>
459523
);
460524
}
@@ -757,6 +821,13 @@ export function ImportTransactions({ modalProps, options }) {
757821
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
758822
const [reconcile, setReconcile] = useState(true);
759823
const { accountId, categories, onImported } = options;
824+
const [detectInstallments, setDetectInstallments] = useState(false);
825+
const [updateDetectedInstallmentDate, setUpdateDetectedInstallmentDate] =
826+
useState(false);
827+
const [
828+
ignoreAlreadyDetectedInstallments,
829+
setIgnoreAlreadyDetectedInstallments,
830+
] = useState(false);
760831

761832
// This cannot be set after parsing the file, because changing it
762833
// requires re-parsing the file. This is different from the other
@@ -916,10 +987,32 @@ export function ImportTransactions({ modalProps, options }) {
916987
for (let trans of transactions) {
917988
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
918989

919-
const date =
990+
let date =
920991
isOfxFile(filetype) || isCamtFile(filetype)
921992
? trans.date
922993
: parseDate(trans.date, parseDateFormat);
994+
995+
if (updateDetectedInstallmentDate) {
996+
if (
997+
detectInstallments &&
998+
updateDetectedInstallmentDate &&
999+
trans.date !== null &&
1000+
trans.date !== undefined
1001+
) {
1002+
const matches = trans.notes?.match(/\((\d{2})\/(\d{2})\)/);
1003+
if (matches) {
1004+
const installmentParcel = parseInt(matches[1]);
1005+
date =
1006+
date != null
1007+
? d.format(
1008+
d.addMonths(monthUtils._parse(date), installmentParcel - 1),
1009+
'yyyy-MM-dd',
1010+
)
1011+
: null;
1012+
}
1013+
}
1014+
}
1015+
9231016
if (date == null) {
9241017
errorMessage = `Unable to parse date ${
9251018
trans.date || '(empty)'
@@ -984,6 +1077,9 @@ export function ImportTransactions({ modalProps, options }) {
9841077
accountId,
9851078
finalTransactions,
9861079
reconcile,
1080+
detectInstallments,
1081+
updateDetectedInstallmentDate,
1082+
ignoreAlreadyDetectedInstallments,
9871083
);
9881084
if (didChange) {
9891085
await getPayees();
@@ -997,7 +1093,7 @@ export function ImportTransactions({ modalProps, options }) {
9971093
}
9981094

9991095
const headers = [
1000-
{ name: 'Date', width: 200 },
1096+
{ name: 'Date', width: updateDetectedInstallmentDate ? 300 : 200 },
10011097
{ name: 'Payee', width: 'flex' },
10021098
{ name: 'Notes', width: 'flex' },
10031099
{ name: 'Category', width: 'flex' },
@@ -1013,14 +1109,22 @@ export function ImportTransactions({ modalProps, options }) {
10131109
headers.push({ name: 'Amount', width: 90, style: { textAlign: 'right' } });
10141110
}
10151111

1112+
if (detectInstallments) {
1113+
headers.push({
1114+
name: 'Installment',
1115+
width: 90,
1116+
style: { textAlign: 'right' },
1117+
});
1118+
}
1119+
10161120
return (
10171121
<Modal
10181122
title={
10191123
'Import transactions' + (filetype ? ` (${filetype.toUpperCase()})` : '')
10201124
}
10211125
{...modalProps}
10221126
loading={loadingState === 'parsing'}
1023-
style={{ width: 800 }}
1127+
style={{ width: 900 }}
10241128
>
10251129
{error && !error.parsed && (
10261130
<View style={{ alignItems: 'center', marginBottom: 15 }}>
@@ -1072,6 +1176,8 @@ export function ImportTransactions({ modalProps, options }) {
10721176
flipAmount={flipAmount}
10731177
multiplierAmount={multiplierAmount}
10741178
categories={categories.list}
1179+
detectInstallments={detectInstallments}
1180+
updateDetectedInstallmentDate={updateDetectedInstallmentDate}
10751181
/>
10761182
</View>
10771183
)}
@@ -1222,6 +1328,9 @@ export function ImportTransactions({ modalProps, options }) {
12221328
checked={reconcile}
12231329
onChange={() => {
12241330
setReconcile(state => !state);
1331+
setDetectInstallments(false);
1332+
setUpdateDetectedInstallmentDate(false);
1333+
setIgnoreAlreadyDetectedInstallments(false);
12251334
}}
12261335
>
12271336
Reconcile transactions
@@ -1270,6 +1379,45 @@ export function ImportTransactions({ modalProps, options }) {
12701379
onChangeAmount={onMultiplierChange}
12711380
/>
12721381
</View>
1382+
<View style={{ marginRight: 25, gap: 5 }}>
1383+
<SectionLabel title="MISC" />
1384+
<CheckboxOption
1385+
id="form_detect_installments"
1386+
checked={detectInstallments}
1387+
disabled={!reconcile}
1388+
onChange={() => {
1389+
setDetectInstallments(!detectInstallments);
1390+
setUpdateDetectedInstallmentDate(false);
1391+
setIgnoreAlreadyDetectedInstallments(false);
1392+
}}
1393+
>
1394+
Detect Installments
1395+
</CheckboxOption>
1396+
<CheckboxOption
1397+
id="form_detect_installments_update_installment_date"
1398+
checked={updateDetectedInstallmentDate}
1399+
disabled={!detectInstallments}
1400+
onChange={() =>
1401+
setUpdateDetectedInstallmentDate(
1402+
!updateDetectedInstallmentDate,
1403+
)
1404+
}
1405+
>
1406+
Update installment parcel to right month date
1407+
</CheckboxOption>
1408+
<CheckboxOption
1409+
id="form_detect_installments_already_detected"
1410+
checked={ignoreAlreadyDetectedInstallments}
1411+
disabled={!detectInstallments}
1412+
onChange={() =>
1413+
setIgnoreAlreadyDetectedInstallments(
1414+
!ignoreAlreadyDetectedInstallments,
1415+
)
1416+
}
1417+
>
1418+
Don&apos;t create installment if already exists
1419+
</CheckboxOption>
1420+
</View>
12731421
</Stack>
12741422
</View>
12751423
)}

packages/loot-core/src/client/actions/account.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,14 @@ export function parseTransactions(filepath, options) {
185185
};
186186
}
187187

188-
export function importTransactions(id: string, transactions, reconcile = true) {
188+
export function importTransactions(
189+
id: string,
190+
transactions,
191+
reconcile = true,
192+
detectInstallments = false,
193+
updateDetectInstallmentDate = false,
194+
ignoreAlreadyDetectedInstallments = false,
195+
) {
189196
return async (dispatch: Dispatch): Promise<boolean> => {
190197
if (!reconcile) {
191198
await send('api/transactions-add', {
@@ -203,6 +210,9 @@ export function importTransactions(id: string, transactions, reconcile = true) {
203210
} = await send('transactions-import', {
204211
accountId: id,
205212
transactions,
213+
detectInstallments,
214+
updateDetectInstallmentDate,
215+
ignoreAlreadyDetectedInstallments,
206216
});
207217

208218
errors.forEach(error => {

0 commit comments

Comments
 (0)