From 4a6f7eb43cf1422e00cc560e6bf1250ce519a225 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Fri, 20 Jun 2025 00:57:07 +0800 Subject: [PATCH] import transactions from mt940 file --- README.md | 2 +- pkg/converters/mt/mt_data.go | 43 +++ pkg/converters/mt/mt_data_reader.go | 290 +++++++++++++++ pkg/converters/mt/mt_data_reader_test.go | 341 ++++++++++++++++++ .../mt/mt_transaction_data_file_importer.go | 42 +++ .../mt_transaction_data_file_importer_test.go | 214 +++++++++++ .../mt/mt_transaction_data_table.go | 168 +++++++++ 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, 1128 insertions(+), 1 deletion(-) create mode 100644 pkg/converters/mt/mt_data.go create mode 100644 pkg/converters/mt/mt_data_reader.go create mode 100644 pkg/converters/mt/mt_data_reader_test.go create mode 100644 pkg/converters/mt/mt_transaction_data_file_importer.go create mode 100644 pkg/converters/mt/mt_transaction_data_file_importer_test.go create mode 100644 pkg/converters/mt/mt_transaction_data_table.go diff --git a/README.md b/README.md index 9f9592b0..a65f4d51 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem * Two-factor authentication (2FA) * Login rate limiting * Application lock (PIN code / WebAuthn) -8. Data Export & Import (CSV, OFX, QFX, QIF, IIF, Camt.053, GnuCash, FireFly III, Beancount, etc.) +8. Data Export & Import (CSV, OFX, QFX, QIF, IIF, Camt.053, MT940, GnuCash, FireFly III, Beancount, etc.) ## Screenshots ### Desktop Version diff --git a/pkg/converters/mt/mt_data.go b/pkg/converters/mt/mt_data.go new file mode 100644 index 00000000..42ae69ac --- /dev/null +++ b/pkg/converters/mt/mt_data.go @@ -0,0 +1,43 @@ +package mt + +type mtCreditDebitMark string + +const ( + MT_MARK_CREDIT mtCreditDebitMark = "C" + MT_MARK_DEBIT mtCreditDebitMark = "D" + MT_MARK_REVERSAL_CREDIT mtCreditDebitMark = "RC" + MT_MARK_REVERSAL_DEBIT mtCreditDebitMark = "RD" +) + +// mt940Data defines the structure of mt940 data +type mt940Data struct { + StatementReferenceNumber string + RelatedReference string + AccountId string + SequentialNumber string + OpeningBalance *mtBalance + ClosingBalance *mtBalance + ClosingAvailableBalance *mtBalance + Statements []*mtStatement +} + +// mtStatement defines the structure of mt940 statement +type mtStatement struct { + ValueDate string + EntryDate string + CreditDebitMark mtCreditDebitMark + FundsCode string + Amount string + TransactionTypeIdentificationCode string + ReferenceForAccountOwner string + ReferenceOfAccountServicingInstitution string + AdditionalInformation []string +} + +// mtBalance defines the structure of mt940 balance +type mtBalance struct { + DebitCreditMark mtCreditDebitMark + Date string + Currency string + Amount string +} diff --git a/pkg/converters/mt/mt_data_reader.go b/pkg/converters/mt/mt_data_reader.go new file mode 100644 index 00000000..3d9d94a4 --- /dev/null +++ b/pkg/converters/mt/mt_data_reader.go @@ -0,0 +1,290 @@ +package mt + +import ( + "bufio" + "bytes" + "strings" + + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" +) + +const mtBasicHeaderBlockPrefix = "{1:" +const mtTextBlockStartPrefix = "{4:" +const mtTextBlockEndPrefix = "-}" +const mtTagPrefix = ':' +const mtStatementAdditionalInformationMaxLines = 6 + +const ( + mtTagStatementReferenceNumber = ":20:" + mtTagRelatedReference = ":21:" + mtTagAccountId = ":25:" + mtTagSequentialNumber = ":28C:" + mtTagOpeningBalanceF = ":60F:" + mtTagOpeningBalanceM = ":60M:" + mtTagClosingBalanceF = ":62F:" + mtTagClosingBalanceM = ":62M:" + mtTagClosingAvailableBalance = ":64:" + mtTagStatementLine = ":61:" + mtTagStatementAdditionalInformation = ":86:" +) + +const ( + mtTransactionTypeSwiftTransfer = 'S' + mtTransactionTypeNonSwiftTransfer = 'N' + mtTransactionTypeFirstAdvice = 'F' +) + +// mt940DataReader defines the structure of mt940 data reader +type mt940DataReader struct { + allLines []string +} + +// read returns the imported mt940 data +// Reference: https://www2.swift.com/knowledgecentre/publications/us9m_20230720/2.0?topic=mt940-format-spec.htm +func (r *mt940DataReader) read(ctx core.Context) (*mt940Data, error) { + if len(r.allLines) < 1 { + return nil, errs.ErrNotFoundTransactionDataInFile + } + + data := &mt940Data{} + var currentStatement *mtStatement + var lastTag string + + for i := 0; i < len(r.allLines); i++ { + line := strings.TrimSpace(r.allLines[i]) + + if len(line) < 1 { + continue + } + + if strings.HasPrefix(line, mtBasicHeaderBlockPrefix) && strings.HasSuffix(line, mtTextBlockStartPrefix) { + data = &mt940Data{} + currentStatement = nil + lastTag = "" + continue + } else if strings.HasPrefix(line, mtTextBlockEndPrefix) { + break + } + + if strings.HasPrefix(line, mtTagStatementReferenceNumber) { + data.StatementReferenceNumber = line[len(mtTagStatementReferenceNumber):] + lastTag = mtTagStatementReferenceNumber + } else if strings.HasPrefix(line, mtTagRelatedReference) { + data.RelatedReference = line[len(mtTagRelatedReference):] + lastTag = mtTagRelatedReference + } else if strings.HasPrefix(line, mtTagAccountId) { + data.AccountId = line[len(mtTagAccountId):] + lastTag = mtTagAccountId + } else if strings.HasPrefix(line, mtTagSequentialNumber) { + data.SequentialNumber = line[len(mtTagSequentialNumber):] + lastTag = mtTagSequentialNumber + } else if strings.HasPrefix(line, mtTagOpeningBalanceF) || strings.HasPrefix(line, mtTagOpeningBalanceM) { + balance, err := r.parseBalance(ctx, line[len(mtTagOpeningBalanceF):]) + + if err != nil { + return nil, err + } + + data.OpeningBalance = balance + lastTag = line[:len(mtTagOpeningBalanceF)] + } else if strings.HasPrefix(line, mtTagClosingBalanceF) || strings.HasPrefix(line, mtTagClosingBalanceM) { + balance, err := r.parseBalance(ctx, line[len(mtTagClosingBalanceF):]) + + if err != nil { + return nil, err + } + + data.ClosingBalance = balance + lastTag = line[:len(mtTagClosingBalanceF)] + } else if strings.HasPrefix(line, mtTagClosingAvailableBalance) { + balance, err := r.parseBalance(ctx, line[len(mtTagClosingAvailableBalance):]) + + if err != nil { + return nil, err + } + + data.ClosingAvailableBalance = balance + lastTag = mtTagClosingAvailableBalance + } else if strings.HasPrefix(line, mtTagStatementLine) { + if currentStatement != nil { + data.Statements = append(data.Statements, currentStatement) + } + + statement, err := r.parseStatement(ctx, line[len(mtTagStatementLine):]) + + if err != nil { + return nil, err + } + + currentStatement = statement + lastTag = mtTagStatementLine + } else if strings.HasPrefix(line, mtTagStatementAdditionalInformation) && currentStatement != nil { + currentStatement.AdditionalInformation = make([]string, 1) + currentStatement.AdditionalInformation[0] = line[len(mtTagStatementAdditionalInformation):] + lastTag = mtTagStatementAdditionalInformation + } else if line[0] != mtTagPrefix && lastTag == mtTagStatementLine && currentStatement != nil { + currentStatement.ReferenceForAccountOwner += line + lastTag = "" + } else if line[0] != mtTagPrefix && lastTag == mtTagStatementAdditionalInformation && currentStatement != nil && len(currentStatement.AdditionalInformation) < mtStatementAdditionalInformationMaxLines { + currentStatement.AdditionalInformation = append(currentStatement.AdditionalInformation, line) + lastTag = mtTagStatementAdditionalInformation + } else { + log.Warnf(ctx, "[mt_data_reader.read] unsupported line \"%s\" and skip this line", line) + } + } + + if currentStatement != nil { + data.Statements = append(data.Statements, currentStatement) + } + + return data, nil +} + +func (r *mt940DataReader) parseBalance(ctx core.Context, data string) (*mtBalance, error) { + // 1!a (debit/credit mark) + // 6!n (date) + // 3!a (currency) + // 15d (amount) + if len(data) < 9 { + return nil, errs.ErrInvalidMT940File + } + + if data[0] != MT_MARK_DEBIT[0] && data[0] != MT_MARK_CREDIT[0] { + log.Errorf(ctx, "[mt_data_reader.parseBalance] cannot parse unknown debit/credit mark, current line is %s", data) + return nil, errs.ErrTransactionTypeInvalid + } + + balance := &mtBalance{ + DebitCreditMark: mtCreditDebitMark(data[0:1]), + Date: data[1:7], + Currency: data[7:10], + Amount: data[10:], + } + + return balance, nil +} + +func (r *mt940DataReader) parseStatement(ctx core.Context, data string) (*mtStatement, error) { + // 6!n (value date) + // [4!n] (entry date, optional) + // 2a (debit/credit mark) + // [1!a] (funds code, optional) + // 15d (amount) + // 1!a3!c (transaction type identification code) + // 16x (reference for account owner) + // [//16x] (reference of account servicing institution, optional) + // [34x] (supplementary details, optional) + if len(data) < 6 { + return nil, errs.ErrInvalidMT940File + } + + statement := &mtStatement{ + ValueDate: data[0:6], + } + + currentIndex := 6 + + // parse entry date if available + if len(data) >= currentIndex+4 && '0' <= data[currentIndex] && data[currentIndex] <= '9' { + statement.EntryDate = data[6:10] + currentIndex += 4 + } + + // parse debit/credit indicator + if len(data) >= currentIndex+1 && (data[currentIndex] == MT_MARK_DEBIT[0] || data[currentIndex] == MT_MARK_CREDIT[0]) { + statement.CreditDebitMark = mtCreditDebitMark(data[currentIndex]) + currentIndex++ + } else if len(data) >= currentIndex+2 && (data[currentIndex:currentIndex+2] == string(MT_MARK_REVERSAL_CREDIT) || data[currentIndex:currentIndex+2] == string(MT_MARK_REVERSAL_DEBIT)) { + statement.CreditDebitMark = mtCreditDebitMark(data[currentIndex : currentIndex+2]) + currentIndex += 2 + } else { + log.Errorf(ctx, "[mt_data_reader.parseStatement] cannot parse unknown debit/credit mark, current line is %s", data) + return nil, errs.ErrTransactionTypeInvalid + } + + // parse funds code if available + if len(data) >= currentIndex+1 && ('A' <= data[currentIndex] && data[currentIndex] <= 'Z') { + statement.FundsCode = string(data[currentIndex]) + currentIndex++ + } + + // parse amount + amountValue := "" + for i := currentIndex; i < len(data); i++ { + if len(amountValue) < 15 && ('0' <= data[i] && data[i] <= '9' || data[i] == ',') { + amountValue += string(data[i]) + } else { + currentIndex = i + break + } + } + statement.Amount = amountValue + + if len(statement.Amount) < 1 { + log.Errorf(ctx, "[mt_data_reader.parseStatement] cannot parse amount, current line is %s", data) + return nil, errs.ErrAmountInvalid + } + + // parse transaction type identification code + if len(data) >= currentIndex+4 && (data[currentIndex] == uint8(mtTransactionTypeSwiftTransfer) || data[currentIndex] == uint8(mtTransactionTypeNonSwiftTransfer) || data[currentIndex] == uint8(mtTransactionTypeFirstAdvice)) { + statement.TransactionTypeIdentificationCode = data[currentIndex : currentIndex+4] + currentIndex += 4 + } else { + log.Errorf(ctx, "[mt_data_reader.parseStatement] cannot parse transaction type identification code, current line is %s", data) + return nil, errs.ErrInvalidMT940File + } + + // parse reference for account owner if available + accountOwnerReference := "" + for i := currentIndex; i < len(data); i++ { + if len(accountOwnerReference) < 16 && (data[i] != '/' || (data[i] == '/' && (i >= len(data)-1 || data[i+1] != '/'))) { + accountOwnerReference += string(data[i]) + } else { + currentIndex = i + break + } + } + statement.ReferenceForAccountOwner = accountOwnerReference + + if len(statement.ReferenceForAccountOwner) < 1 { + log.Errorf(ctx, "[mt_data_reader.parseStatement] cannot parse reference for account owner, current line is %s", data) + return nil, errs.ErrInvalidMT940File + } + + // parse reference of account servicing institution if available + if len(data) >= currentIndex+3 && data[currentIndex] == '/' && data[currentIndex+1] == '/' { + accountServicingInstitutionReference := "" + currentIndex += 2 + for i := currentIndex; i < len(data); i++ { + if len(accountServicingInstitutionReference) < 16 { + accountServicingInstitutionReference += string(data[i]) + } else { + currentIndex = i + break + } + } + statement.ReferenceOfAccountServicingInstitution = accountServicingInstitutionReference + } + + return statement, nil +} + +func createNewMT940FileReader(data []byte) *mt940DataReader { + fallback := unicode.UTF8.NewDecoder() + reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback)) + scanner := bufio.NewScanner(reader) + allLines := make([]string, 0) + + for scanner.Scan() { + allLines = append(allLines, scanner.Text()) + } + + return &mt940DataReader{ + allLines: allLines, + } +} diff --git a/pkg/converters/mt/mt_data_reader_test.go b/pkg/converters/mt/mt_data_reader_test.go new file mode 100644 index 00000000..930e8c95 --- /dev/null +++ b/pkg/converters/mt/mt_data_reader_test.go @@ -0,0 +1,341 @@ +package mt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" +) + +func TestMT940DataReaderParse(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + "{1:F01TESTBANK123456789}{2:I940TESTBANK}{4:", + ":20:MT940-2025001", + ":21:RELATEDREFERENCE", + ":25:123456789", + ":28C:123/1", + ":60F:C250601CNY1234,56", + ":61:2506010602DY123,45NTRFTEST//ABC123456", + ":86:First Transaction", + "Additional Info", + ":61:2506020620CY234,56NSTFFOOBAR//DEF789012", + ":86:Second Transaction", + "More Info", + ":62F:C250602CNY2345,67", + ":64:C250602CNY2345,67", + "-}", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, "MT940-2025001", actualData.StatementReferenceNumber) + assert.Equal(t, "RELATEDREFERENCE", actualData.RelatedReference) + assert.Equal(t, "123456789", actualData.AccountId) + assert.Equal(t, "123/1", actualData.SequentialNumber) + + assert.Equal(t, MT_MARK_CREDIT, actualData.OpeningBalance.DebitCreditMark) + assert.Equal(t, "250601", actualData.OpeningBalance.Date) + assert.Equal(t, "CNY", actualData.OpeningBalance.Currency) + assert.Equal(t, "1234,56", actualData.OpeningBalance.Amount) + + assert.Equal(t, 2, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, "0602", actualData.Statements[0].EntryDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "Y", actualData.Statements[0].FundsCode) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", actualData.Statements[0].ReferenceForAccountOwner) + assert.Equal(t, "ABC123456", actualData.Statements[0].ReferenceOfAccountServicingInstitution) + assert.Equal(t, "First Transaction", actualData.Statements[0].AdditionalInformation[0]) + assert.Equal(t, "Additional Info", actualData.Statements[0].AdditionalInformation[1]) + + assert.Equal(t, "250602", actualData.Statements[1].ValueDate) + assert.Equal(t, "0620", actualData.Statements[1].EntryDate) + assert.Equal(t, MT_MARK_CREDIT, actualData.Statements[1].CreditDebitMark) + assert.Equal(t, "Y", actualData.Statements[0].FundsCode) + assert.Equal(t, "234,56", actualData.Statements[1].Amount) + assert.Equal(t, "NSTF", actualData.Statements[1].TransactionTypeIdentificationCode) + assert.Equal(t, "FOOBAR", actualData.Statements[1].ReferenceForAccountOwner) + assert.Equal(t, "DEF789012", actualData.Statements[1].ReferenceOfAccountServicingInstitution) + assert.Equal(t, "Second Transaction", actualData.Statements[1].AdditionalInformation[0]) + assert.Equal(t, "More Info", actualData.Statements[1].AdditionalInformation[1]) + + assert.Equal(t, MT_MARK_CREDIT, actualData.ClosingBalance.DebitCreditMark) + assert.Equal(t, "250602", actualData.ClosingBalance.Date) + assert.Equal(t, "CNY", actualData.ClosingBalance.Currency) + assert.Equal(t, "2345,67", actualData.ClosingBalance.Amount) + + assert.Equal(t, MT_MARK_CREDIT, actualData.ClosingAvailableBalance.DebitCreditMark) + assert.Equal(t, "250602", actualData.ClosingAvailableBalance.Date) + assert.Equal(t, "CNY", actualData.ClosingAvailableBalance.Currency) + assert.Equal(t, "2345,67", actualData.ClosingAvailableBalance.Amount) +} + +func TestMT940DataReaderParse_NoBlockHeaderFooter(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + ":20:MT940-2025001", + ":25:123456789", + ":28C:123/1", + ":60F:C250601CNY1234,56", + ":61:2506010602DY123,45NTRFTEST//ABC123456", + ":86:First Transaction", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, "MT940-2025001", actualData.StatementReferenceNumber) + assert.Equal(t, "123456789", actualData.AccountId) + assert.Equal(t, "123/1", actualData.SequentialNumber) + + assert.Equal(t, MT_MARK_CREDIT, actualData.OpeningBalance.DebitCreditMark) + assert.Equal(t, "250601", actualData.OpeningBalance.Date) + assert.Equal(t, "CNY", actualData.OpeningBalance.Currency) + assert.Equal(t, "1234,56", actualData.OpeningBalance.Amount) + + assert.Equal(t, 1, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, "0602", actualData.Statements[0].EntryDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "Y", actualData.Statements[0].FundsCode) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", actualData.Statements[0].ReferenceForAccountOwner) + assert.Equal(t, "ABC123456", actualData.Statements[0].ReferenceOfAccountServicingInstitution) + assert.Equal(t, "First Transaction", actualData.Statements[0].AdditionalInformation[0]) +} + +func TestMT940DataReaderParse_ReferenceForTheAccountOwnerTwoLine(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + ":61:250601D123,45NTRFABCDEFGHIJKLMNOP", + "QRSTUVWXYZ", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, 1, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", actualData.Statements[0].ReferenceForAccountOwner) +} + +func TestMT940DataReaderParse_AdditionalInformationSixLine(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + ":61:250601D123,45NTRFTEST", + ":86:Additional Info Line 1", + "Additional Info Line 2", + "Additional Info Line 3", + "Additional Info Line 4", + "Additional Info Line 5", + "Additional Info Line 6", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, 1, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", actualData.Statements[0].ReferenceForAccountOwner) + assert.Equal(t, 6, len(actualData.Statements[0].AdditionalInformation)) + assert.Equal(t, "Additional Info Line 1", actualData.Statements[0].AdditionalInformation[0]) + assert.Equal(t, "Additional Info Line 2", actualData.Statements[0].AdditionalInformation[1]) + assert.Equal(t, "Additional Info Line 3", actualData.Statements[0].AdditionalInformation[2]) + assert.Equal(t, "Additional Info Line 4", actualData.Statements[0].AdditionalInformation[3]) + assert.Equal(t, "Additional Info Line 5", actualData.Statements[0].AdditionalInformation[4]) + assert.Equal(t, "Additional Info Line 6", actualData.Statements[0].AdditionalInformation[5]) +} + +func TestMT940DataReaderParse_AdditionalInformationMoreThanSixLine(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + ":61:250601D123,45NTRFTEST", + ":86:Additional Info Line 1", + "Additional Info Line 2", + "Additional Info Line 3", + "Additional Info Line 4", + "Additional Info Line 5", + "Additional Info Line 6", + "Additional Info Line 7", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, 1, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", actualData.Statements[0].ReferenceForAccountOwner) + assert.Equal(t, 6, len(actualData.Statements[0].AdditionalInformation)) + assert.Equal(t, "Additional Info Line 1", actualData.Statements[0].AdditionalInformation[0]) + assert.Equal(t, "Additional Info Line 2", actualData.Statements[0].AdditionalInformation[1]) + assert.Equal(t, "Additional Info Line 3", actualData.Statements[0].AdditionalInformation[2]) + assert.Equal(t, "Additional Info Line 4", actualData.Statements[0].AdditionalInformation[3]) + assert.Equal(t, "Additional Info Line 5", actualData.Statements[0].AdditionalInformation[4]) + assert.Equal(t, "Additional Info Line 6", actualData.Statements[0].AdditionalInformation[5]) +} + +func TestMT940DataReaderParse_DuplicateBlockHeader(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{ + "{1:F01TESTBANK123456789}{2:I940TESTBANK}{4:", + ":20:MT940-2025001", + ":25:123456789", + ":28C:123/1", + ":60F:C250601CNY1234,56", + "{1:F01TESTBANK123456789}{2:I940TESTBANK}{4:", + ":61:2506010602DY123,45NTRFTEST//ABC123456", + ":86:First Transaction", + "-}", + }, + } + context := core.NewNullContext() + + actualData, err := reader.read(context) + assert.Nil(t, err) + + assert.Equal(t, "", actualData.StatementReferenceNumber) + assert.Equal(t, "", actualData.AccountId) + assert.Equal(t, "", actualData.SequentialNumber) + + assert.Nil(t, actualData.OpeningBalance) + + assert.Equal(t, 1, len(actualData.Statements)) + + assert.Equal(t, "250601", actualData.Statements[0].ValueDate) + assert.Equal(t, "0602", actualData.Statements[0].EntryDate) + assert.Equal(t, MT_MARK_DEBIT, actualData.Statements[0].CreditDebitMark) + assert.Equal(t, "Y", actualData.Statements[0].FundsCode) + assert.Equal(t, "123,45", actualData.Statements[0].Amount) + assert.Equal(t, "NTRF", actualData.Statements[0].TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", actualData.Statements[0].ReferenceForAccountOwner) + assert.Equal(t, "ABC123456", actualData.Statements[0].ReferenceOfAccountServicingInstitution) +} + +func TestMT940DataReaderParse_EmptyContent(t *testing.T) { + reader := &mt940DataReader{ + allLines: []string{}, + } + context := core.NewNullContext() + + _, err := reader.read(context) + assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message) +} + +func TestMT940DataReaderParseBalance_ValidBalance(t *testing.T) { + reader := &mt940DataReader{} + context := core.NewNullContext() + + balance, err := reader.parseBalance(context, "C250601CNY1234,56") + assert.Nil(t, err) + assert.Equal(t, MT_MARK_CREDIT, balance.DebitCreditMark) + assert.Equal(t, "250601", balance.Date) + assert.Equal(t, "CNY", balance.Currency) + assert.Equal(t, "1234,56", balance.Amount) + + balance, err = reader.parseBalance(context, "D250602USD2345,67") + assert.Nil(t, err) + assert.Equal(t, MT_MARK_DEBIT, balance.DebitCreditMark) + assert.Equal(t, "250602", balance.Date) + assert.Equal(t, "USD", balance.Currency) + assert.Equal(t, "2345,67", balance.Amount) +} + +func TestMT940DataReaderParseBalance_InvalidBalance(t *testing.T) { + reader := &mt940DataReader{} + context := core.NewNullContext() + + _, err := reader.parseBalance(context, "X250601CNY1234,56") + assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) + + _, err = reader.parseBalance(context, "C") + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) +} + +func TestMT940DataReaderParseStatement_ValidFields(t *testing.T) { + reader := &mt940DataReader{} + context := core.NewNullContext() + + statement, err := reader.parseStatement(context, "2506010602RDY123,45NTRFTEST//ABC123456") + assert.Nil(t, err) + assert.Equal(t, "250601", statement.ValueDate) + assert.Equal(t, "0602", statement.EntryDate) + assert.Equal(t, MT_MARK_REVERSAL_DEBIT, statement.CreditDebitMark) + assert.Equal(t, "Y", statement.FundsCode) + assert.Equal(t, "123,45", statement.Amount) + assert.Equal(t, "NTRF", statement.TransactionTypeIdentificationCode) + assert.Equal(t, "TEST", statement.ReferenceForAccountOwner) + assert.Equal(t, "ABC123456", statement.ReferenceOfAccountServicingInstitution) + + statement, err = reader.parseStatement(context, "250601RC234,56NSTFFOOBAR") + assert.Nil(t, err) + assert.Equal(t, "250601", statement.ValueDate) + assert.Equal(t, "", statement.EntryDate) + assert.Equal(t, MT_MARK_REVERSAL_CREDIT, statement.CreditDebitMark) + assert.Equal(t, "234,56", statement.Amount) + assert.Equal(t, "NSTF", statement.TransactionTypeIdentificationCode) + assert.Equal(t, "FOOBAR", statement.ReferenceForAccountOwner) +} + +func TestMT940DataReaderParseStatement_InvalidField(t *testing.T) { + reader := &mt940DataReader{} + context := core.NewNullContext() + + _, err := reader.parseStatement(context, "250601X123,45NTRFTest") + assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) +} + +func TestMT940DataReaderParseStatement_MissingField(t *testing.T) { + reader := &mt940DataReader{} + context := core.NewNullContext() + + // Missing entry date + _, err := reader.parseStatement(context, "2406") + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) + + // Missing debit/credit mark + _, err = reader.parseStatement(context, "250601060234,56NTRFTEST//ABC123456") + assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) + + // Missing amount + _, err = reader.parseStatement(context, "250601DNTRFTEST//ABC123456") + assert.EqualError(t, err, errs.ErrAmountInvalid.Message) + + // Missing transaction type identification code + _, err = reader.parseStatement(context, "250601D234,56TEST//ABC123456") + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) + + // Missing reference for account owner + _, err = reader.parseStatement(context, "250601D234,56NTRF//ABC123456") + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) +} diff --git a/pkg/converters/mt/mt_transaction_data_file_importer.go b/pkg/converters/mt/mt_transaction_data_file_importer.go new file mode 100644 index 00000000..ef53b727 --- /dev/null +++ b/pkg/converters/mt/mt_transaction_data_file_importer.go @@ -0,0 +1,42 @@ +package mt + +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 mt940TransactionTypeNameMapping = 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)), +} + +// mt940TransactionDataFileImporter defines the structure of mt940 file importer for statement data +type mt940TransactionDataFileImporter struct{} + +// Initialize a mt940 statement data importer singleton instance +var ( + MT940TransactionDataFileImporter = &mt940TransactionDataFileImporter{} +) + +// ParseImportedData returns the imported data by parsing the mt940 file statement data +func (c *mt940TransactionDataFileImporter) 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) { + mt940DataReader := createNewMT940FileReader(data) + mt940Data, err := mt940DataReader.read(ctx) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + transactionDataTable, err := createNewMT940TransactionDataTable(mt940Data) + + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + + dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(mt940TransactionTypeNameMapping) + + return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) +} diff --git a/pkg/converters/mt/mt_transaction_data_file_importer_test.go b/pkg/converters/mt/mt_transaction_data_file_importer_test.go new file mode 100644 index 00000000..390c3202 --- /dev/null +++ b/pkg/converters/mt/mt_transaction_data_file_importer_test.go @@ -0,0 +1,214 @@ +package mt + +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 TestMT940TransactionDataFileParseImportedData_MinimumValidData(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:2506010602C123,45NTRFTEST//ABC123456 + :86:Transaction 1 + :61:2506020603D234,56NTRFFOOBAR + :86:Transaction 2 + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + + assert.Equal(t, 2, len(allNewTransactions)) + assert.Equal(t, 1, 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(1748736000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + assert.Equal(t, "12345678", 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(1748822400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime)) + assert.Equal(t, int64(23456), allNewTransactions[1].Amount) + assert.Equal(t, "12345678", allNewTransactions[1].OriginalSourceAccountName) + assert.Equal(t, "CNY", allNewTransactions[1].OriginalSourceAccountCurrency) + assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) + assert.Equal(t, "12345678", allNewAccounts[0].Name) + assert.Equal(t, "CNY", allNewAccounts[0].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 TestMT940TransactionDataFileParseImportedData_ParseTransactionValidAmountAndCurrency(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:250601C123,45NTRFTEST + :86:Transaction 1 + :61:250602C0,12NTRFTEST + :86:Transaction 2 + :61:250603C1,NTRFTEST + :86:Transaction 3 + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 3, len(allNewTransactions)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + assert.Equal(t, int64(12), allNewTransactions[1].Amount) + assert.Equal(t, int64(100), allNewTransactions[2].Amount) +} + +func TestMT940TransactionDataFileParseImportedData_ParseTransactionInvalidAmountAndCurrency(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:2506010602C123 45NTRFTEST + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) + + _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:2506010602C12.45NTRFTEST + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrInvalidMT940File.Message) +} + +func TestMT940TransactionDataFileParseImportedData_ParseTransactionType(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:250601C123,45NTRFTEST + :86:Transaction 1 + :61:250602D123,45NTRFTEST + :86:Transaction 2 + :61:250603RC123,45NTRFTEST + :86:Transaction 3 + :61:250604RD123,45NTRFTEST + :86:Transaction 4 + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 4, len(allNewTransactions)) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[3].Type) +} + +func TestMT940TransactionDataFileParseImportedData_ParseDescription(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :25:12345678 + :28C:123/1 + :60F:C250601CNY123,45 + :61:2506010602C123,45NTRFTEST + :86:Transaction 1 + Part 2 + Part 3 + :62F:C250601CNY123,45 + -}`), 0, nil, nil, nil, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, 1, len(allNewTransactions)) + assert.Equal(t, "Transaction 1\nPart 2\nPart 3", allNewTransactions[0].Comment) +} + +func TestMT940TransactionDataFileParseImportedData_MissingRequiredField(t *testing.T) { + converter := MT940TransactionDataFileImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + // Missing opening balance and closing balance + _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte( + `{1:F01TESTBANK123456789}{2:I940TESTBANK}{4: + :20:123456789 + :28C:123/1 + :61:250601C123,45NTRFTEST + :86:Transaction 1 + -}`), 0, nil, nil, nil, nil, nil) + assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) +} diff --git a/pkg/converters/mt/mt_transaction_data_table.go b/pkg/converters/mt/mt_transaction_data_table.go new file mode 100644 index 00000000..60afeb59 --- /dev/null +++ b/pkg/converters/mt/mt_transaction_data_table.go @@ -0,0 +1,168 @@ +package mt + +import ( + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/converters/datatable" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +var mt940TransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{ + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true, + datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true, + datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true, + datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true, + datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true, + datatable.TRANSACTION_DATA_TABLE_AMOUNT: true, + datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true, + datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true, +} + +// mt940TransactionDataTable represents the mt940 statement data dataTable +type mt940TransactionDataTable struct { + data *mt940Data +} + +// mt940TransactionDataRow represents a row in the mt940 statement data dataTable +type mt940TransactionDataRow struct { + statement *mtStatement + finalItems map[datatable.TransactionDataTableColumn]string +} + +// mt940TransactionDataRowIterator represents an iterator for mt940 statement data rows +type mt940TransactionDataRowIterator struct { + dataTable *mt940TransactionDataTable + currentIndex int +} + +// HasColumn implements TransactionDataTable.HasColumn +func (t *mt940TransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool { + _, exists := mt940TransactionSupportedColumns[column] + return exists +} + +// TransactionRowCount implements TransactionDataTable.TransactionRowCount +func (t *mt940TransactionDataTable) TransactionRowCount() int { + return len(t.data.Statements) +} + +// TransactionRowIterator implements TransactionDataTable.TransactionRowIterator +func (t *mt940TransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator { + return &mt940TransactionDataRowIterator{ + dataTable: t, + currentIndex: -1, + } +} + +// IsValid implements TransactionDataRow.IsValid +func (r *mt940TransactionDataRow) IsValid() bool { + return true +} + +// GetData implements TransactionDataRow.GetData +func (r *mt940TransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string { + _, exists := mt940TransactionSupportedColumns[column] + + if exists { + return r.finalItems[column] + } + + return "" +} + +// HasNext implements TransactionDataRowIterator.HasNext +func (t *mt940TransactionDataRowIterator) HasNext() bool { + return t.currentIndex+1 < len(t.dataTable.data.Statements) +} + +// Next implements TransactionDataRowIterator.Next +func (t *mt940TransactionDataRowIterator) Next(ctx core.Context, user *models.User) (datatable.TransactionDataRow, error) { + if t.currentIndex+1 >= len(t.dataTable.data.Statements) { + return nil, nil + } + + t.currentIndex++ + + data := t.dataTable.data.Statements[t.currentIndex] + rowItems, err := t.parseTransaction(ctx, user, t.dataTable.data, data) + + if err != nil { + log.Errorf(ctx, "[mt_transaction_data_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error()) + return nil, err + } + + return &mt940TransactionDataRow{ + statement: data, + finalItems: rowItems, + }, nil +} + +func (t *mt940TransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, mt940Data *mt940Data, statement *mtStatement) (map[datatable.TransactionDataTableColumn]string, error) { + data := make(map[datatable.TransactionDataTableColumn]string, len(mt940TransactionSupportedColumns)) + + if statement.ValueDate == "" && len(statement.ValueDate) != 6 { + return nil, errs.ErrTransactionTimeInvalid + } + + transactionTime, err := utils.FormatYearMonthDayToLongDateTime(statement.ValueDate[0:2], statement.ValueDate[2:4], statement.ValueDate[4:6]) + + if err != nil { + log.Errorf(ctx, "[mt_transaction_data_table.parseTransaction] cannot format transaction time in row#%d, because %s", t.currentIndex, err.Error()) + return nil, errs.ErrTransactionTimeInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transactionTime + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mt940Data.AccountId + + if mt940Data.OpeningBalance != nil && mt940Data.OpeningBalance.Currency != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = mt940Data.OpeningBalance.Currency + } else if mt940Data.ClosingBalance != nil && mt940Data.ClosingBalance.Currency != "" { + data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = mt940Data.ClosingBalance.Currency + } + + amountValue := strings.ReplaceAll(statement.Amount, ",", ".") // decimal separator is comma in mt data + + if len(amountValue) > 0 && amountValue[len(amountValue)-1] == '.' { + amountValue = amountValue[:len(amountValue)-1] + } + + amount, err := utils.ParseAmount(amountValue) + + if err != nil { + log.Errorf(ctx, "[mt_transaction_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", statement.Amount, err.Error()) + return nil, errs.ErrAmountInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) + + if statement.CreditDebitMark == MT_MARK_CREDIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)) + } else if statement.CreditDebitMark == MT_MARK_DEBIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)) + } else if statement.CreditDebitMark == MT_MARK_REVERSAL_CREDIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)) + } else if statement.CreditDebitMark == MT_MARK_REVERSAL_DEBIT { + data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)) + } else { + return nil, errs.ErrTransactionTypeInvalid + } + + data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = strings.Join(statement.AdditionalInformation, "\n") + + return data, nil +} + +// createNewMT940TransactionDataTable creates a new mt940 statement data dataTable +func createNewMT940TransactionDataTable(data *mt940Data) (*mt940TransactionDataTable, error) { + if data == nil || len(data.Statements) < 1 { + return nil, errs.ErrNotFoundTransactionDataInFile + } + + return &mt940TransactionDataTable{ + data: data, + }, nil +} diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index c8233c7b..a9279de3 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -12,6 +12,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII" "github.com/mayswind/ezbookkeeping/pkg/converters/gnucash" "github.com/mayswind/ezbookkeeping/pkg/converters/iif" + "github.com/mayswind/ezbookkeeping/pkg/converters/mt" "github.com/mayswind/ezbookkeeping/pkg/converters/ofx" "github.com/mayswind/ezbookkeeping/pkg/converters/qif" "github.com/mayswind/ezbookkeeping/pkg/converters/wechat" @@ -50,6 +51,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor return iif.IifTransactionDataFileImporter, nil } else if fileType == "camt053" { return camt.Camt053TransactionDataImporter, nil + } else if fileType == "mt940" { + return mt.MT940TransactionDataFileImporter, 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 585a963b..0156b08d 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -29,4 +29,5 @@ var ( 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") + ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file") ) diff --git a/src/consts/file.ts b/src/consts/file.ts index 7542f623..8f5c847c 100644 --- a/src/consts/file.ts +++ b/src/consts/file.ts @@ -167,6 +167,11 @@ export const SUPPORTED_IMPORT_FILE_TYPES: ImportFileType[] = [ name: 'Camt.053 Bank to Customer Statement File', extensions: '.xml' }, + { + type: 'mt940', + name: 'MT940 Consumer Statement Message File', + extensions: '.txt' + }, { type: 'gnucash', name: 'GnuCash XML Database File', diff --git a/src/locales/de.json b/src/locales/de.json index a20022b1..9f211364 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 717e2873..04f4d700 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 de8a9a6d..c0341e10 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 fa5b329b..2f916772 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 54e57bb5..45e4364a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 34e3fae9..a5878741 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 cf7114b0..d6b6766d 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1182,6 +1182,7 @@ "not support include directive for beancount file": "Не підтримується директива \"include\" у файлі Beancount", "invalid amount expression": "Недійсний вираз суми", "invalid xml file": "Invalid XML file", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 93edc2e0..0ca6eb6f 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1182,6 +1182,7 @@ "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", + "invalid mt940 file": "Invalid MT940 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", @@ -1694,6 +1695,7 @@ "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", + "MT940 Consumer Statement Message File": "MT940 Consumer Statement Message 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 27eb7138..52921d03 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1182,6 +1182,7 @@ "not support include directive for beancount file": "不支持 Beancount 文件的 \"include\" 指令", "invalid amount expression": "金额表达式无效", "invalid xml file": "无效的 XML 文件", + "invalid mt940 file": "无效的 MT940 文件", "user custom exchange rate data not found": "用户自定义汇率数据不存在", "cannot update exchange rate data for base currency": "不能更新默认货币的汇率数据", "cannot delete exchange rate data for base currency": "不能删除默认货币的汇率数据", @@ -1694,6 +1695,7 @@ "Day-month-year format": "日-月-年 格式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 文件", "Camt.053 Bank to Customer Statement File": "Camt.053 银行对账单文件", + "MT940 Consumer Statement Message File": "MT940 客户对账消息文件", "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 9727f255..bb3c8837 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -1182,6 +1182,7 @@ "not support include directive for beancount file": "不支援 Beancount 檔案的 \"include\" 指令", "invalid amount expression": "金額表達式無效", "invalid xml file": "無效的 XML 檔案", + "invalid mt940 file": "無效的 MT940 檔案", "user custom exchange rate data not found": "使用者自訂匯率資料不存在", "cannot update exchange rate data for base currency": "不能更新基準貨幣的匯率資料", "cannot delete exchange rate data for base currency": "不能刪除基準貨幣的匯率資料", @@ -1694,6 +1695,7 @@ "Day-month-year format": "日-月-年 格式", "Intuit Interchange Format (IIF) File": "Intuit Interchange Format (IIF) 檔案", "Camt.053 Bank to Customer Statement File": "Camt.053 銀行對帳單檔案", + "MT940 Consumer Statement Message File": "MT940 客戶對帳訊息檔案", "Delimiter-separated Values (DSV) File": "分隔符分隔值 (DSV) 檔案", "Delimiter-separated Values (DSV) Data": "分隔符分隔值 (DSV) 資料", "GnuCash XML Database File": "GnuCash XML 資料庫檔案",