support importing transaction by csv/tsv file via command line

This commit is contained in:
MaysWind
2024-09-02 00:40:00 +08:00
parent 366311edbb
commit 7c59e8386e
27 changed files with 1496 additions and 208 deletions
+4 -1
View File
@@ -4,8 +4,11 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// DataConverter defines the structure of data exporter
// DataConverter defines the structure of data converter
type DataConverter interface {
// ToExportedContent returns the exported data
ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error)
// ParseImportedData returns the imported data
ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error)
}
+9 -4
View File
@@ -4,14 +4,19 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// EzBookKeepingCSVFileExporter defines the structure of CSV file exporter
type EzBookKeepingCSVFileExporter struct {
EzBookKeepingPlainFileExporter
// EzBookKeepingCSVFileConverter defines the structure of CSV file converter
type EzBookKeepingCSVFileConverter struct {
EzBookKeepingPlainFileConverter
}
const csvSeparator = ","
// ToExportedContent returns the exported CSV data
func (e *EzBookKeepingCSVFileExporter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
func (e *EzBookKeepingCSVFileConverter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
return e.toExportedContent(uid, csvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
}
// ParseImportedData parses transactions of ezbookkeeping CSV data
func (e *EzBookKeepingCSVFileConverter) ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
return e.parseImportedData(user, csvSeparator, data, defaultTimezoneOffset, accountMap, categoryMap, tagMap)
}
+325 -10
View File
@@ -2,15 +2,18 @@ package converters
import (
"fmt"
"sort"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// EzBookKeepingPlainFileExporter defines the structure of plain file exporter
type EzBookKeepingPlainFileExporter struct {
// EzBookKeepingPlainFileConverter defines the structure of plain file converter
type EzBookKeepingPlainFileConverter struct {
}
const lineSeparator = "\n"
@@ -20,7 +23,7 @@ const headerLine = "Time,Timezone,Type,Category,Sub Category,Account,Account Cur
const dataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" + lineSeparator
// toExportedContent returns the exported plain data
func (e *EzBookKeepingPlainFileExporter) toExportedContent(uid int64, separator string, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
func (e *EzBookKeepingPlainFileConverter) toExportedContent(uid int64, separator string, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
var ret strings.Builder
ret.Grow(len(transactions) * 100)
@@ -75,7 +78,270 @@ func (e *EzBookKeepingPlainFileExporter) toExportedContent(uid int64, separator
return []byte(ret.String()), nil
}
func (e *EzBookKeepingPlainFileExporter) getTransactionTypeName(transactionDbType models.TransactionDbType) string {
func (e *EzBookKeepingPlainFileConverter) parseImportedData(user *models.User, separator string, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
lines := strings.Split(string(data), lineSeparator)
if len(lines) < 2 {
return nil, nil, nil, nil, errs.ErrOperationFailed
}
headerLineItems := strings.Split(lines[0], separator)
headerItemMap := make(map[string]int)
for i := 0; i < len(headerLineItems); i++ {
headerItemMap[headerLineItems[i]] = i
}
timeColumnIdx, timeColumnExists := headerItemMap["Time"]
timezoneColumnIdx, timezoneColumnExists := headerItemMap["Timezone"]
typeColumnIdx, typeColumnExists := headerItemMap["Type"]
subCategoryColumnIdx, subCategoryColumnExists := headerItemMap["Sub Category"]
accountColumnIdx, accountColumnExists := headerItemMap["Account"]
accountCurrencyColumnIdx, accountCurrencyColumnExists := headerItemMap["Account Currency"]
amountColumnIdx, amountColumnExists := headerItemMap["Amount"]
account2ColumnIdx, account2ColumnExists := headerItemMap["Account2"]
account2CurrencyColumnIdx, account2CurrencyColumnExists := headerItemMap["Account2 Currency"]
amount2ColumnIdx, amount2ColumnExists := headerItemMap["Account2 Amount"]
geoLocationIdx, geoLocationExists := headerItemMap["Geographic Location"]
tagsColumnIdx, tagsColumnExists := headerItemMap["Tags"]
descriptionColumnIdx, descriptionColumnExists := headerItemMap["Description"]
if !timeColumnExists || !typeColumnExists || !subCategoryColumnExists ||
!accountColumnExists || !amountColumnExists || !account2ColumnExists || !amount2ColumnExists {
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
if accountMap == nil {
accountMap = make(map[string]*models.Account)
}
if categoryMap == nil {
categoryMap = make(map[string]*models.TransactionCategory)
}
if tagMap == nil {
tagMap = make(map[string]*models.TransactionTag)
}
allNewTransactions := make(ImportTransactionSlice, 0, len(lines))
allNewAccounts := make([]*models.Account, 0)
allNewSubCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0)
for i := 1; i < len(lines); i++ {
line := lines[i]
if len(line) < 1 {
continue
}
lineItems := strings.Split(line, separator)
if len(lineItems) < len(headerLineItems) {
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
timezoneOffset := defaultTimezoneOffset
if timezoneColumnExists {
transactionTimezone, err := utils.ParseFromTimezoneOffset(lineItems[timezoneColumnIdx])
if err != nil {
return nil, nil, nil, nil, err
}
timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone)
}
transactionTime, err := utils.ParseFromLongDateTime(lineItems[timeColumnIdx], timezoneOffset)
if err != nil {
return nil, nil, nil, nil, err
}
transactionDbType, err := e.getTransactionDbType(lineItems[typeColumnIdx])
if err != nil {
return nil, nil, nil, nil, err
}
categoryId := int64(0)
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := e.getTransactionCategoryType(transactionDbType)
if err != nil {
return nil, nil, nil, nil, err
}
subCategoryName := lineItems[subCategoryColumnIdx]
if subCategoryName == "" {
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
subCategory, exists := categoryMap[subCategoryName]
if !exists {
subCategory = e.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubCategories = append(allNewSubCategories, subCategory)
categoryMap[subCategoryName] = subCategory
}
categoryId = subCategory.CategoryId
}
accountName := lineItems[accountColumnIdx]
if accountName == "" {
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
account, exists := accountMap[accountName]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = lineItems[accountCurrencyColumnIdx]
if _, ok := validators.AllCurrencyNames[currency]; !ok {
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account = e.createNewAccountModel(user.Uid, accountName, currency)
allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account
}
if accountCurrencyColumnExists {
if account.Currency != lineItems[accountCurrencyColumnIdx] {
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
amount, err := utils.ParseAmount(lineItems[amountColumnIdx])
if err != nil {
return nil, nil, nil, nil, err
}
relatedAccountId := int64(0)
relatedAccountAmount := int64(0)
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name := lineItems[account2ColumnIdx]
if account2Name == "" {
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
account2, exists := accountMap[account2Name]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = lineItems[account2CurrencyColumnIdx]
if _, ok := validators.AllCurrencyNames[currency]; !ok {
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2 = e.createNewAccountModel(user.Uid, account2Name, currency)
allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2
}
if account2CurrencyColumnExists {
if account2.Currency != lineItems[account2CurrencyColumnIdx] {
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
relatedAccountId = account2.AccountId
relatedAccountAmount, err = utils.ParseAmount(lineItems[amount2ColumnIdx])
if err != nil {
return nil, nil, nil, nil, err
}
}
geoLongitude := float64(0)
geoLatitude := float64(0)
if geoLocationExists {
geoLocationItems := strings.Split(lineItems[geoLocationIdx], geoLocationSeparator)
if len(geoLocationItems) == 2 {
geoLongitude, err = utils.StringToFloat64(geoLocationItems[0])
if err != nil {
return nil, nil, nil, nil, err
}
geoLatitude, err = utils.StringToFloat64(geoLocationItems[1])
if err != nil {
return nil, nil, nil, nil, err
}
}
}
if tagsColumnExists {
tagNames := strings.Split(lineItems[tagsColumnIdx], transactionTagSeparator)
for i := 0; i < len(tagNames); i++ {
tagName := tagNames[i]
if tagName == "" {
continue
}
tag, exists := tagMap[tagName]
if !exists {
tag = e.createNewTransactionTagModel(user.Uid, tagName)
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
}
}
description := ""
if descriptionColumnExists {
description = lineItems[descriptionColumnIdx]
}
transaction := &models.Transaction{
Uid: user.Uid,
Type: transactionDbType,
CategoryId: categoryId,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
TimezoneUtcOffset: timezoneOffset,
AccountId: account.AccountId,
Amount: amount,
HideAmount: false,
RelatedAccountId: relatedAccountId,
RelatedAccountAmount: relatedAccountAmount,
Comment: description,
GeoLongitude: geoLongitude,
GeoLatitude: geoLatitude,
CreatedIp: "127.0.0.1",
}
allNewTransactions = append(allNewTransactions, transaction)
}
sort.Sort(allNewTransactions)
return allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, nil
}
func (e *EzBookKeepingPlainFileConverter) getTransactionTypeName(transactionDbType models.TransactionDbType) string {
if transactionDbType == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return "Balance Modification"
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
@@ -89,7 +355,33 @@ func (e *EzBookKeepingPlainFileExporter) getTransactionTypeName(transactionDbTyp
}
}
func (e *EzBookKeepingPlainFileExporter) getTransactionCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
func (e *EzBookKeepingPlainFileConverter) getTransactionDbType(transactionTypeName string) (models.TransactionDbType, error) {
if transactionTypeName == "Balance Modification" {
return models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, nil
} else if transactionTypeName == "Income" {
return models.TRANSACTION_DB_TYPE_INCOME, nil
} else if transactionTypeName == "Expense" {
return models.TRANSACTION_DB_TYPE_EXPENSE, nil
} else if transactionTypeName == "Transfer" {
return models.TRANSACTION_DB_TYPE_TRANSFER_OUT, nil
} else {
return 0, errs.ErrTransactionTypeInvalid
}
}
func (e *EzBookKeepingPlainFileConverter) getTransactionCategoryType(transactionType models.TransactionDbType) (models.TransactionCategoryType, error) {
if transactionType == models.TRANSACTION_DB_TYPE_INCOME {
return models.CATEGORY_TYPE_INCOME, nil
} else if transactionType == models.TRANSACTION_DB_TYPE_EXPENSE {
return models.CATEGORY_TYPE_EXPENSE, nil
} else if transactionType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
return models.CATEGORY_TYPE_TRANSFER, nil
} else {
return 0, errs.ErrTransactionTypeInvalid
}
}
func (e *EzBookKeepingPlainFileConverter) getTransactionCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if !exists {
@@ -109,7 +401,7 @@ func (e *EzBookKeepingPlainFileExporter) getTransactionCategoryName(categoryId i
return parentCategory.Name
}
func (e *EzBookKeepingPlainFileExporter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
func (e *EzBookKeepingPlainFileConverter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
@@ -119,7 +411,7 @@ func (e *EzBookKeepingPlainFileExporter) getTransactionSubCategoryName(categoryI
}
}
func (e *EzBookKeepingPlainFileExporter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string {
func (e *EzBookKeepingPlainFileConverter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
@@ -129,7 +421,7 @@ func (e *EzBookKeepingPlainFileExporter) getAccountName(accountId int64, account
}
}
func (e *EzBookKeepingPlainFileExporter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string {
func (e *EzBookKeepingPlainFileConverter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
@@ -139,7 +431,7 @@ func (e *EzBookKeepingPlainFileExporter) getAccountCurrency(accountId int64, acc
}
}
func (e *EzBookKeepingPlainFileExporter) getTags(transactionId int64, allTagIndexes map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
func (e *EzBookKeepingPlainFileConverter) getTags(transactionId int64, allTagIndexes map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
tagIndexes, exists := allTagIndexes[transactionId]
if !exists {
@@ -166,10 +458,33 @@ func (e *EzBookKeepingPlainFileExporter) getTags(transactionId int64, allTagInde
return ret.String()
}
func (e *EzBookKeepingPlainFileExporter) replaceDelimiters(text string, separator string) string {
func (e *EzBookKeepingPlainFileConverter) replaceDelimiters(text string, separator string) string {
text = strings.Replace(text, separator, " ", -1)
text = strings.Replace(text, "\r\n", " ", -1)
text = strings.Replace(text, "\n", " ", -1)
return text
}
func (e *EzBookKeepingPlainFileConverter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account {
return &models.Account{
Uid: uid,
Name: accountName,
Currency: currency,
}
}
func (e *EzBookKeepingPlainFileConverter) createNewTransactionCategoryModel(uid int64, categoryName string, transactionCategoryType models.TransactionCategoryType) *models.TransactionCategory {
return &models.TransactionCategory{
Uid: uid,
Name: categoryName,
Type: transactionCategoryType,
}
}
func (e *EzBookKeepingPlainFileConverter) createNewTransactionTagModel(uid int64, tagName string) *models.TransactionTag {
return &models.TransactionTag{
Uid: uid,
Name: tagName,
}
}
@@ -0,0 +1,365 @@
package converters
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestEzBookKeepingPlainFileConverterParseImportedData_MinimumValidData(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,\n"+
"2024-09-01 01:23:45,Income,Test Category,Test Account,0.12,,\n"+
"2024-09-01 12:34:56,Expense,Test Category2,Test Account,1.00,,\n"+
"2024-09-01 23:59:59,Transfer,Test Category3,Test Account,0.05,Test Account2,0.05"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 3, len(allNewSubCategories))
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, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type)
assert.Equal(t, int64(1725153825), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
assert.Equal(t, int64(1725235199), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
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, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubCategories[1].Uid)
assert.Equal(t, "Test Category2", allNewSubCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubCategories[2].Uid)
assert.Equal(t, "Test Category3", allNewSubCategories[2].Name)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01T12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"09/01/2024 12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Type,Test Category,Test Account,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidTimezone(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,+08:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725165296), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidTimezone(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Asia/Shanghai,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidSubCategoryName(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Expense,,Test Account,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAccountName(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Expense,Test Category,,123.45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,,123.45"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,Test Category,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category2,Test Account,USD,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category3,Test Account,CNY,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+
"2024-09-01 12:34:56,Transfer,Test Category3,Test Account2,CNY,1.23,Test Account,EUR,1.10"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Balance Modification,Test Category,Test Account,XXX,123.45,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+
"2024-09-01 01:23:45,Transfer,Test Category,Test Account,USD,123.45,Test Account2,XXX,123.45"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123 45,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,Test Account2,123 45"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidGeographicLocation(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,123.45 45.56"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 123.45, allNewTransactions[0].GeoLongitude)
assert.Equal(t, 45.56, allNewTransactions[0].GeoLatitude)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidGeographicLocation(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, float64(0), allNewTransactions[0].GeoLongitude)
assert.Equal(t, float64(0), allNewTransactions[0].GeoLatitude)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,a b"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1 "), 0, nil, nil, nil)
assert.NotNil(t, err)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseTag(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, allNewTags, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Tags\n"+
"2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,,foo;;bar.;#test;hello\tworld;;"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTags[0].Uid)
assert.Equal(t, "foo", allNewTags[0].Name)
assert.Equal(t, int64(1234567890), allNewTags[1].Uid)
assert.Equal(t, "bar.", allNewTags[1].Name)
assert.Equal(t, int64(1234567890), allNewTags[2].Uid)
assert.Equal(t, "#test", allNewTags[2].Name)
assert.Equal(t, int64(1234567890), allNewTags[3].Uid)
assert.Equal(t, "hello\tworld", allNewTags[3].Name)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_ParseDescription(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Description\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,foo bar\t#test"), 0, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment)
}
func TestEzBookKeepingPlainFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := &EzBookKeepingPlainFileConverter{}
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, err := converter.parseImportedData(user, ",", []byte(""), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,CNY,123.45,,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
_, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+
"2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil)
assert.NotNil(t, err)
}
+9 -4
View File
@@ -4,14 +4,19 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// EzBookKeepingTSVFileExporter defines the structure of TSV file exporter
type EzBookKeepingTSVFileExporter struct {
EzBookKeepingPlainFileExporter
// EzBookKeepingTSVFileConverter defines the structure of TSV file converter
type EzBookKeepingTSVFileConverter struct {
EzBookKeepingPlainFileConverter
}
const tsvSeparator = "\t"
// ToExportedContent returns the exported TSV data
func (e *EzBookKeepingTSVFileExporter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
func (e *EzBookKeepingTSVFileConverter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) {
return e.toExportedContent(uid, tsvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
}
// ParseImportedData parses transactions of ezbookkeeping TSV data
func (e *EzBookKeepingTSVFileConverter) ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
return e.parseImportedData(user, tsvSeparator, data, defaultTimezoneOffset, accountMap, categoryMap, tagMap)
}
+29
View File
@@ -0,0 +1,29 @@
package converters
import "github.com/mayswind/ezbookkeeping/pkg/models"
// ImportTransactionSlice represents the slice data structure of import transaction data
type ImportTransactionSlice []*models.Transaction
// Len returns the count of items
func (s ImportTransactionSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s ImportTransactionSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less reports whether the first item is less than the second one
func (s ImportTransactionSlice) Less(i, j int) bool {
if s[i].Type != s[j].Type && (s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE || s[j].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE) {
if s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return true
} else if s[j].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return false
}
}
return s[i].TransactionTime < s[j].TransactionTime
}
@@ -0,0 +1,53 @@
package converters
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestImportTransactionSliceLess(t *testing.T) {
var transactionSlice ImportTransactionSlice
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 1,
Type: models.TRANSACTION_DB_TYPE_EXPENSE,
TransactionTime: 1,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 2,
Type: models.TRANSACTION_DB_TYPE_INCOME,
TransactionTime: 2,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 3,
Type: models.TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 10,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 4,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_IN,
TransactionTime: 3,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 5,
Type: models.TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 11,
})
transactionSlice = append(transactionSlice, &models.Transaction{
TransactionId: 6,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_OUT,
TransactionTime: 4,
})
sort.Sort(transactionSlice)
assert.Equal(t, int64(3), transactionSlice[0].TransactionId)
assert.Equal(t, int64(5), transactionSlice[1].TransactionId)
assert.Equal(t, int64(1), transactionSlice[2].TransactionId)
assert.Equal(t, int64(2), transactionSlice[3].TransactionId)
assert.Equal(t, int64(4), transactionSlice[4].TransactionId)
assert.Equal(t, int64(6), transactionSlice[5].TransactionId)
}