From 7127c5539a3c6ddc98e6cd57498aa5305410c60d Mon Sep 17 00:00:00 2001 From: MaysWind Date: Wed, 18 Jun 2025 00:51:19 +0800 Subject: [PATCH] import transactions from camt.053 file --- pkg/converters/camt/camt_data.go | 67 ++ pkg/converters/camt/camt_data_reader.go | 42 + .../camt_statement_transaction_data_table.go | 318 ++++++++ .../camt_transaction_data_file_importer.go | 48 ++ ...amt_transaction_data_file_importer_test.go | 765 ++++++++++++++++++ .../data_table_transaction_data_importer.go | 3 +- .../datatable/transaction_data_table.go | 3 + pkg/converters/transaction_data_converters.go | 3 + pkg/errs/converter.go | 1 + src/consts/file.ts | 5 + src/locales/de.json | 2 + src/locales/en.json | 2 + src/locales/es.json | 2 + src/locales/it.json | 2 + src/locales/ja.json | 2 + src/locales/ru.json | 2 + src/locales/uk.json | 2 + src/locales/vi.json | 2 + src/locales/zh_Hans.json | 2 + src/locales/zh_Hant.json | 2 + 20 files changed, 1274 insertions(+), 1 deletion(-) create mode 100644 pkg/converters/camt/camt_data.go create mode 100644 pkg/converters/camt/camt_data_reader.go create mode 100644 pkg/converters/camt/camt_statement_transaction_data_table.go create mode 100644 pkg/converters/camt/camt_transaction_data_file_importer.go create mode 100644 pkg/converters/camt/camt_transaction_data_file_importer_test.go diff --git a/pkg/converters/camt/camt_data.go b/pkg/converters/camt/camt_data.go new file mode 100644 index 00000000..805d2a73 --- /dev/null +++ b/pkg/converters/camt/camt_data.go @@ -0,0 +1,67 @@ +package camt + +import "encoding/xml" + +type camtCreditDebitIndicator string + +const ( + CAMT_INDICATOR_CREDIT camtCreditDebitIndicator = "CRDT" + CAMT_INDICATOR_DEBIT camtCreditDebitIndicator = "DBIT" +) + +type camt053File struct { + XMLName xml.Name `xml:"Document"` + BankToCustomerStatement *camtBankToCustomerStatement `xml:"BkToCstmrStmt"` +} + +type camtBankToCustomerStatement struct { + Statements []*camtStatement `xml:"Stmt"` +} + +type camtStatement struct { + Account *camtAccount `xml:"Acct"` + Entries []*camtEntry `xml:"Ntry"` +} + +type camtAccount struct { + IBAN string `xml:"Id>IBAN"` + OtherIdentification string `xml:"Id>Othr>Id"` + Currency string `xml:"Ccy"` +} + +type camtEntry struct { + Amount *camtAmount `xml:"Amt"` + CreditDebitIndicator camtCreditDebitIndicator `xml:"CdtDbtInd"` + BookingDate *camtDate `xml:"BookgDt"` + EntryDetails *camtEntryDetails `xml:"NtryDtls"` + AdditionalEntryInformation string `xml:"AddtlNtryInf"` +} + +type camtAmount struct { + Value string `xml:",chardata"` + Currency string `xml:"Ccy,attr"` +} + +type camtDate struct { + Date string `xml:"Dt"` + DateTime string `xml:"DtTm"` +} + +type camtEntryDetails struct { + TransactionDetails []*camtTransactionDetails `xml:"TxDtls"` +} + +type camtTransactionDetails struct { + AmountDetails *camtAmountDetails `xml:"AmtDtls"` + RemittanceInformation *camtRemittanceInformation `xml:"RmtInf"` + AdditionalTransactionInformation string `xml:"AddtlTxInf"` +} + +type camtAmountDetails struct { + InstructedAmount *camtAmount `xml:"InstdAmt>Amt"` + TransactionAmount *camtAmount `xml:"TxAmt>Amt"` +} + +type camtRemittanceInformation struct { + Unstructured []string `xml:"Ustrd"` +} diff --git a/pkg/converters/camt/camt_data_reader.go b/pkg/converters/camt/camt_data_reader.go new file mode 100644 index 00000000..1a105afb --- /dev/null +++ b/pkg/converters/camt/camt_data_reader.go @@ -0,0 +1,42 @@ +package camt + +import ( + "bytes" + "encoding/xml" + + "golang.org/x/net/html/charset" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" +) + +// camt053FileReader defines the structure of camt.053 file reader +type camt053FileReader struct { + xmlDecoder *xml.Decoder +} + +// read returns the imported camt.053 data +func (r *camt053FileReader) read(ctx core.Context) (*camt053File, error) { + file := &camt053File{} + + err := r.xmlDecoder.Decode(&file) + + if err != nil { + return nil, err + } + + return file, nil +} + +func createNewCamt053FileReader(data []byte) (*camt053FileReader, error) { + if len(data) > 5 && data[0] == 0x3C && data[1] == 0x3F && data[2] == 0x78 && data[3] == 0x6D && data[4] == 0x6C { // = len(allStatements) { + return false + } + + currentStatement := allStatements[t.currentStatementIndex] + + if t.currentEntryIndex+1 < len(currentStatement.Entries) { + return true + } else if t.currentEntryIndex < len(currentStatement.Entries) { + currencyEntry := currentStatement.Entries[t.currentEntryIndex] + + if currencyEntry.EntryDetails != nil { + if t.currentTransactionDetailsIndex+1 < len(currencyEntry.EntryDetails.TransactionDetails) { + return true + } + } else { + if t.currentTransactionDetailsIndex < 0 { + return true + } + } + } + + for i := t.currentStatementIndex + 1; i < len(allStatements); i++ { + statement := allStatements[i] + + if len(statement.Entries) < 1 { + continue + } + + return true + } + + return false +} + +// Next returns the next imported data row +func (t *camtStatementTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) { + allStatements := t.dataTable.allStatements + + for i := t.currentStatementIndex; i < len(allStatements); i++ { + foundNextRow := false + statement := allStatements[i] + + for j := t.currentEntryIndex; j < len(statement.Entries); j++ { + if statement.Entries[j].EntryDetails != nil { + if t.currentTransactionDetailsIndex+1 < len(statement.Entries[j].EntryDetails.TransactionDetails) { + t.currentTransactionDetailsIndex++ + foundNextRow = true + break + } + } else { + if t.currentTransactionDetailsIndex < 0 { + t.currentTransactionDetailsIndex++ + foundNextRow = true + break + } + } + + t.currentEntryIndex++ + t.currentTransactionDetailsIndex = -1 + } + + if foundNextRow { + break + } + + t.currentStatementIndex++ + t.currentEntryIndex = 0 + t.currentTransactionDetailsIndex = -1 + } + + if t.currentStatementIndex >= len(allStatements) { + return nil, nil + } + + currentStatement := allStatements[t.currentStatementIndex] + + if t.currentEntryIndex >= len(currentStatement.Entries) { + return nil, nil + } + + account := currentStatement.Account + entry := currentStatement.Entries[t.currentEntryIndex] + var transactionDetails *camtTransactionDetails + + if entry.EntryDetails != nil { + if t.currentTransactionDetailsIndex >= len(entry.EntryDetails.TransactionDetails) { + return nil, nil + } else { + transactionDetails = entry.EntryDetails.TransactionDetails[t.currentTransactionDetailsIndex] + } + } else { + if t.currentTransactionDetailsIndex >= 1 { + return nil, nil + } + } + + rowItems, err := t.parseTransaction(ctx, user, account, entry, transactionDetails) + + if err != nil { + log.Errorf(ctx, "[camt_statement_transaction_data_table.Next] cannot parsing transaction in entry#%d-transaction_detail#%d (statement#%d), because %s", t.currentEntryIndex, t.currentTransactionDetailsIndex, t.currentStatementIndex, err.Error()) + return nil, err + } + + return &camtStatementTransactionDataRow{ + dataTable: t.dataTable, + account: account, + entry: entry, + transactionDetails: transactionDetails, + finalItems: rowItems, + }, nil +} + +func (t *camtStatementTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, account *camtAccount, entry *camtEntry, transactionDetails *camtTransactionDetails) (map[datatable.TransactionDataTableColumn]string, error) { + data := make(map[datatable.TransactionDataTableColumn]string, len(camtTransactionSupportedColumns)) + + if account == nil { + return nil, errs.ErrMissingAccountData + } + + if entry.BookingDate != nil && entry.BookingDate.DateTime != "" { + if strings.Index(entry.BookingDate.DateTime, "T") <= 0 { + return nil, errs.ErrTransactionTimeInvalid + } + + dateTime, err := utils.ParseFromLongDateTimeWithTimezone(strings.ReplaceAll(entry.BookingDate.DateTime, "T", " ")) + + if err != nil { + return nil, errs.ErrTransactionTimeInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location()) + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location()) + } else if entry.BookingDate != nil && entry.BookingDate.Date != "" { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = fmt.Sprintf("%s 00:00:00", entry.BookingDate.Date) + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = datatable.TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE + } else { + return nil, errs.ErrMissingTransactionTime + } + + if account.IBAN != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.IBAN + } else if account.OtherIdentification != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = account.OtherIdentification + } + + if transactionDetails != nil && transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Currency != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = transactionDetails.AmountDetails.TransactionAmount.Currency + } else if entry.Amount != nil && entry.Amount.Currency != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = entry.Amount.Currency + } else if account.Currency != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = account.Currency + } else { + return nil, errs.ErrAccountCurrencyInvalid + } + + amountValue := "" + + if entry.EntryDetails != nil && len(entry.EntryDetails.TransactionDetails) > 1 && transactionDetails != nil { // when there are multiple transaction details in one entry, only use the amount in the transaction details + if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.InstructedAmount != nil && transactionDetails.AmountDetails.InstructedAmount.Value != "" { + amountValue = transactionDetails.AmountDetails.InstructedAmount.Value + } else if transactionDetails.AmountDetails != nil && transactionDetails.AmountDetails.TransactionAmount != nil && transactionDetails.AmountDetails.TransactionAmount.Value != "" { + amountValue = transactionDetails.AmountDetails.TransactionAmount.Value + } else { + return nil, errs.ErrAmountInvalid + } + } else if entry.Amount != nil && entry.Amount.Value != "" { + amountValue = entry.Amount.Value + } + + if amountValue == "" { + return nil, errs.ErrAmountInvalid + } + + amount, err := utils.ParseAmount(amountValue) + + if err != nil { + log.Errorf(ctx, "[camt_statement_transaction_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", amountValue, err.Error()) + return nil, errs.ErrAmountInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) + + if entry.CreditDebitIndicator == CAMT_INDICATOR_CREDIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)) + } else if entry.CreditDebitIndicator == CAMT_INDICATOR_DEBIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)) + } else { + return nil, errs.ErrTransactionTypeInvalid + } + + if transactionDetails != nil && transactionDetails.AdditionalTransactionInformation != "" { + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transactionDetails.AdditionalTransactionInformation + } else if transactionDetails != nil && transactionDetails.RemittanceInformation != nil && len(transactionDetails.RemittanceInformation.Unstructured) > 0 { + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = strings.Join(transactionDetails.RemittanceInformation.Unstructured, "\n") + } else if entry.AdditionalEntryInformation != "" { + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = entry.AdditionalEntryInformation + } else { + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = "" + } + + return data, nil +} + +func createNewCamtStatementTransactionDataTable(file *camt053File) (*camtStatementTransactionDataTable, error) { + if file == nil || file.BankToCustomerStatement == nil || len(file.BankToCustomerStatement.Statements) == 0 { + return nil, errs.ErrNotFoundTransactionDataInFile + } + + return &camtStatementTransactionDataTable{ + allStatements: file.BankToCustomerStatement.Statements, + }, nil +} diff --git a/pkg/converters/camt/camt_transaction_data_file_importer.go b/pkg/converters/camt/camt_transaction_data_file_importer.go new file mode 100644 index 00000000..ec4c1f27 --- /dev/null +++ b/pkg/converters/camt/camt_transaction_data_file_importer.go @@ -0,0 +1,48 @@ +package camt + +import ( + "github.com/mayswind/ezbookkeeping/pkg/converters/converter" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +var camtTransactionTypeNameMapping = map[models.TransactionType]string{ + models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)), + models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)), + models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)), +} + +// camt053TransactionDataImporter defines the structure of camt.053 file importer for transaction data +type camt053TransactionDataImporter struct { +} + +// Initialize a camt.053 transaction data importer singleton instance +var ( + Camt053TransactionDataImporter = &camt053TransactionDataImporter{} +) + +// ParseImportedData returns the imported data by parsing the camt.053 file transaction data +func (c *camt053TransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) { + camt053DataReader, err := createNewCamt053FileReader(data) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + camt053Data, err := camt053DataReader.read(ctx) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + transactionDataTable, err := createNewCamtStatementTransactionDataTable(camt053Data) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(camtTransactionTypeNameMapping) + + return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) +} diff --git a/pkg/converters/camt/camt_transaction_data_file_importer_test.go b/pkg/converters/camt/camt_transaction_data_file_importer_test.go new file mode 100644 index 00000000..4ac38c77 --- /dev/null +++ b/pkg/converters/camt/camt_transaction_data_file_importer_test.go @@ -0,0 +1,765 @@ +package camt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +func TestCamt053TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T01:23:45+08:00 + + CRDT + 123.45 + + + + 2024-09-01T12:34:56+08:00 + + DBIT + 0.12 + + + + + + + 456 + + + USD + + + + 2024-09-01T23:59:59+08:00 + + CRDT + 1.23 + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 3, len(allNewTransactions)) + assert.Equal(t, 2, len(allNewAccounts)) + assert.Equal(t, 1, len(allNewSubExpenseCategories)) + assert.Equal(t, 1, len(allNewSubIncomeCategories)) + assert.Equal(t, 0, len(allNewSubTransferCategories)) + assert.Equal(t, 0, len(allNewTags)) + + assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type) + assert.Equal(t, int64(1725125025), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + assert.Equal(t, "123", allNewTransactions[0].OriginalSourceAccountName) + assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type) + assert.Equal(t, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime)) + assert.Equal(t, int64(12), allNewTransactions[1].Amount) + assert.Equal(t, "123", allNewTransactions[1].OriginalSourceAccountName) + assert.Equal(t, "CNY", allNewTransactions[1].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[2].Type) + assert.Equal(t, int64(1725206399), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime)) + assert.Equal(t, int64(123), allNewTransactions[2].Amount) + assert.Equal(t, "456", allNewTransactions[2].OriginalSourceAccountName) + assert.Equal(t, "USD", allNewTransactions[2].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[2].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) + assert.Equal(t, "123", allNewAccounts[0].Name) + assert.Equal(t, "CNY", allNewAccounts[0].Currency) + + assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid) + assert.Equal(t, "456", allNewAccounts[1].Name) + assert.Equal(t, "USD", allNewAccounts[1].Currency) + + assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid) + assert.Equal(t, "", allNewSubExpenseCategories[0].Name) + + assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid) + assert.Equal(t, "", allNewSubIncomeCategories[0].Name) +} + +func TestCamt053TransactionDataFileParseImportedData_ParseValidTransactionTime(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + +
2024-09-01
+
+ CRDT + 123.45 +
+ + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + 2024-09-02T03:04:05Z + + CRDT + 123.45 + +
+
+
`), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 3, len(allNewTransactions)) + + assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + assert.Equal(t, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime)) + assert.Equal(t, int64(1725246245), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime)) +} + +func TestCamt053TransactionDataFileParseImportedData_ParseInvalidTransactionTime(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024T1 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01 12:34:56 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + +
2024/09/01
+
+ CRDT + 123.45 +
+
+
+
`), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) +} + +func TestCamt053TransactionDataFileParseImportedData_ParseTransactionValidAmountAndCurrency(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + + 100.23 + + + + + + + 23.22 + + + + + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 2, len(allNewTransactions)) + assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) + assert.Equal(t, int64(2322), allNewTransactions[0].Amount) + assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency) + assert.Equal(t, int64(10023), allNewTransactions[1].Amount) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + + 99.99 + + + 100.23 + + + + + + + 23.46 + + + 23.22 + + + + + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 2, len(allNewTransactions)) + assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) + assert.Equal(t, int64(2346), allNewTransactions[0].Amount) + assert.Equal(t, "USD", allNewTransactions[1].OriginalSourceAccountCurrency) + assert.Equal(t, int64(9999), allNewTransactions[1].Amount) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + + 123.45 + + + + + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "CNY", allNewTransactions[0].OriginalSourceAccountCurrency) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) +} + +func TestCamt053TransactionDataFileParseImportedData_ParseTransactionInvalidAmountAndCurrency(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123 45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + + + + + + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + + + + + + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) +} + +func TestCamt053TransactionDataFileParseImportedData_ParseDescription(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + Test Entry + + + Test Transaction + + Test Line 1 + Test Line 2 + + + + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test Transaction", allNewTransactions[0].Comment) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + Test Entry + + + + Test Line 1 + Test Line 2 + + + + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test Line 1\nTest Line 2", allNewTransactions[0].Comment) + + allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + Test Entry + + + + `), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Test Entry", allNewTransactions[0].Comment) +} + +func TestCamt053TransactionDataFileParseImportedData_MissingAccountNode(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingAccountData.Message) +} + +func TestCamt053TransactionDataFileParseImportedData_MissingTransactionRequiredNode(t *testing.T) { + converter := Camt053TransactionDataImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrMissingTransactionTime.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + CNY + + + + 2024-09-01T12:34:56+08:00 + + CRDT + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + ` + + + + + + 123 + + + + + 2024-09-01T12:34:56+08:00 + + CRDT + 123.45 + + + + `), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) +} diff --git a/pkg/converters/converter/data_table_transaction_data_importer.go b/pkg/converters/converter/data_table_transaction_data_importer.go index 0f8bfd3c..9e55fd7b 100644 --- a/pkg/converters/converter/data_table_transaction_data_importer.go +++ b/pkg/converters/converter/data_table_transaction_data_importer.go @@ -96,7 +96,8 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u timezoneOffset := defaultTimezoneOffset - if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) { + if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) && + dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) != datatable.TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE { transactionTimezone, err := utils.ParseFromTimezoneOffset(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE)) if err != nil { diff --git a/pkg/converters/datatable/transaction_data_table.go b/pkg/converters/datatable/transaction_data_table.go index d79adf9a..1ae312f2 100644 --- a/pkg/converters/datatable/transaction_data_table.go +++ b/pkg/converters/datatable/transaction_data_table.go @@ -73,3 +73,6 @@ const ( TRANSACTION_DATA_TABLE_TAGS TransactionDataTableColumn = 13 TRANSACTION_DATA_TABLE_DESCRIPTION TransactionDataTableColumn = 14 ) + +// TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE represents the constant for timezone not available +const TRANSACTION_DATA_TABLE_TIMEZONE_NOT_AVAILABLE = "TIMEZONE_NOT_AVAILABLE" diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index 6fe9a39b..c8233c7b 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -3,6 +3,7 @@ package converters import ( "github.com/mayswind/ezbookkeeping/pkg/converters/alipay" "github.com/mayswind/ezbookkeeping/pkg/converters/beancount" + "github.com/mayswind/ezbookkeeping/pkg/converters/camt" "github.com/mayswind/ezbookkeeping/pkg/converters/converter" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/default" @@ -47,6 +48,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor return qif.QifDayMonthYearTransactionDataImporter, nil } else if fileType == "iif" { return iif.IifTransactionDataFileImporter, nil + } else if fileType == "camt053" { + return camt.Camt053TransactionDataImporter, nil } else if fileType == "gnucash" { return gnucash.GnuCashTransactionDataImporter, nil } else if fileType == "firefly_iii_csv" { diff --git a/pkg/errs/converter.go b/pkg/errs/converter.go index 426ad966..585a963b 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -28,4 +28,5 @@ var ( ErrInvalidBeancountFile = NewNormalError(NormalSubcategoryConverter, 21, http.StatusBadRequest, "invalid beancount file") ErrBeancountFileNotSupportInclude = NewNormalError(NormalSubcategoryConverter, 22, http.StatusBadRequest, "not support include directive for beancount file") ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression") + ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file") ) diff --git a/src/consts/file.ts b/src/consts/file.ts index 9a0a5994..7542f623 100644 --- a/src/consts/file.ts +++ b/src/consts/file.ts @@ -162,6 +162,11 @@ export const SUPPORTED_IMPORT_FILE_TYPES: ImportFileType[] = [ name: 'Intuit Interchange Format (IIF) File', extensions: '.iif' }, + { + type: 'camt053', + name: 'Camt.053 Bank to Customer Statement File', + extensions: '.xml' + }, { type: 'gnucash', name: 'GnuCash XML Database File', diff --git a/src/locales/de.json b/src/locales/de.json index a6fd133a..a20022b1 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Monat-Tag-Jahr-Format", "Day-month-year format": "Tag-Monat-Jahr-Format", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF)-Datei", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) File", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) Data", "GnuCash XML Database File": "GnuCash XML-Datenbankdatei", diff --git a/src/locales/en.json b/src/locales/en.json index cfce970c..717e2873 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Month-day-year format", "Day-month-year format": "Day-month-year format", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) File", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) File", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) Data", "GnuCash XML Database File": "GnuCash XML Database File", diff --git a/src/locales/es.json b/src/locales/es.json index 8b48abea..de8a9a6d 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Formato mes-día-año", "Day-month-year format": "Formato día-mes-año", "Intuit Interchange Format (IIF) File": "Archivo de formato de intercambio Intuit (IIF)", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) File", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) Data", "GnuCash XML Database File": "Archivo de base de datos XML GnuCash", diff --git a/src/locales/it.json b/src/locales/it.json index 13045206..fa5b329b 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "File Beancount non valido", "not support include directive for beancount file": "Direttiva \"include\" non supportata per il file Beancount", "invalid amount expression": "Espressione dell'importo non valida", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Formato mese-giorno-anno", "Day-month-year format": "Formato giorno-mese-anno", "Intuit Interchange Format (IIF) File": "File Intuit Interchange Format (IIF)", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "File valori separati da delimitatore (DSV)", "Delimiter-separated Values (DSV) Data": "Dati valori separati da delimitatore (DSV)", "GnuCash XML Database File": "File database XML GnuCash", diff --git a/src/locales/ja.json b/src/locales/ja.json index 7a1985ff..54e57bb5 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "月-日-年 形式", "Day-month-year format": "日-月-年 形式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) ファイル", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) ファイル", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) データ", "GnuCash XML Database File": "GnuCash XMLデータベースファイル", diff --git a/src/locales/ru.json b/src/locales/ru.json index bc400cc7..34e3fae9 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Формат месяц-день-год", "Day-month-year format": "Формат день-месяц-год", "Intuit Interchange Format (IIF) File": "Файл Intuit Interchange Format (IIF)", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) File", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) Data", "GnuCash XML Database File": "Файл базы данных GnuCash XML", diff --git a/src/locales/uk.json b/src/locales/uk.json index 111e5436..cf7114b0 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Недійсний файл Beancount", "not support include directive for beancount file": "Не підтримується директива \"include\" у файлі Beancount", "invalid amount expression": "Недійсний вираз суми", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Формат місяць-день-рік", "Day-month-year format": "Формат день-місяць-рік", "Intuit Interchange Format (IIF) File": "Файл Intuit Interchange Format (IIF)", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Файл із розділювачами значень (DSV)", "Delimiter-separated Values (DSV) Data": "Дані з розділювачами значень (DSV)", "GnuCash XML Database File": "Файл бази даних GnuCash XML", diff --git a/src/locales/vi.json b/src/locales/vi.json index 669b4835..93edc2e0 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "invalid xml file": "Invalid XML file", "user custom exchange rate data not found": "User custom exchange rate data is not found", "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", @@ -1692,6 +1693,7 @@ "Month-day-year format": "Định dạng tháng-ngày-năm", "Day-month-year format": "Định dạng ngày-tháng-năm", "Intuit Interchange Format (IIF) File": "Tệp Intuit Interchange Format (IIF)", + "Camt.053 Bank to Customer Statement File": "Camt.053 Bank to Customer Statement File", "Delimiter-separated Values (DSV) File": "Delimiter-separated Values (DSV) File", "Delimiter-separated Values (DSV) Data": "Delimiter-separated Values (DSV) Data", "GnuCash XML Database File": "Tệp cơ sở dữ liệu XML GnuCash", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 7ce067a4..27eb7138 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "无效的 Beancount 文件", "not support include directive for beancount file": "不支持 Beancount 文件的 \"include\" 指令", "invalid amount expression": "金额表达式无效", + "invalid xml file": "无效的 XML 文件", "user custom exchange rate data not found": "用户自定义汇率数据不存在", "cannot update exchange rate data for base currency": "不能更新默认货币的汇率数据", "cannot delete exchange rate data for base currency": "不能删除默认货币的汇率数据", @@ -1692,6 +1693,7 @@ "Month-day-year format": "月-日-年 格式", "Day-month-year format": "日-月-年 格式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 文件", + "Camt.053 Bank to Customer Statement File": "Camt.053 银行对账单文件", "Delimiter-separated Values (DSV) File": "分隔符分隔值 (DSV) 文件", "Delimiter-separated Values (DSV) Data": "分隔符分隔值 (DSV) 数据", "GnuCash XML Database File": "GnuCash XML 数据库文件", diff --git a/src/locales/zh_Hant.json b/src/locales/zh_Hant.json index 994563a3..9727f255 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -1181,6 +1181,7 @@ "invalid beancount file": "無效的 Beancount 檔案", "not support include directive for beancount file": "不支援 Beancount 檔案的 \"include\" 指令", "invalid amount expression": "金額表達式無效", + "invalid xml file": "無效的 XML 檔案", "user custom exchange rate data not found": "使用者自訂匯率資料不存在", "cannot update exchange rate data for base currency": "不能更新基準貨幣的匯率資料", "cannot delete exchange rate data for base currency": "不能刪除基準貨幣的匯率資料", @@ -1692,6 +1693,7 @@ "Month-day-year format": "月-日-年 格式", "Day-month-year format": "日-月-年 格式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 檔案", + "Camt.053 Bank to Customer Statement File": "Camt.053 銀行對帳單檔案", "Delimiter-separated Values (DSV) File": "分隔符分隔值 (DSV) 檔案", "Delimiter-separated Values (DSV) Data": "分隔符分隔值 (DSV) 資料", "GnuCash XML Database File": "GnuCash XML 資料庫檔案",