import transaction from qif file

This commit is contained in:
MaysWind
2024-10-20 01:47:54 +08:00
parent 70ccf7b691
commit 981a1aac4f
15 changed files with 1999 additions and 6 deletions
+124
View File
@@ -0,0 +1,124 @@
package qif
// qifTransactionClearedStatus represents the quicken interchange format (qif) transaction cleared status
type qifTransactionClearedStatus string
// Quicken interchange format transaction types
const (
qifClearedStatusUnreconciled qifTransactionClearedStatus = ""
qifClearedStatusCleared qifTransactionClearedStatus = "C"
qifClearedStatusReconciled qifTransactionClearedStatus = "R"
)
// qifTransactionType represents the quicken interchange format (qif) transaction type
type qifTransactionType string
// Quicken interchange format transaction types
const (
qifInvalidTransactionType qifTransactionType = ""
qifCheckTransactionType qifTransactionType = "KC"
qifDepositTransactionType qifTransactionType = "KD"
qifPaymentTransactionType qifTransactionType = "KP"
qifInvestmentTransactionType qifTransactionType = "KI"
qifElectronicPayeeTransactionType qifTransactionType = "KE"
)
// qifCategoryType represents the quicken interchange format (qif) category type
type qifCategoryType string
// Quicken interchange format category types
const (
qifIncomeTransaction qifCategoryType = "I"
qifExpenseTransaction qifCategoryType = "E"
)
// qifData defines the structure of quicken interchange format (qif) data
type qifData struct {
bankAccountTransactions []*qifTransactionData
cashAccountTransactions []*qifTransactionData
creditCardAccountTransactions []*qifTransactionData
assetAccountTransactions []*qifTransactionData
liabilityAccountTransactions []*qifTransactionData
memorizedTransactions []*qifMemorizedTransactionData
investmentAccountTransactions []*qifInvestmentTransactionData
accounts []*qifAccountData
categories []*qifCategoryData
classes []*qifClassData
}
// qifTransactionData defines the structure of quicken interchange format (qif) transaction data
type qifTransactionData struct {
date string
amount string
clearedStatus qifTransactionClearedStatus
num string
payee string
memo string
addresses []string
category string
subTransactionCategory []string
subTransactionMemo []string
subTransactionAmount []string
account *qifAccountData
}
// qifInvestmentTransactionData defines the structure of quicken interchange format (qif) investment transaction data
type qifInvestmentTransactionData struct {
date string
action string
security string
price string
quantity string
amount string
clearedStatus qifTransactionClearedStatus
text string
memo string
commission string
accountForTransfer string
amountTransferred string
account *qifAccountData
}
// qifMemorizedTransactionData defines the structure of quicken interchange format (qif) memorized transaction data
type qifMemorizedTransactionData struct {
qifTransactionData
transactionType qifTransactionType
amortization qifMemorizedTransactionAmortizationData
}
// qifMemorizedTransactionAmortizationData defines the structure of quicken interchange format (qif) memorized transaction amortization data
type qifMemorizedTransactionAmortizationData struct {
firstPaymentDate string
totalYearsForLoan string
numberOfPayments string
numberOfPeriodsPerYear string
interestRate string
currentLoanBalance string
originalLoanAmount string
}
// qifAccountData defines the structure of quicken interchange format (qif) account data
type qifAccountData struct {
name string
accountType string
description string
creditLimit string
statementBalanceDate string
statementBalanceAmount string
}
// qifCategoryData defines the structure of quicken interchange format (qif) category data
type qifCategoryData struct {
name string
description string
taxRelated bool
categoryType qifCategoryType
budgetAmount string
taxScheduleInformation string
}
// qifClassData defines the structure of quicken interchange format (qif) class data
type qifClassData struct {
name string
description string
}
+466
View File
@@ -0,0 +1,466 @@
package qif
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 qifBankTransactionHeader = "!Type:Bank"
const qifCashTransactionHeader = "!Type:Cash"
const qifCreditCardTransactionHeader = "!Type:CCard"
const qifAssetAccountTransactionHeader = "!Type:Oth A"
const qifLiabilityAccountTransactionHeader = "!Type:Oth L"
const qifMemorizedTransactionHeader = "!Type:Memorized"
const qifInvestmentTransactionHeader = "!Type:Invst"
const qifAccountHeader = "!Account"
const qifCategoryHeader = "!Type:Cat"
const qifClassHeader = "!Type:Class"
const qifEntryStartRune = '!'
const qifEntryEnd = '^'
// qifDataReader defines the structure of quicken interchange format (qif) data reader
type qifDataReader struct {
allLines []string
}
// read returns the imported qif data
func (r *qifDataReader) read(ctx core.Context) (*qifData, error) {
if len(r.allLines) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
data := &qifData{}
var currentEntryHeader string
var currentEntryData []string
var currentAccount *qifAccountData
for i := 0; i < len(r.allLines); i++ {
line := r.allLines[i]
if len(line) < 1 {
continue
}
if line[0] == qifEntryStartRune {
if len(currentEntryData) > 0 {
log.Errorf(ctx, "[qif_data_reader.read] read new entry header \"%s\" after unclosed entry", line)
return nil, errs.ErrInvalidQIFFile
}
if line == qifBankTransactionHeader ||
line == qifCashTransactionHeader ||
line == qifCreditCardTransactionHeader ||
line == qifAssetAccountTransactionHeader ||
line == qifLiabilityAccountTransactionHeader ||
line == qifMemorizedTransactionHeader ||
line == qifInvestmentTransactionHeader ||
line == qifAccountHeader ||
line == qifCategoryHeader ||
line == qifClassHeader {
currentEntryHeader = line
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported entry header line \"%s\" and skip this line", line)
continue
}
} else if line[0] == qifEntryEnd {
entryData := currentEntryData
currentEntryData = nil
if currentEntryHeader == qifBankTransactionHeader ||
currentEntryHeader == qifCashTransactionHeader ||
currentEntryHeader == qifCreditCardTransactionHeader ||
currentEntryHeader == qifAssetAccountTransactionHeader ||
currentEntryHeader == qifLiabilityAccountTransactionHeader {
transactionData, err := r.parseTransaction(ctx, entryData)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
if currentEntryHeader == qifBankTransactionHeader {
data.bankAccountTransactions = append(data.bankAccountTransactions, transactionData)
} else if currentEntryHeader == qifCashTransactionHeader {
data.cashAccountTransactions = append(data.cashAccountTransactions, transactionData)
} else if currentEntryHeader == qifCreditCardTransactionHeader {
data.creditCardAccountTransactions = append(data.creditCardAccountTransactions, transactionData)
} else if currentEntryHeader == qifAssetAccountTransactionHeader {
data.assetAccountTransactions = append(data.assetAccountTransactions, transactionData)
} else if currentEntryHeader == qifLiabilityAccountTransactionHeader {
data.liabilityAccountTransactions = append(data.liabilityAccountTransactions, transactionData)
}
} else if currentEntryHeader == qifMemorizedTransactionHeader {
transactionData, err := r.parseMemorizedTransaction(ctx, entryData)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
data.memorizedTransactions = append(data.memorizedTransactions, transactionData)
} else if currentEntryHeader == qifInvestmentTransactionHeader {
transactionData, err := r.parseInvestmentTransaction(ctx, entryData)
if err != nil {
return nil, err
}
if transactionData == nil {
continue
}
transactionData.account = currentAccount
data.investmentAccountTransactions = append(data.investmentAccountTransactions, transactionData)
} else if currentEntryHeader == qifAccountHeader {
accountData, err := r.parseAccount(ctx, entryData)
if err != nil {
return nil, err
}
if accountData == nil {
continue
}
currentAccount = accountData
data.accounts = append(data.accounts, accountData)
} else if currentEntryHeader == qifCategoryHeader {
categoryData, err := r.parseCategory(ctx, entryData)
if err != nil {
return nil, err
}
if categoryData == nil {
continue
}
data.categories = append(data.categories, categoryData)
} else if currentEntryHeader == qifClassHeader {
classData, err := r.parseClass(ctx, entryData)
if err != nil {
return nil, err
}
if classData == nil {
continue
}
data.classes = append(data.classes, classData)
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported entry header \"%s\" and skip this entry", currentEntryHeader)
continue
}
} else if currentEntryHeader != "" {
currentEntryData = append(currentEntryData, line)
} else {
log.Warnf(ctx, "[qif_data_reader.read] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return data, nil
}
func (r *qifDataReader) parseTransaction(ctx core.Context, data []string) (*qifTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
transactionData := &qifTransactionData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'D' {
transactionData.date = line[1:]
} else if line[0] == 'T' {
transactionData.amount = line[1:]
} else if line[0] == 'C' {
transactionData.clearedStatus = r.parseClearedStatus(ctx, line[1:])
} else if line[0] == 'N' {
transactionData.num = line[1:]
} else if line[0] == 'P' {
transactionData.payee = line[1:]
} else if line[0] == 'M' {
transactionData.memo = line[1:]
} else if line[0] == 'A' {
transactionData.addresses = append(transactionData.addresses, line[1:])
} else if line[0] == 'L' {
transactionData.category = line[1:]
} else if line[0] == 'S' {
transactionData.subTransactionCategory = append(transactionData.subTransactionCategory, line[1:])
} else if line[0] == 'E' {
transactionData.subTransactionMemo = append(transactionData.subTransactionMemo, line[1:])
} else if line[0] == '$' {
transactionData.subTransactionAmount = append(transactionData.subTransactionAmount, line[1:])
} else {
log.Warnf(ctx, "[qif_data_reader.parseTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return transactionData, nil
}
func (r *qifDataReader) parseMemorizedTransaction(ctx core.Context, data []string) (*qifMemorizedTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
baseTransactionData, err := r.parseTransaction(ctx, data)
if err != nil {
return nil, err
}
transactionData := &qifMemorizedTransactionData{
qifTransactionData: *baseTransactionData,
amortization: qifMemorizedTransactionAmortizationData{},
}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'K' {
if line == string(qifCheckTransactionType) {
transactionData.transactionType = qifCheckTransactionType
} else if line == string(qifDepositTransactionType) {
transactionData.transactionType = qifDepositTransactionType
} else if line == string(qifPaymentTransactionType) {
transactionData.transactionType = qifPaymentTransactionType
} else if line == string(qifInvestmentTransactionType) {
transactionData.transactionType = qifInvestmentTransactionType
} else if line == string(qifElectronicPayeeTransactionType) {
transactionData.transactionType = qifElectronicPayeeTransactionType
} else {
log.Warnf(ctx, "[qif_data_reader.parseMemorizedTransaction] read unsupported transaction type \"%s\" and skip this line", line)
continue
}
} else if line[0] == '1' {
transactionData.amortization.firstPaymentDate = line[1:]
} else if line[0] == '2' {
transactionData.amortization.totalYearsForLoan = line[1:]
} else if line[0] == '3' {
transactionData.amortization.numberOfPayments = line[1:]
} else if line[0] == '4' {
transactionData.amortization.numberOfPeriodsPerYear = line[1:]
} else if line[0] == '5' {
transactionData.amortization.interestRate = line[1:]
} else if line[0] == '6' {
transactionData.amortization.currentLoanBalance = line[1:]
} else if line[0] == '7' {
transactionData.amortization.originalLoanAmount = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseMemorizedTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return transactionData, nil
}
func (r *qifDataReader) parseInvestmentTransaction(ctx core.Context, data []string) (*qifInvestmentTransactionData, error) {
if len(data) < 1 {
return nil, nil
}
transactionData := &qifInvestmentTransactionData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'D' {
transactionData.date = line[1:]
} else if line[0] == 'N' {
transactionData.action = line[1:]
} else if line[0] == 'Y' {
transactionData.security = line[1:]
} else if line[0] == 'I' {
transactionData.price = line[1:]
} else if line[0] == 'Q' {
transactionData.quantity = line[1:]
} else if line[0] == 'T' {
transactionData.amount = line[1:]
} else if line[0] == 'C' {
transactionData.clearedStatus = r.parseClearedStatus(ctx, line[1:])
} else if line[0] == 'P' {
transactionData.text = line[1:]
} else if line[0] == 'M' {
transactionData.memo = line[1:]
} else if line[0] == 'O' {
transactionData.commission = line[1:]
} else if line[0] == 'L' {
transactionData.accountForTransfer = line[1:]
} else if line[0] == '$' {
transactionData.amountTransferred = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseInvestmentTransaction] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return transactionData, nil
}
func (r *qifDataReader) parseAccount(ctx core.Context, data []string) (*qifAccountData, error) {
if len(data) < 1 {
return nil, nil
}
accountData := &qifAccountData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
accountData.name = line[1:]
} else if line[0] == 'T' {
accountData.accountType = line[1:]
} else if line[0] == 'D' {
accountData.description = line[1:]
} else if line[0] == 'L' {
accountData.creditLimit = line[1:]
} else if line[0] == '/' {
accountData.statementBalanceDate = line[1:]
} else if line[0] == '$' {
accountData.statementBalanceAmount = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseAccount] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return accountData, nil
}
func (r *qifDataReader) parseCategory(ctx core.Context, data []string) (*qifCategoryData, error) {
if len(data) < 1 {
return nil, nil
}
categoryData := &qifCategoryData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
categoryData.name = line[1:]
} else if line[0] == 'D' {
categoryData.description = line[1:]
} else if line[0] == 'T' {
categoryData.taxRelated = true
} else if line[0] == 'I' {
categoryData.categoryType = qifIncomeTransaction
} else if line[0] == 'E' {
categoryData.categoryType = qifExpenseTransaction
} else if line[0] == 'B' {
categoryData.budgetAmount = line[1:]
} else if line[0] == 'R' {
categoryData.taxScheduleInformation = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseCategory] read unsupported line \"%s\" and skip this line", line)
continue
}
}
if categoryData.categoryType == "" {
categoryData.categoryType = qifExpenseTransaction
}
return categoryData, nil
}
func (r *qifDataReader) parseClass(ctx core.Context, data []string) (*qifClassData, error) {
if len(data) < 1 {
return nil, nil
}
classData := &qifClassData{}
for i := 0; i < len(data); i++ {
line := data[i]
if len(line) < 1 {
continue
}
if line[0] == 'N' {
classData.name = line[1:]
} else if line[0] == 'D' {
classData.description = line[1:]
} else {
log.Warnf(ctx, "[qif_data_reader.parseClass] read unsupported line \"%s\" and skip this line", line)
continue
}
}
return classData, nil
}
func (r *qifDataReader) parseClearedStatus(ctx core.Context, value string) qifTransactionClearedStatus {
if value == "" {
return qifClearedStatusUnreconciled
} else if value == "*" || strings.ToUpper(value) == "C" {
return qifClearedStatusCleared
} else if strings.ToUpper(value) == "R" || strings.ToUpper(value) == "X" {
return qifClearedStatusReconciled
} else {
log.Warnf(ctx, "[qif_data_reader.parseClearedStatus] read unsupported transaction cleared status \"%s\" and skip this value", value)
return qifClearedStatusUnreconciled
}
}
func createNewQifDataReader(data []byte) *qifDataReader {
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 &qifDataReader{
allLines: allLines,
}
}
+477
View File
@@ -0,0 +1,477 @@
package qif
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestQifDataReaderParse(t *testing.T) {
reader := &qifDataReader{
allLines: []string{
"!Type:Bank",
"D2024/10/9",
"T-123.45",
"^",
"D2024/10/12",
"T+234.56",
"^",
"!Type:Cash",
"D2024/9/1",
"T100.00",
"POpening Balance",
"L[Wallet]",
"^",
"!Type:Memorized",
"KC",
"T-123.45",
"12024/10/13",
"23",
"31",
"42",
"512.34",
"6100.45",
"7234.56",
"^",
"!Type:Invst",
"D2024/10/14",
"NBuy",
"YTest",
"I12.34",
"Q10",
"T-123.4",
"^",
"!Account",
"NTest Account",
"^",
"NWallet",
"^",
"!Type:Cat",
"NTest Category",
"I",
"^",
"!Type:Class",
"NTest Class",
"DFoo Bar",
"^",
},
}
context := core.NewNullContext()
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.bankAccountTransactions))
assert.Equal(t, "2024/10/9", actualData.bankAccountTransactions[0].date)
assert.Equal(t, "-123.45", actualData.bankAccountTransactions[0].amount)
assert.Equal(t, "2024/10/12", actualData.bankAccountTransactions[1].date)
assert.Equal(t, "+234.56", actualData.bankAccountTransactions[1].amount)
assert.Equal(t, 1, len(actualData.cashAccountTransactions))
assert.Equal(t, "2024/9/1", actualData.cashAccountTransactions[0].date)
assert.Equal(t, "100.00", actualData.cashAccountTransactions[0].amount)
assert.Equal(t, "Opening Balance", actualData.cashAccountTransactions[0].payee)
assert.Equal(t, "[Wallet]", actualData.cashAccountTransactions[0].category)
assert.Equal(t, 1, len(actualData.memorizedTransactions))
assert.Equal(t, qifCheckTransactionType, actualData.memorizedTransactions[0].transactionType)
assert.Equal(t, "-123.45", actualData.memorizedTransactions[0].amount)
assert.Equal(t, "2024/10/13", actualData.memorizedTransactions[0].amortization.firstPaymentDate)
assert.Equal(t, "3", actualData.memorizedTransactions[0].amortization.totalYearsForLoan)
assert.Equal(t, "1", actualData.memorizedTransactions[0].amortization.numberOfPayments)
assert.Equal(t, "2", actualData.memorizedTransactions[0].amortization.numberOfPeriodsPerYear)
assert.Equal(t, "12.34", actualData.memorizedTransactions[0].amortization.interestRate)
assert.Equal(t, "100.45", actualData.memorizedTransactions[0].amortization.currentLoanBalance)
assert.Equal(t, "234.56", actualData.memorizedTransactions[0].amortization.originalLoanAmount)
assert.Equal(t, 1, len(actualData.investmentAccountTransactions))
assert.Equal(t, "2024/10/14", actualData.investmentAccountTransactions[0].date)
assert.Equal(t, "Buy", actualData.investmentAccountTransactions[0].action)
assert.Equal(t, "Test", actualData.investmentAccountTransactions[0].security)
assert.Equal(t, "12.34", actualData.investmentAccountTransactions[0].price)
assert.Equal(t, "10", actualData.investmentAccountTransactions[0].quantity)
assert.Equal(t, "-123.4", actualData.investmentAccountTransactions[0].amount)
assert.Equal(t, 2, len(actualData.accounts))
assert.Equal(t, "Test Account", actualData.accounts[0].name)
assert.Equal(t, "Wallet", actualData.accounts[1].name)
assert.Equal(t, 1, len(actualData.categories))
assert.Equal(t, "Test Category", actualData.categories[0].name)
assert.Equal(t, qifIncomeTransaction, actualData.categories[0].categoryType)
assert.Equal(t, 1, len(actualData.classes))
assert.Equal(t, "Test Class", actualData.classes[0].name)
assert.Equal(t, "Foo Bar", actualData.classes[0].description)
}
func TestQifDataReaderParse_AccountEntryBeforeTransaction(t *testing.T) {
reader := &qifDataReader{
allLines: []string{
"!Account",
"NTest Account",
"^",
"!Type:Bank",
"D2024/10/9",
"T-123.45",
"^",
"D2024/10/12",
"T+234.56",
"^",
"!Account",
"NWallet",
"^",
"!Type:Cash",
"D2024/9/1",
"T100.00",
"POpening Balance",
"L[Wallet]",
"^",
},
}
context := core.NewNullContext()
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.bankAccountTransactions))
assert.Equal(t, "2024/10/9", actualData.bankAccountTransactions[0].date)
assert.Equal(t, "-123.45", actualData.bankAccountTransactions[0].amount)
assert.Equal(t, "2024/10/12", actualData.bankAccountTransactions[1].date)
assert.Equal(t, "+234.56", actualData.bankAccountTransactions[1].amount)
assert.Equal(t, 1, len(actualData.cashAccountTransactions))
assert.Equal(t, "2024/9/1", actualData.cashAccountTransactions[0].date)
assert.Equal(t, "100.00", actualData.cashAccountTransactions[0].amount)
assert.Equal(t, "Opening Balance", actualData.cashAccountTransactions[0].payee)
assert.Equal(t, "[Wallet]", actualData.cashAccountTransactions[0].category)
assert.Equal(t, 2, len(actualData.accounts))
assert.Equal(t, "Test Account", actualData.accounts[0].name)
assert.Equal(t, "Wallet", actualData.accounts[1].name)
}
func TestQifDataReaderParse_EmptyContent(t *testing.T) {
reader := &qifDataReader{
allLines: []string{},
}
context := core.NewNullContext()
_, err := reader.read(context)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestQifDataReaderParse_EmptyEntry(t *testing.T) {
reader := &qifDataReader{
allLines: []string{
"!Type:Bank",
"^",
"!Type:Cash",
"^",
"!Type:Memorized",
"^",
"!Type:Invst",
"^",
"!Account",
"^",
"!Type:Cat",
"^",
"!Type:Class",
"^",
},
}
context := core.NewNullContext()
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 0, len(actualData.bankAccountTransactions))
assert.Equal(t, 0, len(actualData.cashAccountTransactions))
assert.Equal(t, 0, len(actualData.memorizedTransactions))
assert.Equal(t, 0, len(actualData.investmentAccountTransactions))
assert.Equal(t, 0, len(actualData.accounts))
assert.Equal(t, 0, len(actualData.categories))
assert.Equal(t, 0, len(actualData.classes))
}
func TestQifDataReaderParse_NewEntryHeaderAfterUnclosedEntry(t *testing.T) {
reader := &qifDataReader{
allLines: []string{
"!Type:Bank",
"D2024/10/9",
"T-123.45",
"!Type:Cash",
"D2024/9/1",
"T100.00",
"POpening Balance",
"L[Wallet]",
"^",
},
}
context := core.NewNullContext()
_, err := reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidQIFFile.Message)
}
func TestQifDataReaderParseTransaction_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseTransaction(context, []string{
"D2024/10/12",
"T-123.45",
"C",
"N100",
"PFoo",
"MBar",
"AAddress 1",
"AAddress 2",
"AAddress 3",
"LTest Category",
"SPart1 Category",
"EPart1 Memo",
"$-100.00",
"SPart2 Category",
"EPart2 Memo",
"$-23.45",
})
assert.Nil(t, err)
assert.Equal(t, "2024/10/12", actualData.date)
assert.Equal(t, "-123.45", actualData.amount)
assert.Equal(t, qifClearedStatusUnreconciled, actualData.clearedStatus)
assert.Equal(t, "100", actualData.num)
assert.Equal(t, "Foo", actualData.payee)
assert.Equal(t, "Bar", actualData.memo)
assert.Equal(t, 3, len(actualData.addresses))
assert.Equal(t, "Address 1", actualData.addresses[0])
assert.Equal(t, "Address 2", actualData.addresses[1])
assert.Equal(t, "Address 3", actualData.addresses[2])
assert.Equal(t, "Test Category", actualData.category)
assert.Equal(t, 2, len(actualData.subTransactionCategory))
assert.Equal(t, "Part1 Category", actualData.subTransactionCategory[0])
assert.Equal(t, "Part2 Category", actualData.subTransactionCategory[1])
assert.Equal(t, 2, len(actualData.subTransactionMemo))
assert.Equal(t, "Part1 Memo", actualData.subTransactionMemo[0])
assert.Equal(t, "Part2 Memo", actualData.subTransactionMemo[1])
assert.Equal(t, 2, len(actualData.subTransactionAmount))
assert.Equal(t, "-100.00", actualData.subTransactionAmount[0])
assert.Equal(t, "-23.45", actualData.subTransactionAmount[1])
}
func TestQifDataReaderParseMemorizedTransaction_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseMemorizedTransaction(context, []string{
"KC",
"D2024/10/12",
"T-123.45",
"C*",
"N100",
"PFoo",
"MBar",
"12024/10/13",
"23",
"31",
"42",
"512.34",
"6100.45",
"7234.56",
})
assert.Nil(t, err)
assert.Equal(t, qifCheckTransactionType, actualData.transactionType)
assert.Equal(t, "2024/10/12", actualData.date)
assert.Equal(t, "-123.45", actualData.amount)
assert.Equal(t, qifClearedStatusCleared, actualData.clearedStatus)
assert.Equal(t, "100", actualData.num)
assert.Equal(t, "Foo", actualData.payee)
assert.Equal(t, "Bar", actualData.memo)
assert.Equal(t, "2024/10/13", actualData.amortization.firstPaymentDate)
assert.Equal(t, "3", actualData.amortization.totalYearsForLoan)
assert.Equal(t, "1", actualData.amortization.numberOfPayments)
assert.Equal(t, "2", actualData.amortization.numberOfPeriodsPerYear)
assert.Equal(t, "12.34", actualData.amortization.interestRate)
assert.Equal(t, "100.45", actualData.amortization.currentLoanBalance)
assert.Equal(t, "234.56", actualData.amortization.originalLoanAmount)
actualData, err = reader.parseMemorizedTransaction(context, []string{"KD"})
assert.Nil(t, err)
assert.Equal(t, qifDepositTransactionType, actualData.transactionType)
actualData, err = reader.parseMemorizedTransaction(context, []string{"KP"})
assert.Nil(t, err)
assert.Equal(t, qifPaymentTransactionType, actualData.transactionType)
actualData, err = reader.parseMemorizedTransaction(context, []string{"KI"})
assert.Nil(t, err)
assert.Equal(t, qifInvestmentTransactionType, actualData.transactionType)
actualData, err = reader.parseMemorizedTransaction(context, []string{"KE"})
assert.Nil(t, err)
assert.Equal(t, qifElectronicPayeeTransactionType, actualData.transactionType)
}
func TestQifDataReaderParseInvestmentTransaction_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseInvestmentTransaction(context, []string{
"D2024/10/12",
"NBuy",
"YTest",
"I12.34",
"Q10",
"T-123.4",
"CR",
"PFoo",
"MBar",
"OTest2",
"LAccount Name",
"$100",
})
assert.Nil(t, err)
assert.Equal(t, "2024/10/12", actualData.date)
assert.Equal(t, "Buy", actualData.action)
assert.Equal(t, "Test", actualData.security)
assert.Equal(t, "12.34", actualData.price)
assert.Equal(t, "10", actualData.quantity)
assert.Equal(t, "-123.4", actualData.amount)
assert.Equal(t, qifClearedStatusReconciled, actualData.clearedStatus)
assert.Equal(t, "Foo", actualData.text)
assert.Equal(t, "Bar", actualData.memo)
assert.Equal(t, "Test2", actualData.commission)
assert.Equal(t, "Account Name", actualData.accountForTransfer)
assert.Equal(t, "100", actualData.amountTransferred)
}
func TestQifDataReaderParseAccount_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseAccount(context, []string{
"NAccount Name",
"TAccount Type",
"DSome Text",
"L1234.56",
"/2024/10/12",
"$123.45",
})
assert.Nil(t, err)
assert.Equal(t, "Account Name", actualData.name)
assert.Equal(t, "Account Type", actualData.accountType)
assert.Equal(t, "Some Text", actualData.description)
assert.Equal(t, "1234.56", actualData.creditLimit)
assert.Equal(t, "2024/10/12", actualData.statementBalanceDate)
assert.Equal(t, "123.45", actualData.statementBalanceAmount)
}
func TestQifDataReaderParseCategory_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseCategory(context, []string{
"NCategory Name:Sub Category Name",
"DSome Text",
"T",
"I",
"B123.45",
"RTest",
})
assert.Nil(t, err)
assert.Equal(t, "Category Name:Sub Category Name", actualData.name)
assert.Equal(t, "Some Text", actualData.description)
assert.Equal(t, true, actualData.taxRelated)
assert.Equal(t, qifIncomeTransaction, actualData.categoryType)
assert.Equal(t, "123.45", actualData.budgetAmount)
assert.Equal(t, "Test", actualData.taxScheduleInformation)
actualData2, err := reader.parseCategory(context, []string{
"NCategory Name:Sub Category Name",
"DSome Text",
"E",
})
assert.Nil(t, err)
assert.Equal(t, "Category Name:Sub Category Name", actualData2.name)
assert.Equal(t, "Some Text", actualData2.description)
assert.Equal(t, false, actualData2.taxRelated)
assert.Equal(t, qifExpenseTransaction, actualData2.categoryType)
actualData3, err := reader.parseCategory(context, []string{
"NCategory Name:Sub Category Name",
"DSome Text",
})
assert.Nil(t, err)
assert.Equal(t, "Category Name:Sub Category Name", actualData3.name)
assert.Equal(t, "Some Text", actualData3.description)
assert.Equal(t, qifExpenseTransaction, actualData3.categoryType)
}
func TestQifDataReaderParseClass_SupportedFields(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualData, err := reader.parseClass(context, []string{
"NClass Name",
"DSome Text",
})
assert.Nil(t, err)
assert.Equal(t, "Class Name", actualData.name)
assert.Equal(t, "Some Text", actualData.description)
}
func TestQifDataReaderParse_UnsupportedFieldsOrValues(t *testing.T) {
reader := &qifDataReader{}
context := core.NewNullContext()
actualTransactionData, err := reader.parseTransaction(context, []string{
"ZTest",
"CZ",
"",
})
assert.Nil(t, err)
assert.Equal(t, qifClearedStatusUnreconciled, actualTransactionData.clearedStatus)
actualMemorizedTransactionData, err := reader.parseMemorizedTransaction(context, []string{
"ZTest",
"KZ",
"",
})
assert.Nil(t, err)
assert.Equal(t, qifInvalidTransactionType, actualMemorizedTransactionData.transactionType)
_, err = reader.parseInvestmentTransaction(context, []string{
"ZTest",
"",
})
assert.Nil(t, err)
_, err = reader.parseAccount(context, []string{
"ZTest",
"",
})
assert.Nil(t, err)
_, err = reader.parseCategory(context, []string{
"ZTest",
"",
})
assert.Nil(t, err)
_, err = reader.parseClass(context, []string{
"ZTest",
"",
})
assert.Nil(t, err)
}
@@ -0,0 +1,55 @@
package qif
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var qifTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
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)),
}
// qifTransactionDataImporter defines the structure of quicken interchange format (qif) importer for transaction data
type qifTransactionDataImporter struct {
dateFormatType qifDateFormatType
}
// Initialize a quicken interchange format (qif) transaction data importer singleton instance
var (
QifYearMonthDayTransactionDataImporter = &qifTransactionDataImporter{
dateFormatType: qifYearMonthDayDateFormat,
}
QifMonthDayYearTransactionDataImporter = &qifTransactionDataImporter{
dateFormatType: qifMonthDayYearDateFormat,
}
QifDayMonthYearTransactionDataImporter = &qifTransactionDataImporter{
dateFormatType: qifDayMonthYearDateFormat,
}
)
// ParseImportedData returns the imported data by parsing the quicken interchange format (qif) transaction data
func (c *qifTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
qifDataReader := createNewQifDataReader(data)
qifData, err := qifDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewQifTransactionDataTable(c.dateFormatType, qifData)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := datatable.CreateNewSimpleImporter(qifTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,437 @@
package qif
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 TestQIFTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T123.45\n"+
"POpening Balance\n"+
"L[Test Account]\n"+
"^\n"+
"D2024-09-02\n"+
"T0.12\n"+
"LTest Category\n"+
"^\n"+
"D2024-09-03\n"+
"T-1.00\n"+
"LTest Category2\n"+
"^\n"+
"D2024-09-04\n"+
"T-0.05\n"+
"L[Test Account2]\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 3, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, "", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestQIFTransactionDataFileParseImportedData_ParseYearMonthDayDateFormatTime(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"+
"D2024-9-2\n"+
"T-123.45\n"+
"^\n"+
"D2024/9/3\n"+
"T-123.45\n"+
"^\n"+
"D2024.9.4\n"+
"T-123.45\n"+
"^\n"+
"D2024'9.5\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 5, len(allNewTransactions))
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
}
func TestQIFTransactionDataFileParseImportedData_ParseMonthDayYearDateFormatTime(t *testing.T) {
converter := QifMonthDayYearTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D09-01-2024\n"+
"T-123.45\n"+
"^\n"+
"D9-2-2024\n"+
"T-123.45\n"+
"^\n"+
"D9/3/2024\n"+
"T-123.45\n"+
"^\n"+
"D9.4.2024\n"+
"T-123.45\n"+
"^\n"+
"D9.5'2024\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 5, len(allNewTransactions))
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
}
func TestQIFTransactionDataFileParseImportedData_ParseDayYearMonthDateFormatTime(t *testing.T) {
converter := QifDayMonthYearTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D01-09-2024\n"+
"T-123.45\n"+
"^\n"+
"D2-9-2024\n"+
"T-123.45\n"+
"^\n"+
"D3/9/2024\n"+
"T-123.45\n"+
"^\n"+
"D4.9.2024\n"+
"T-123.45\n"+
"^\n"+
"D5'9.2024\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 5, len(allNewTransactions))
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
}
func TestQIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024 09 01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestQIFTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T-123 45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestQIFTransactionDataFileParseImportedData_ParseAccountType(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Cash\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!Type:CCard\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!Type:Oth A\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!Type:Oth L\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
}
func TestQIFTransactionDataFileParseImportedData_ParseAccount(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Account\n"+
"NTest Account\n"+
"^\n"+
"!Type:Bank\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 1, len(allNewAccounts))
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
}
func TestQIFTransactionDataFileParseImportedData_ParseAmountWithLeadingPlusSing(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T+123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
}
func TestQIFTransactionDataFileParseImportedData_ParseSubCategory(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, allNewSubExpenseCategories, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"LTest Category:Sub Category\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, "Sub Category", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Sub Category", allNewSubExpenseCategories[0].Name)
}
func TestQIFTransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"T-123.45\n"+
"PTest\n"+
"Mfoo bar\t#test\n"+
"^\n"+
"D2024-09-02\n"+
"T-234.56\n"+
"PTest2\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment)
assert.Equal(t, "Test2", allNewTransactions[1].Comment)
}
func TestQIFTransactionDataFileParseImportedData_MissingRequiredFields(t *testing.T) {
converter := QifYearMonthDayTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Field
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"T-123.45\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
// Missing Amount Field
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!Type:Bank\n"+
"D2024-09-01\n"+
"^\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
@@ -0,0 +1,241 @@
package qif
import (
"fmt"
"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"
)
const qifOpeningBalancePayeeText = "Opening Balance"
var qifTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
// qifDateFormatType represents the quicken interchange format (qif) date format type
type qifDateFormatType byte
const (
qifYearMonthDayDateFormat qifDateFormatType = 0
qifMonthDayYearDateFormat qifDateFormatType = 1
qifDayMonthYearDateFormat qifDateFormatType = 2
)
// qifTransactionDataTable defines the structure of quicken interchange format (qif) transaction data table
type qifTransactionDataTable struct {
dateFormatType qifDateFormatType
allData []*qifTransactionData
}
// qifTransactionDataRow defines the structure of quicken interchange format (qif) transaction data row
type qifTransactionDataRow struct {
dataTable *qifTransactionDataTable
data *qifTransactionData
finalItems map[datatable.TransactionDataTableColumn]string
}
// qifTransactionDataRowIterator defines the structure of quicken interchange format (qif) transaction data row iterator
type qifTransactionDataRowIterator struct {
dataTable *qifTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *qifTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := qifTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *qifTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *qifTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &qifTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *qifTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *qifTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := qifTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *qifTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next imported data row
func (t *qifTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
if t.currentIndex+1 >= len(t.dataTable.allData) {
return nil, nil
}
t.currentIndex++
data := t.dataTable.allData[t.currentIndex]
rowItems, err := t.parseTransaction(ctx, user, data)
if err != nil {
return nil, err
}
return &qifTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
}, nil
}
func (t *qifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, qifTransaction *qifTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(qifTransactionSupportedColumns))
transactionTime, err := t.parseTransactionTime(ctx, qifTransaction.date)
if err != nil {
return nil, err
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transactionTime
if qifTransaction.amount == "" {
return nil, errs.ErrAmountInvalid
}
amount, err := utils.ParseAmount(strings.ReplaceAll(qifTransaction.amount, ",", ""))
if err != nil {
return nil, errs.ErrAmountInvalid
}
if qifTransaction.account != nil {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = qifTransaction.account.name
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
}
if len(qifTransaction.category) > 0 && qifTransaction.category[0] == '[' && qifTransaction.category[len(qifTransaction.category)-1] == ']' {
if qifTransaction.payee == qifOpeningBalancePayeeText { // balance modification
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = qifTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = qifTransaction.category[1 : len(qifTransaction.category)-1]
} else { // transfer to [account name]
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = qifTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = qifTransaction.category[1 : len(qifTransaction.category)-1]
}
} else { // income/expense
if amount >= 0 {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = qifTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
} else {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = qifTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
if strings.Index(qifTransaction.category, ":") > 0 { // category:subcategory
categories := strings.Split(qifTransaction.category, ":")
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = categories[0]
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = categories[len(categories)-1]
} else {
data[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = qifTransaction.category
}
}
if qifTransaction.memo != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = qifTransaction.memo
} else if qifTransaction.payee != "" && qifTransaction.payee != qifOpeningBalancePayeeText {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = qifTransaction.payee
}
return data, nil
}
func (t *qifTransactionDataRowIterator) parseTransactionTime(ctx core.Context, date string) (string, error) {
var year, month, day string
if (t.dataTable.dateFormatType == qifYearMonthDayDateFormat && utils.IsValidYearMonthDayLongOrShortDateFormat(date)) ||
(t.dataTable.dateFormatType == qifMonthDayYearDateFormat && utils.IsValidMonthDayYearLongOrShortDateFormat(date)) ||
(t.dataTable.dateFormatType == qifDayMonthYearDateFormat && utils.IsValidDayMonthYearLongOrShortDateFormat(date)) {
date = strings.ReplaceAll(date, ".", "-")
date = strings.ReplaceAll(date, "/", "-")
date = strings.ReplaceAll(date, "'", "-")
items := strings.Split(date, "-")
if t.dataTable.dateFormatType == qifYearMonthDayDateFormat {
year = items[0]
month = items[1]
day = items[2]
} else if t.dataTable.dateFormatType == qifMonthDayYearDateFormat {
month = items[0]
day = items[1]
year = items[2]
} else if t.dataTable.dateFormatType == qifDayMonthYearDateFormat {
day = items[0]
month = items[1]
year = items[2]
}
}
if year == "" || month == "" || day == "" {
log.Errorf(ctx, "[qif_transaction_data_table.parseTransactionTime] cannot parse date \"%s\"", date)
return "", errs.ErrTransactionTimeInvalid
}
if len(month) < 2 {
month = "0" + month
}
if len(day) < 2 {
day = "0" + day
}
return fmt.Sprintf("%s-%s-%s 00:00:00", year, month, day), nil
}
func createNewQifTransactionDataTable(dateFormatType qifDateFormatType, qifData *qifData) (*qifTransactionDataTable, error) {
if qifData == nil {
return nil, errs.ErrNotFoundTransactionDataInFile
}
allData := make([]*qifTransactionData, 0)
allData = append(allData, qifData.bankAccountTransactions...)
allData = append(allData, qifData.cashAccountTransactions...)
allData = append(allData, qifData.creditCardAccountTransactions...)
allData = append(allData, qifData.assetAccountTransactions...)
allData = append(allData, qifData.liabilityAccountTransactions...)
return &qifTransactionDataTable{
dateFormatType: dateFormatType,
allData: allData,
}, nil
}
@@ -6,6 +6,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/converters/default" "github.com/mayswind/ezbookkeeping/pkg/converters/default"
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee" "github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII" "github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat" "github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
) )
@@ -27,6 +28,12 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
return _default.DefaultTransactionDataCSVFileConverter, nil return _default.DefaultTransactionDataCSVFileConverter, nil
} else if fileType == "ezbookkeeping_tsv" { } else if fileType == "ezbookkeeping_tsv" {
return _default.DefaultTransactionDataTSVFileConverter, nil return _default.DefaultTransactionDataTSVFileConverter, nil
} else if fileType == "qif_ymd" {
return qif.QifYearMonthDayTransactionDataImporter, nil
} else if fileType == "qif_mdy" {
return qif.QifMonthDayYearTransactionDataImporter, nil
} else if fileType == "qif_dmy" {
return qif.QifDayMonthYearTransactionDataImporter, nil
} else if fileType == "firefly_iii_csv" { } else if fileType == "firefly_iii_csv" {
return fireflyIII.FireflyIIITransactionDataCsvFileImporter, nil return fireflyIII.FireflyIIITransactionDataCsvFileImporter, nil
} else if fileType == "feidee_mymoney_csv" { } else if fileType == "feidee_mymoney_csv" {
+1
View File
@@ -16,4 +16,5 @@ var (
ErrInvalidCSVFile = NewNormalError(NormalSubcategoryConverter, 9, http.StatusBadRequest, "invalid csv file") ErrInvalidCSVFile = NewNormalError(NormalSubcategoryConverter, 9, http.StatusBadRequest, "invalid csv file")
ErrRelatedIdCannotBeBlank = NewNormalError(NormalSubcategoryConverter, 10, http.StatusBadRequest, "related id cannot be blank") ErrRelatedIdCannotBeBlank = NewNormalError(NormalSubcategoryConverter, 10, http.StatusBadRequest, "related id cannot be blank")
ErrFoundRecordNotHasRelatedRecord = NewNormalError(NormalSubcategoryConverter, 11, http.StatusBadRequest, "found some transactions without related records") ErrFoundRecordNotHasRelatedRecord = NewNormalError(NormalSubcategoryConverter, 11, http.StatusBadRequest, "found some transactions without related records")
ErrInvalidQIFFile = NewNormalError(NormalSubcategoryConverter, 12, http.StatusBadRequest, "invalid qif file")
) )
+3
View File
@@ -140,6 +140,9 @@ func ParseAmount(amount string) (int64, error) {
if amount[0] == '-' { if amount[0] == '-' {
amount = amount[1:] amount = amount[1:]
sign = -1 sign = -1
} else if amount[0] == '+' {
amount = amount[1:]
sign = 1
} }
if len(amount) < 1 { if len(amount) < 1 {
+15
View File
@@ -278,6 +278,21 @@ func TestParseAmount(t *testing.T) {
actualValue, err = ParseAmount("-12.34") actualValue, err = ParseAmount("-12.34")
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue) assert.Equal(t, expectedValue, actualValue)
expectedValue = int64(0)
actualValue, err = ParseAmount("+0")
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
expectedValue = int64(12)
actualValue, err = ParseAmount("+0.12")
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
expectedValue = int64(1234)
actualValue, err = ParseAmount("+12.34")
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
} }
func TestParseAmount_InvalidAmount(t *testing.T) { func TestParseAmount_InvalidAmount(t *testing.T) {
+18
View File
@@ -9,6 +9,9 @@ var (
longDateTimePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$") longDateTimePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$")
longDateTimeWithoutSecondPattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9])$") longDateTimeWithoutSecondPattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9])$")
longDatePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01])$") longDatePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01])$")
longOrShortYearMonthDayDatePattern = regexp.MustCompile("^([1-9][0-9]{3})[-/.']([1-9]|0[1-9]|1[0-2])[-/.']([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])$")
longOrShortMonthDayYearDatePattern = regexp.MustCompile("^([1-9]|0[1-9]|1[0-2])[-/.']([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])[-/.']([1-9][0-9]{3})$")
longOrShortDayMonthYearDatePattern = regexp.MustCompile("^([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])[-/.']([1-9]|0[1-9]|1[0-2])[-/.']([1-9][0-9]{3})$")
) )
// IsValidUsername reports whether username is valid // IsValidUsername reports whether username is valid
@@ -40,3 +43,18 @@ func IsValidLongDateTimeWithoutSecondFormat(datetime string) bool {
func IsValidLongDateFormat(date string) bool { func IsValidLongDateFormat(date string) bool {
return longDatePattern.MatchString(date) return longDatePattern.MatchString(date)
} }
// IsValidYearMonthDayLongOrShortDateFormat reports long date is valid format
func IsValidYearMonthDayLongOrShortDateFormat(date string) bool {
return longOrShortYearMonthDayDatePattern.MatchString(date)
}
// IsValidMonthDayYearLongOrShortDateFormat reports long date is valid format
func IsValidMonthDayYearLongOrShortDateFormat(date string) bool {
return longOrShortMonthDayYearDatePattern.MatchString(date)
}
// IsValidDayMonthYearLongOrShortDateFormat reports long date is valid format
func IsValidDayMonthYearLongOrShortDateFormat(date string) bool {
return longOrShortDayMonthYearDatePattern.MatchString(date)
}
+126
View File
@@ -229,3 +229,129 @@ func TestIsValidLongDateFormat_InvalidLongDateFormat(t *testing.T) {
actualValue = IsValidLongDateFormat(datetime) actualValue = IsValidLongDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue) assert.Equal(t, expectedValue, actualValue)
} }
func TestIsValidYearMonthDayLongOrShortDateFormat_ValidFormat(t *testing.T) {
datetime := "2024-09-01"
expectedValue := true
actualValue := IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024-09-1"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024-9-01"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024-9-1"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "9999-12-31"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024/09/01"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024.09.01"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "2024'09.01"
expectedValue = true
actualValue = IsValidYearMonthDayLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
}
func TestIsValidMonthDayYearLongOrShortDateFormat_ValidFormat(t *testing.T) {
datetime := "09-01-2024"
expectedValue := true
actualValue := IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "09-1-2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "9-01-2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "9-1-2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "12-31-9999"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "09/01/2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "09.01.2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "09/01'2024"
expectedValue = true
actualValue = IsValidMonthDayYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
}
func TestIsValidDayMonthYearLongDateFormat_ValidLongDateFormat(t *testing.T) {
datetime := "01-09-2024"
expectedValue := true
actualValue := IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "1-09-2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "01-9-2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "1-9-2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "31-12-9999"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "01/09/2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "01.09.2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
datetime = "01/09'2024"
expectedValue = true
actualValue = IsValidDayMonthYearLongOrShortDateFormat(datetime)
assert.Equal(t, expectedValue, actualValue)
}
+15
View File
@@ -28,6 +28,21 @@ const supportedImportFileTypes = [
anchor: 'how-to-get-firefly-iii-data-export-file' anchor: 'how-to-get-firefly-iii-data-export-file'
} }
}, },
{
type: 'qif_ymd',
name: 'Quicken Interchange Format (QIF) File (Year-month-day format)',
extensions: '.qif'
},
{
type: 'qif_mdy',
name: 'Quicken Interchange Format (QIF) File (Month-day-year format)',
extensions: '.qif'
},
{
type: 'qif_dmy',
name: 'Quicken Interchange Format (QIF) File (Day-month-year format)',
extensions: '.qif'
},
{ {
type: 'feidee_mymoney_csv', type: 'feidee_mymoney_csv',
name: 'Feidee MyMoney (App) Data Export File', name: 'Feidee MyMoney (App) Data Export File',
+4
View File
@@ -1126,6 +1126,7 @@
"invalid csv file": "Invalid CSV file", "invalid csv file": "Invalid CSV file",
"related id cannot be blank": "Related ID cannot be blank", "related id cannot be blank": "Related ID cannot be blank",
"found some transactions without related records": "There are some transactions which don't have related records", "found some transactions without related records": "There are some transactions which don't have related records",
"invalid qif file": "Invalid QIF file",
"query items cannot be blank": "There are no query items", "query items cannot be blank": "There are no query items",
"query items too much": "There are too many query items", "query items too much": "There are too many query items",
"query items have invalid item": "There is invalid item in query items", "query items have invalid item": "There is invalid item in query items",
@@ -1517,6 +1518,9 @@
"How to export this file?": "How to export this file?", "How to export this file?": "How to export this file?",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)", "ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)", "ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)",
"Quicken Interchange Format (QIF) File (Year-month-day format)": "Quicken Interchange Format (QIF) File (Year-month-day format)",
"Quicken Interchange Format (QIF) File (Month-day-year format)": "Quicken Interchange Format (QIF) File (Month-day-year format)",
"Quicken Interchange Format (QIF) File (Day-month-year format)": "Quicken Interchange Format (QIF) File (Day-month-year format)",
"Firefly III Data Export File": "Firefly III Data Export File", "Firefly III Data Export File": "Firefly III Data Export File",
"Feidee MyMoney (App) Data Export File": "Feidee MyMoney (App) Data Export File", "Feidee MyMoney (App) Data Export File": "Feidee MyMoney (App) Data Export File",
"Feidee MyMoney (Web) Data Export File": "Feidee MyMoney (Web) Data Export File", "Feidee MyMoney (Web) Data Export File": "Feidee MyMoney (Web) Data Export File",
+4
View File
@@ -1126,6 +1126,7 @@
"invalid csv file": "无效的 CSV 文件", "invalid csv file": "无效的 CSV 文件",
"related id cannot be blank": "关联Id不能为空", "related id cannot be blank": "关联Id不能为空",
"found some transactions without related records": "有一些交易没有关联记录", "found some transactions without related records": "有一些交易没有关联记录",
"invalid qif file": "无效的 QIF 文件",
"query items cannot be blank": "请求项目不能为空", "query items cannot be blank": "请求项目不能为空",
"query items too much": "请求项目过多", "query items too much": "请求项目过多",
"query items have invalid item": "请求项目中有非法项目", "query items have invalid item": "请求项目中有非法项目",
@@ -1517,6 +1518,9 @@
"How to export this file?": "如何导出该文件?", "How to export this file?": "如何导出该文件?",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)", "ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)", "ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)",
"Quicken Interchange Format (QIF) File (Year-month-day format)": "Quicken Interchange Format (QIF) 文件 (年-月-日 格式)",
"Quicken Interchange Format (QIF) File (Month-day-year format)": "Quicken Interchange Format (QIF) 文件 (月-日-年 格式)",
"Quicken Interchange Format (QIF) File (Day-month-year format)": "Quicken Interchange Format (QIF) 文件 (日-月-年 格式)",
"Firefly III Data Export File": "Firefly III 数据导出文件", "Firefly III Data Export File": "Firefly III 数据导出文件",
"Feidee MyMoney (App) Data Export File": "金蝶随手记 (App) 数据导出文件", "Feidee MyMoney (App) Data Export File": "金蝶随手记 (App) 数据导出文件",
"Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件", "Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件",