diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index 493ace69..2edb3aa5 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -1197,6 +1197,20 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) timezoneFormat = timezoneFormats[0] } + amountDecimalSeparators := form.Value["amountDecimalSeparator"] + amountDecimalSeparator := "" + + if len(amountDecimalSeparators) > 0 { + amountDecimalSeparator = amountDecimalSeparators[0] + } + + amountDigitGroupingSymbols := form.Value["amountDigitGroupingSymbol"] + amountDigitGroupingSymbol := "" + + if len(amountDigitGroupingSymbols) > 0 { + amountDigitGroupingSymbol = amountDigitGroupingSymbols[0] + } + geoLocationSeparators := form.Value["geoSeparator"] geoLocationSeparator := "" @@ -1211,7 +1225,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) transactionTagSeparator = transactionTagSeparators[0] } - dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, geoLocationSeparator, transactionTagSeparator) + dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, transactionTagSeparator) } else { dataImporter, err = converters.GetTransactionDataImporter(fileType) } diff --git a/pkg/converters/dsv/custom_transaction_data_dsv_file_importer.go b/pkg/converters/dsv/custom_transaction_data_dsv_file_importer.go index db67fcc8..d2484c5d 100644 --- a/pkg/converters/dsv/custom_transaction_data_dsv_file_importer.go +++ b/pkg/converters/dsv/custom_transaction_data_dsv_file_importer.go @@ -103,6 +103,8 @@ type customTransactionDataDsvFileImporter struct { hasHeaderLine bool timeFormat string timezoneFormat string + amountDecimalSeparator string + amountDigitGroupingSymbol string geoLocationSeparator string transactionTagSeparator string } @@ -155,7 +157,7 @@ func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Contex } dataTable := csvconverter.CreateNewCustomCsvImportedDataTable(allLines) - transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat) + transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol) dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.transactionTagSeparator) return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) @@ -188,7 +190,7 @@ func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding s } // CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data -func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (converter.TransactionDataImporter, error) { +func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, transactionTagSeparator string) (converter.TransactionDataImporter, error) { separator, exists := supportedFileTypeSeparators[fileType] if !exists { @@ -221,6 +223,8 @@ func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding hasHeaderLine: hasHeaderLine, timeFormat: timeFormat, timezoneFormat: timezoneFormat, + amountDecimalSeparator: amountDecimalSeparator, + amountDigitGroupingSymbol: amountDigitGroupingSymbol, geoLocationSeparator: geoLocationSeparator, transactionTagSeparator: transactionTagSeparator, }, nil diff --git a/pkg/converters/dsv/custom_transaction_data_dsv_file_importer_test.go b/pkg/converters/dsv/custom_transaction_data_dsv_file_importer_test.go index 427bfa23..34480949 100644 --- a/pkg/converters/dsv/custom_transaction_data_dsv_file_importer_test.go +++ b/pkg/converters/dsv/custom_transaction_data_dsv_file_importer_test.go @@ -77,7 +77,7 @@ func TestCustomTransactionDataDsvFileImporter_MinimumValidData(t *testing.T) { "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", ".", "", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -168,7 +168,7 @@ func TestCustomTransactionDataDsvFileImporter_WithAllSupportedColumns(t *testing "Expense": models.TRANSACTION_TYPE_EXPENSE, "Transfer": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, true, "YYYY-MM-DD HH:mm:ss", "", " ", ";") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, true, "YYYY-MM-DD HH:mm:ss", "", ".", "", " ", ";") assert.Nil(t, err) context := core.NewNullContext() @@ -261,7 +261,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidTime(t *testing.T) { transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -292,7 +292,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseTransactionWithoutType(t *tes "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -316,7 +316,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidType(t *testing.T) { transactionTypeMapping := map[string]models.TransactionType{ "B": 0, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -340,7 +340,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseTimeWithTimezone(t *testing.T transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ssZ", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ssZ", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -378,7 +378,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseTimeWithTimezone2(t *testing. transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ssZZ", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ssZZ", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -417,7 +417,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseValidTimezone(t *testing.T) { transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -456,7 +456,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseValidTimezone2(t *testing.T) transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "ZZ", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "ZZ", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -495,7 +495,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidTimezoneFormat(t *test transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "z", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "z", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -520,7 +520,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidTimezone(t *testing.T) transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -549,7 +549,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidTimezone2(t *testing.T transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "ZZ", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "ZZ", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -568,6 +568,80 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidTimezone2(t *testing.T assert.EqualError(t, err, errs.ErrTransactionTimeZoneInvalid.Message) } +func TestCustomTransactionDataDsvFileImporter_ParseAmountWithCustomFormat(t *testing.T) { + columnIndexMapping := map[datatable.TransactionDataTableColumn]int{ + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, + datatable.TRANSACTION_DATA_TABLE_AMOUNT: 2, + } + transactionTypeMapping := map[string]models.TransactionType{ + "E": models.TRANSACTION_TYPE_EXPENSE, + } + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_tsv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ",", ".", "", "") + assert.Nil(t, err) + + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + "2024-09-01 12:34:56\tE\t1.234,56"), 0, nil, nil, nil, nil, nil) + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, int64(123456), allNewTransactions[0].Amount) +} + +func TestCustomTransactionDataDsvFileImporter_ParseInvalidAmountWithCustomFormat(t *testing.T) { + columnIndexMapping := map[datatable.TransactionDataTableColumn]int{ + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, + datatable.TRANSACTION_DATA_TABLE_AMOUNT: 2, + } + transactionTypeMapping := map[string]models.TransactionType{ + "E": models.TRANSACTION_TYPE_EXPENSE, + } + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_tsv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", ",", "", "") + assert.Nil(t, err) + + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + "2024-09-01 12:34:56\tE\t1.234,56"), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) +} + +func TestCustomTransactionDataDsvFileImporter_ParseInvalidAmountWithCustomFormat2(t *testing.T) { + columnIndexMapping := map[datatable.TransactionDataTableColumn]int{ + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, + datatable.TRANSACTION_DATA_TABLE_AMOUNT: 2, + } + transactionTypeMapping := map[string]models.TransactionType{ + "E": models.TRANSACTION_TYPE_EXPENSE, + } + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_tsv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ",", "", "", "") + assert.Nil(t, err) + + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + "2024-09-01 12:34:56\tE\t1.234,56"), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) +} + func TestCustomTransactionDataDsvFileImporter_ParsePrimaryCategory(t *testing.T) { columnIndexMapping := map[datatable.TransactionDataTableColumn]int{ datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, @@ -581,7 +655,7 @@ func TestCustomTransactionDataDsvFileImporter_ParsePrimaryCategory(t *testing.T) "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -650,7 +724,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseValidAccountCurrency(t *testi "B": models.TRANSACTION_TYPE_MODIFY_BALANCE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -693,7 +767,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidAccountCurrency(t *tes "B": models.TRANSACTION_TYPE_MODIFY_BALANCE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -729,7 +803,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseNotSupportedCurrency(t *testi "B": models.TRANSACTION_TYPE_MODIFY_BALANCE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -761,7 +835,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseValidAmount(t *testing.T) { "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -812,7 +886,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidAmount(t *testing.T) { "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -843,7 +917,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseNoAmount2(t *testing.T) { "E": models.TRANSACTION_TYPE_EXPENSE, "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -878,7 +952,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseValidGeographicLocation(t *te transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ";", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", ";", "") assert.Nil(t, err) context := core.NewNullContext() @@ -907,7 +981,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseInvalidGeographicLocation(t * transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", " ", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", " ", "") assert.Nil(t, err) context := core.NewNullContext() @@ -939,7 +1013,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseTag(t *testing.T) { transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", ";") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", ";") assert.Nil(t, err) context := core.NewNullContext() @@ -979,7 +1053,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseTagWithoutSeparator(t *testin transactionTypeMapping := map[string]models.TransactionType{ "E": models.TRANSACTION_TYPE_EXPENSE, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -1010,7 +1084,7 @@ func TestCustomTransactionDataDsvFileImporter_ParseDescription(t *testing.T) { transactionTypeMapping := map[string]models.TransactionType{ "T": models.TRANSACTION_TYPE_TRANSFER, } - converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + converter, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.Nil(t, err) context := core.NewNullContext() @@ -1037,7 +1111,7 @@ func TestCustomTransactionDataDsvFileImporter_InvalidSeparator(t *testing.T) { datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, datatable.TRANSACTION_DATA_TABLE_AMOUNT: 2, } - _, err := CreateNewCustomTransactionDataDsvFileImporter("test", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + _, err := CreateNewCustomTransactionDataDsvFileImporter("test", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.EqualError(t, err, errs.ErrImportFileTypeNotSupported.Message) } @@ -1050,7 +1124,7 @@ func TestCustomTransactionDataDsvFileImporter_InvalidFileEncoding(t *testing.T) datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, datatable.TRANSACTION_DATA_TABLE_AMOUNT: 2, } - _, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "ascii", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + _, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "ascii", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.EqualError(t, err, errs.ErrImportFileEncodingNotSupported.Message) } @@ -1064,7 +1138,7 @@ func TestCustomTransactionDataDsvFileImporter_MissingRequiredColumn(t *testing.T datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 0, datatable.TRANSACTION_DATA_TABLE_AMOUNT: 1, } - _, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + _, err := CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) // Missing Type Column @@ -1072,7 +1146,7 @@ func TestCustomTransactionDataDsvFileImporter_MissingRequiredColumn(t *testing.T datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, datatable.TRANSACTION_DATA_TABLE_AMOUNT: 1, } - _, err = CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + _, err = CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) // Missing Amount Column @@ -1080,6 +1154,6 @@ func TestCustomTransactionDataDsvFileImporter_MissingRequiredColumn(t *testing.T datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: 0, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: 1, } - _, err = CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", "", "") + _, err = CreateNewCustomTransactionDataDsvFileImporter("custom_csv", "utf-8", columnIndexMapping, transactionTypeMapping, false, "YYYY-MM-DD HH:mm:ss", "", ".", "", "", "") assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) } diff --git a/pkg/converters/dsv/custom_transaction_plain_text_data_table.go b/pkg/converters/dsv/custom_transaction_plain_text_data_table.go index 8b443a95..f92ab80f 100644 --- a/pkg/converters/dsv/custom_transaction_plain_text_data_table.go +++ b/pkg/converters/dsv/custom_transaction_plain_text_data_table.go @@ -20,6 +20,8 @@ type customPlainTextDataTable struct { timeFormat string timezoneFormat string timeFormatIncludeTimezone bool + amountDecimalSeparator string + amountDigitGroupingSymbol string } // customPlainTextDataRow defines the structure of custom plain text transaction data row @@ -174,27 +176,25 @@ func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user // trim trailing zero in decimal if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" { - rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]) - amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]) + amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]) if err != nil { log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error()) - return nil, false, errs.ErrAmountInvalid + return nil, false, err } - rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) + rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = amount } if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" { - rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT]) - amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT]) + amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT]) if err != nil { log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error()) - return nil, false, errs.ErrAmountInvalid + return nil, false, err } - rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount) + rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = amount } if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists { @@ -212,8 +212,31 @@ func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user return rowData, true, nil } +func (t *customPlainTextDataRowIterator) parseAmount(ctx core.Context, amountValue string) (string, error) { + if t.transactionDataTable.amountDigitGroupingSymbol != "" { + amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDigitGroupingSymbol, "") + } + + if t.transactionDataTable.amountDecimalSeparator != "" && t.transactionDataTable.amountDecimalSeparator != "." { + if strings.Contains(amountValue, ".") { + return "", errs.ErrAmountInvalid + } + + amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDecimalSeparator, ".") + } + + amountValue = utils.TrimTrailingZerosInDecimal(amountValue) + amount, err := utils.ParseAmount(amountValue) + + if err != nil { + return "", errs.ErrAmountInvalid + } + + return utils.FormatAmount(amount), nil +} + // CreateNewCustomPlainTextDataTable returns transaction data table from imported data table -func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string) *customPlainTextDataTable { +func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string) *customPlainTextDataTable { timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z") return &customPlainTextDataTable{ @@ -223,6 +246,8 @@ func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, co timeFormat: getDateTimeFormat(timeFormat), timezoneFormat: timezoneFormat, timeFormatIncludeTimezone: timeFormatIncludeTimezone, + amountDecimalSeparator: amountDecimalSeparator, + amountDigitGroupingSymbol: amountDigitGroupingSymbol, } } diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index e42d6f90..82a98176 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -81,6 +81,6 @@ func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding s } // CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding -func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (converter.TransactionDataImporter, error) { - return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, geoLocationSeparator, transactionTagSeparator) +func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, transactionTagSeparator string) (converter.TransactionDataImporter, error) { + return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, transactionTagSeparator) } diff --git a/src/core/numeral.ts b/src/core/numeral.ts index 1132c291..aca8a8ce 100644 --- a/src/core/numeral.ts +++ b/src/core/numeral.ts @@ -144,6 +144,87 @@ export class DigitGroupingType implements TypeAndName { } } +export class KnownAmountFormat { + private static readonly allInstances: KnownAmountFormat[] = []; + private static readonly allInstancesByType: Record = {}; + + public static readonly DotDecimalSeparator = new KnownAmountFormat('1234.56', DecimalSeparator.Dot, undefined, /^-?[0-9]+(\.[0-9]+)?$/); + public static readonly CommaDecimalSeparator = new KnownAmountFormat('1234,56', DecimalSeparator.Comma, undefined, /^-?[0-9]+(,[0-9]+)?$/); + public static readonly DotDecimalSeparatorWithCommaGroupingSymbol = new KnownAmountFormat('1,234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Comma, /^-?([0-9]+,)*[0-9]+(\.[0-9]+)?$/); + public static readonly CommaDecimalSeparatorWithDotGroupingSymbol = new KnownAmountFormat('1.234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Dot, /^-?([0-9]+\.)*[0-9]+(,[0-9]+)?$/); + public static readonly DotDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Space, /^-?([0-9]+ )*[0-9]+(\.[0-9]+)?$/); + public static readonly CommaDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Space, /^-?([0-9]+ )*[0-9]+(,[0-9]+)?$/); + public static readonly DotDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(\.[0-9]+)?$/); + public static readonly CommaDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(,[0-9]+)?$/); + + public readonly format: string; + public readonly decimalSeparator: DecimalSeparator; + public readonly digitGroupingSymbol?: DigitGroupingSymbol; + public readonly type: string; + private readonly regex: RegExp; + + private constructor(format: string, decimalSeparator: DecimalSeparator, digitGroupingSymbol: DigitGroupingSymbol | undefined, regex: RegExp) { + this.format = format; + this.decimalSeparator = decimalSeparator; + this.digitGroupingSymbol = digitGroupingSymbol; + this.type = this.decimalSeparator.type + '-' + (this.digitGroupingSymbol ? this.digitGroupingSymbol.type : 0).toString(); + this.regex = regex; + + KnownAmountFormat.allInstances.push(this); + KnownAmountFormat.allInstancesByType[this.type] = this; + } + + public isValid(amount: string): boolean { + return this.regex.test(amount); + } + + public static values(): KnownAmountFormat[] { + return KnownAmountFormat.allInstances; + } + + public static valueOf(type: string): KnownAmountFormat | undefined { + return KnownAmountFormat.allInstancesByType[type]; + } + + public static detect(amount: string): KnownAmountFormat[] | undefined { + const result: KnownAmountFormat[] = []; + + for (const format of KnownAmountFormat.allInstances) { + if (format.isValid(amount)) { + result.push(format); + } + } + + return result.length > 0 ? result : undefined; + } + + public static detectMulti(amounts: string[]): KnownAmountFormat[] | undefined { + const detectedCounts: Record = {}; + + for (const amount of amounts) { + const detectedFormats = KnownAmountFormat.detect(amount); + + if (detectedFormats) { + for (const format of detectedFormats) { + detectedCounts[format.type] = (detectedCounts[format.type] || 0) + 1; + } + } else { + return undefined; + } + } + + const result: KnownAmountFormat[] = []; + + for (const format of KnownAmountFormat.allInstances) { + if (detectedCounts[format.type] === amounts.length) { + result.push(format); + } + } + + return result.length > 0 ? result : undefined; + } +} + export class AmountFilterType { private static readonly allInstances: AmountFilterType[] = []; private static readonly allInstancesByType: Record = {}; diff --git a/src/lib/services.ts b/src/lib/services.ts index 582b2677..09e15dff 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -440,7 +440,7 @@ export default { timeout: DEFAULT_UPLOAD_API_TIMEOUT } as ApiRequestConfig); }, - parseImportTransaction: ({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record, transactionTypeMapping?: Record, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, geoSeparator?: string, tagSeparator?: string }): ApiResponsePromise => { + parseImportTransaction: ({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record, transactionTypeMapping?: Record, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, tagSeparator?: string }): ApiResponsePromise => { let textualColumnMapping: string | undefined = undefined; let textualTransactionTypeMapping: string | undefined = undefined; let textualHasHeaderLine: string | undefined = undefined; @@ -466,6 +466,8 @@ export default { hasHeaderLine: textualHasHeaderLine, timeFormat: timeFormat, timezoneFormat: timezoneFormat, + amountDecimalSeparator: amountDecimalSeparator, + amountDigitGroupingSymbol: amountDigitGroupingSymbol, geoSeparator: geoSeparator, tagSeparator: tagSeparator }, { diff --git a/src/locales/de.json b/src/locales/de.json index 82e9e2bb..1ea54575 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1668,6 +1668,7 @@ "Time Format": "Time Format", "Transaction Type Mapping": "Transaction Type Mapping", "Timezone Format": "Timezone Format", + "Amount Format": "Amount Format", "Geographic Location Separator": "Geographic Location Separator", "Transaction Tags Separator": "Transaction Tags Separator", "Lines Per Page": "Lines Per Page", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping", "Transaction type mapping is not set": "Transaction type mapping is not set", "Transaction time format is not set": "Transaction time format is not set", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "Ungültige Transaktionen können nicht importiert werden", "Unable to parse import file": "Importdatei kann nicht geparst werden", "Batch Replace Selected Expense Categories": "Ausgewählte Ausgabenkategorien im Batch ersetzen", diff --git a/src/locales/en.json b/src/locales/en.json index e06b0d85..a3756514 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1668,6 +1668,7 @@ "Time Format": "Time Format", "Transaction Type Mapping": "Transaction Type Mapping", "Timezone Format": "Timezone Format", + "Amount Format": "Amount Format", "Geographic Location Separator": "Geographic Location Separator", "Transaction Tags Separator": "Transaction Tags Separator", "Lines Per Page": "Lines Per Page", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping", "Transaction type mapping is not set": "Transaction type mapping is not set", "Transaction time format is not set": "Transaction time format is not set", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "Cannot import invalid transactions", "Unable to parse import file": "Unable to parse import file", "Batch Replace Selected Expense Categories": "Batch Replace Selected Expense Categories", diff --git a/src/locales/es.json b/src/locales/es.json index 49108eb1..12accfb0 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1668,6 +1668,7 @@ "Time Format": "Time Format", "Transaction Type Mapping": "Transaction Type Mapping", "Timezone Format": "Timezone Format", + "Amount Format": "Amount Format", "Geographic Location Separator": "Geographic Location Separator", "Transaction Tags Separator": "Transaction Tags Separator", "Lines Per Page": "Lines Per Page", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping", "Transaction type mapping is not set": "Transaction type mapping is not set", "Transaction time format is not set": "Transaction time format is not set", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "No se pueden importar transacciones no válidas", "Unable to parse import file": "No se puede analizar el archivo de importación", "Batch Replace Selected Expense Categories": "Reemplazar por lotes categorías de gastos seleccionadas", diff --git a/src/locales/ja.json b/src/locales/ja.json index 2c1a54e7..1fedecf0 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1668,6 +1668,7 @@ "Time Format": "時刻形式", "Transaction Type Mapping": "取引タイプのマッピング", "Timezone Format": "タイムゾーン形式", + "Amount Format": "Amount Format", "Geographic Location Separator": "地理座標の区切り", "Transaction Tags Separator": "取引タグの区切り", "Lines Per Page": "ページあたりの行数", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "取引時間、取引タイプ、または金額の列マッピングがありません", "Transaction type mapping is not set": "取引タイプのマッピングが設定されていません", "Transaction time format is not set": "取引時間の形式が設定されていません", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "無効な取引をインポートできません", "Unable to parse import file": "インポートファイルを解析できません", "Batch Replace Selected Expense Categories": "バッチは選択した支出カテゴリを置き換えます", diff --git a/src/locales/ru.json b/src/locales/ru.json index b6dfd375..6d0769be 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1668,6 +1668,7 @@ "Time Format": "Time Format", "Transaction Type Mapping": "Transaction Type Mapping", "Timezone Format": "Timezone Format", + "Amount Format": "Amount Format", "Geographic Location Separator": "Geographic Location Separator", "Transaction Tags Separator": "Transaction Tags Separator", "Lines Per Page": "Lines Per Page", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping", "Transaction type mapping is not set": "Transaction type mapping is not set", "Transaction time format is not set": "Transaction time format is not set", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "Невозможно импортировать недействительные транзакции", "Unable to parse import file": "Не удалось обработать файл импорта", "Batch Replace Selected Expense Categories": "Пакетная замена выбранных категорий расходов", diff --git a/src/locales/vi.json b/src/locales/vi.json index 88064f63..4fb4ca7e 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1668,6 +1668,7 @@ "Time Format": "Time Format", "Transaction Type Mapping": "Transaction Type Mapping", "Timezone Format": "Timezone Format", + "Amount Format": "Amount Format", "Geographic Location Separator": "Geographic Location Separator", "Transaction Tags Separator": "Transaction Tags Separator", "Lines Per Page": "Lines Per Page", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping", "Transaction type mapping is not set": "Transaction type mapping is not set", "Transaction time format is not set": "Transaction time format is not set", + "Transaction amount format is not set": "Transaction amount format is not set", "Cannot import invalid transactions": "Không thể nhập giao dịch không hợp lệ", "Unable to parse import file": "Không thể phân tích tệp nhập", "Batch Replace Selected Expense Categories": "Thay thế hàng loạt các danh mục chi phí đã chọn", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index f88d7dea..41787d3e 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1666,6 +1666,7 @@ "Please select a file to import": "请选择要导入的文件", "Include Header Line": "包含标题行", "Time Format": "时间格式", + "Amount Format": "金额格式", "Transaction Type Mapping": "交易类型映射", "Timezone Format": "时区格式", "Geographic Location Separator": "地理位置分隔符", @@ -1675,6 +1676,7 @@ "Missing transaction time, transaction type, or amount column mapping": "缺少交易时间、交易类型或金额列映射", "Transaction type mapping is not set": "交易类型映射没有设置", "Transaction time format is not set": "交易时间格式没有设置", + "Transaction amount format is not set": "交易金额格式没有设置", "Cannot import invalid transactions": "不能导入无效的交易", "Unable to parse import file": "无法解析导入的文件", "Batch Replace Selected Expense Categories": "批量替换选中的支出分类", diff --git a/src/stores/transaction.ts b/src/stores/transaction.ts index ec03ba30..6a9e0be3 100644 --- a/src/stores/transaction.ts +++ b/src/stores/transaction.ts @@ -1081,9 +1081,9 @@ export const useTransactionsStore = defineStore('transactions', () => { }); } - function parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record, transactionTypeMapping?: Record, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, geoSeparator?: string, tagSeparator?: string }): Promise { + function parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record, transactionTypeMapping?: Record, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, tagSeparator?: string }): Promise { return new Promise((resolve, reject) => { - services.parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }).then(response => { + services.parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }).then(response => { const data = response.data; if (!data || !data.success || !data.result) { diff --git a/src/views/desktop/transactions/import/ImportDialog.vue b/src/views/desktop/transactions/import/ImportDialog.vue index 1df8711e..801b990e 100644 --- a/src/views/desktop/transactions/import/ImportDialog.vue +++ b/src/views/desktop/transactions/import/ImportDialog.vue @@ -377,6 +377,32 @@ + + {{ tt('Amount Format') }} + ({{ KnownAmountFormat.valueOf(parsedFileAmountFormat || parsedFileAutoDetectedAmountFormat || '')?.format || tt('Unknown') }}) + + + + + {{ tt('Auto detect') }} + ({{ KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')?.format }}) + ({{ tt('Unknown') }}) + + + + + {{ amountFormat.format }} + + + + + {{ tt('Geographic Location Separator') }} @@ -812,6 +838,7 @@ import { useOverviewStore } from '@/stores/overview.ts'; import { useStatisticsStore } from '@/stores/statistics.ts'; import type { NameValue, TypeAndDisplayName } from '@/core/base.ts'; +import { KnownAmountFormat } from '@/core/numeral.ts'; import { KnownDateTimeFormat } from '@/core/datetime.ts'; import { KnownDateTimezoneFormat } from '@/core/timezone.ts'; import { CategoryType } from '@/core/category.ts'; @@ -930,6 +957,7 @@ const parsedFileDataColumnMapping = ref>({}); const parsedFileTransactionTypeMapping = ref>({}); const parsedFileTimeFormat = ref(''); const parsedFileTimezoneFormat = ref(''); +const parsedFileAmountFormat = ref(''); const parsedFileGeoLocationSeparator = ref(' '); const parsedFileTagSeparator = ref(';'); const importTransactions = ref(undefined); @@ -1288,6 +1316,37 @@ const parsedFileAutoDetectedTimezoneFormat = computed(() => return detectedFormats[0].value; }); +const parsedFileAutoDetectedAmountFormat = computed(() => { + if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.Amount.type])) { + return undefined; + } + + const allAmounts: string[] = []; + const amountColumnIndex = parsedFileDataColumnMapping.value[ImportTransactionColumnType.Amount.type]; + + const startIndex = parsedFileIncludeHeader.value ? 1 : 0; + + for (let i = startIndex; i < parsedFileData.value.length; i++) { + if (parsedFileData.value[i].length <= amountColumnIndex) { + continue; + } + + const amount = parsedFileData.value[i][amountColumnIndex]; + + if (amount) { + allAmounts.push(amount); + } + } + + const detectedFormats = KnownAmountFormat.detectMulti(allAmounts); + + if (!detectedFormats || !detectedFormats.length) { + return undefined; + } + + return detectedFormats[0].type; +}); + const importTransactionsTableHeight = computed(() => { if (countPerPage.value <= 10 || !importTransactions.value || importTransactions.value.length <= 10) { return undefined; @@ -1853,6 +1912,7 @@ function open(): Promise { parsedFileTransactionTypeMapping.value = {}; parsedFileTimeFormat.value = ''; parsedFileTimezoneFormat.value = ''; + parsedFileAmountFormat.value = ''; parsedFileGeoLocationSeparator.value = ' '; parsedFileTagSeparator.value = ';'; importTransactions.value = undefined; @@ -1995,6 +2055,9 @@ function parseData(): void { let hasHeaderLine: boolean | undefined = undefined; let timeFormat: string | undefined = undefined; let timezoneFormat: string | undefined = undefined; + let amountFormat: string | undefined = undefined; + let amountDecimalSeparator: string | undefined = undefined; + let amountDigitGroupingSymbol: string | undefined = undefined; let geoLocationSeparator: string | undefined = undefined; let tagSeparator: string | undefined = undefined; @@ -2004,6 +2067,7 @@ function parseData(): void { hasHeaderLine = parsedFileIncludeHeader.value; timeFormat = parsedFileTimeFormat.value; timezoneFormat = parsedFileTimezoneFormat.value; + amountFormat = parsedFileAmountFormat.value; geoLocationSeparator = parsedFileGeoLocationSeparator.value; tagSeparator = parsedFileTagSeparator.value; @@ -2028,10 +2092,28 @@ function parseData(): void { timezoneFormat = parsedFileAutoDetectedTimezoneFormat.value; } + if (!parsedFileAmountFormat.value) { + amountFormat = parsedFileAutoDetectedAmountFormat.value; + } + + if (amountFormat) { + const knownAmountFormat = KnownAmountFormat.valueOf(amountFormat); + + if (knownAmountFormat) { + amountDecimalSeparator = knownAmountFormat.decimalSeparator.symbol; + amountDigitGroupingSymbol = knownAmountFormat.digitGroupingSymbol?.symbol; + } + } + if (!timeFormat) { snackbar.value?.showError('Transaction time format is not set'); return; } + + if (!amountDecimalSeparator) { + snackbar.value?.showError('Transaction amount format is not set'); + return; + } } submitting.value = true; @@ -2045,6 +2127,8 @@ function parseData(): void { hasHeaderLine: hasHeaderLine, timeFormat: timeFormat, timezoneFormat: timezoneFormat, + amountDecimalSeparator: amountDecimalSeparator, + amountDigitGroupingSymbol: amountDigitGroupingSymbol, geoSeparator: geoLocationSeparator, tagSeparator: tagSeparator }).then(response => {