import transactions from mt940 file

This commit is contained in:
MaysWind
2025-06-20 00:57:07 +08:00
parent 8f0e6ba95a
commit 4a6f7eb43c
20 changed files with 1128 additions and 1 deletions
+1 -1
View File
@@ -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
+43
View File
@@ -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
}
+290
View File
@@ -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,
}
}
+341
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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" {
+1
View File
@@ -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")
)
+5
View File
@@ -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',
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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データベースファイル",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 数据库文件",
+2
View File
@@ -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 資料庫檔案",