import transactions from mt940 file
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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データベースファイル",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 数据库文件",
|
||||
|
||||
@@ -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 資料庫檔案",
|
||||
|
||||
Reference in New Issue
Block a user