support importing transaction in frontend

This commit is contained in:
MaysWind
2024-09-09 01:31:43 +08:00
parent 3d5a03a629
commit 470a74f420
32 changed files with 1772 additions and 197 deletions
+242 -12
View File
@@ -1,11 +1,13 @@
package api
import (
"io"
"sort"
"strings"
orderedmap "github.com/wk8/go-ordered-map/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -23,12 +25,14 @@ const maximumPicturesCountOfTransaction = 10
type TransactionsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
transactions *services.TransactionService
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
transactionPictures *services.TransactionPictureService
accounts *services.AccountService
users *services.UserService
ezBookKeepingCsvConverter converters.TransactionDataConverter
ezBookKeepingTsvConverter converters.TransactionDataConverter
transactions *services.TransactionService
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
transactionPictures *services.TransactionPictureService
accounts *services.AccountService
users *services.UserService
}
// Initialize a transaction api singleton instance
@@ -40,12 +44,14 @@ var (
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container,
},
transactions: services.Transactions,
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
transactionPictures: services.TransactionPictures,
accounts: services.Accounts,
users: services.Users,
ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter,
ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter,
transactions: services.Transactions,
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
transactionPictures: services.TransactionPictures,
accounts: services.Accounts,
users: services.Users,
}
)
@@ -1004,6 +1010,230 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return true, nil
}
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
fileTypes := form.Value["fileType"]
if len(fileTypes) < 1 || fileTypes[0] == "" {
return nil, errs.ErrImportFileTypeIsEmpty
}
fileType := fileTypes[0]
var dataImporter converters.TransactionDataImporter
if fileType == "ezbookkeeping_csv" {
dataImporter = a.ezBookKeepingCsvConverter
} else if fileType == "ezbookkeeping_tsv" {
dataImporter = a.ezBookKeepingTsvConverter
} else {
return nil, errs.ErrImportFileTypeNotSupported
}
importFiles := form.File["file"]
if len(importFiles) < 1 {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] there is no import file in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoFilesUpload
}
if importFiles[0].Size < 1 {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrUploadedFileEmpty
}
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
log.Warnf(c, "[transactions.TransactionParseImportFileHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
return nil, errs.ErrExceedMaxUploadFileSize
}
importFile, err := importFiles[0].Open()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
fileData, err := io.ReadAll(importFile)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
accounts, err := a.accounts.GetAllAccountsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get accounts for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountNameMapByList(accounts)
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, user.Uid, 0, -1)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get categories for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
categoryMap := a.transactionCategories.GetCategoryNameMapByList(categories)
tags, err := a.transactionTags.GetAllTagsByUid(c, user.Uid)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to get tags for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagMap := a.transactionTags.GetTagNameMapByList(tags)
parsedTransactions, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, categoryMap, tagMap)
if err != nil {
log.BootErrorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse imported data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
parsedTransactionRespsList := parsedTransactions.ToImportTransactionResponseList()
if len(parsedTransactionRespsList) < 1 {
return nil, errs.ErrNoDataToImport
}
parsedTransactionResps := &models.ImportTransactionResponsePageWrapper{
Items: parsedTransactionRespsList,
TotalCount: int64(len(parsedTransactionRespsList)),
}
return parsedTransactionResps, nil
}
// TransactionImportHandler imports transactions by request parameters for current user
func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *errs.Error) {
var transactionImportReq models.TransactionImportRequest
err := c.ShouldBindJSON(&transactionImportReq)
if err != nil {
log.Warnf(c, "[transactions.TransactionImportHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && transactionImportReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId)
if found {
log.Infof(c, "[transactions.TransactionImportHandler] another \"%s\" transactions has been imported for user \"uid:%d\"", remark, uid)
count, err := utils.StringToInt(remark)
if err == nil {
return count, nil
}
}
}
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
tagIds, err := utils.StringArrayToInt64Array(transactionCreateReq.TagIds)
if err != nil {
log.Warnf(c, "[transactions.TransactionImportHandler] parse tag ids failed of transaction \"index:%d\", because %s", i, err.Error())
return nil, errs.ErrTransactionTagIdInvalid
}
if len(tagIds) > maximumTagsCountOfTransaction {
return nil, errs.ErrTransactionHasTooManyTags
}
if transactionCreateReq.Type < models.TRANSACTION_TYPE_MODIFY_BALANCE || transactionCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transactions.TransactionImportHandler] transaction type of transaction \"index:%d\" is invalid", i)
return nil, errs.ErrTransactionTypeInvalid
}
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
}
if transactionCreateReq.Type != models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.DestinationAccountId != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] non-transfer transaction \"index:%d\" destination account cannot be set", i)
return nil, errs.ErrTransactionDestinationAccountCannotBeSet
} else if transactionCreateReq.Type == models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.SourceAccountId == transactionCreateReq.DestinationAccountId {
log.Warnf(c, "[transactions.TransactionImportHandler] transfer transaction \"index:%d\" source account must not be destination account", i)
return nil, errs.ErrTransactionSourceAndDestinationIdCannotBeEqual
}
if transactionCreateReq.Type != models.TRANSACTION_TYPE_TRANSFER && transactionCreateReq.DestinationAmount != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] non-transfer transaction \"index:%d\" destination amount cannot be set", i)
return nil, errs.ErrTransactionDestinationAmountCannotBeSet
}
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionImportHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
newTransactions := make([]*models.Transaction, len(transactionImportReq.Transactions))
for i := 0; i < len(transactionImportReq.Transactions); i++ {
transactionCreateReq := transactionImportReq.Transactions[i]
transaction := a.createNewTransactionModel(uid, transactionCreateReq, c.ClientIP())
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, transactionCreateReq.UtcOffset)
if !transactionEditable {
return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
}
newTransactions[i] = transaction
}
err = a.transactions.BatchCreateTransactions(c, user.Uid, newTransactions)
count := len(newTransactions)
if err != nil {
log.Errorf(c, "[transactions.TransactionImportHandler] failed to import %d transactions for user \"uid:%d\", because %s", count, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transactions.TransactionImportHandler] user \"uid:%d\" has imported %d transactions successfully", uid, count)
a.SetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS, uid, transactionImportReq.ClientSessionId, utils.IntToString(count))
return count, nil
}
func (a *TransactionsApi) filterTransactions(c *core.WebContext, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account) []*models.Transaction {
finalTransactions := make([]*models.Transaction, 0, len(transactions))
+1 -1
View File
@@ -720,7 +720,7 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil
return errs.ErrOperationFailed
}
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions)
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions.ToTransactionsList())
if err != nil {
log.BootErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error())
@@ -82,7 +82,7 @@ func (c *DataTableTransactionDataConverter) buildExportedContent(ctx core.Contex
return nil
}
func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context, user *models.User, dataTable ImportedDataTable, 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) {
func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context, user *models.User, dataTable ImportedDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
if dataTable.DataRowCount() < 1 {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
return nil, nil, nil, nil, errs.ErrOperationFailed
@@ -127,7 +127,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
tagMap = make(map[string]*models.TransactionTag)
}
allNewTransactions := make(ImportedTransactionSlice, 0, dataTable.DataRowCount())
allNewTransactions := make(models.ImportedTransactionSlice, 0, dataTable.DataRowCount())
allNewAccounts := make([]*models.Account, 0)
allNewSubCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0)
@@ -177,6 +177,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
}
categoryId := int64(0)
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
@@ -186,7 +187,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
return nil, nil, nil, nil, err
}
subCategoryName := dataRow.GetData(subCategoryColumnIdx)
subCategoryName = dataRow.GetData(subCategoryColumnIdx)
if subCategoryName == "" {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] sub category type is empty in data row \"index:%d\" for user \"uid:%d\"", dataRowIndex, user.Uid)
@@ -211,30 +212,32 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
accountCurrency := user.DefaultCurrency
if accountCurrencyColumnExists {
accountCurrency = dataRow.GetData(accountCurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[accountCurrency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", accountCurrency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account, exists := accountMap[accountName]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = dataRow.GetData(accountCurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account = c.createNewAccountModel(user.Uid, accountName, currency)
account = c.createNewAccountModel(user.Uid, accountName, accountCurrency)
allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account
}
if accountCurrencyColumnExists {
if account.Currency != dataRow.GetData(accountCurrencyColumnIdx) {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", dataRow.GetData(accountCurrencyColumnIdx), dataRowIndex, account.Currency, user.Uid)
if account.Currency != accountCurrency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", accountCurrency, dataRowIndex, account.Currency, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
accountCurrency = account.Currency
}
amount, err := utils.ParseAmount(dataRow.GetData(amountColumnIdx))
@@ -246,39 +249,43 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
relatedAccountId := int64(0)
relatedAccountAmount := int64(0)
account2Name := ""
account2Currency := ""
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name := dataRow.GetData(account2ColumnIdx)
account2Name = dataRow.GetData(account2ColumnIdx)
if account2Name == "" {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 name is empty in data row \"index:%d\" for user \"uid:%d\"", dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrFormatInvalid
}
account2Currency = user.DefaultCurrency
if account2CurrencyColumnExists {
account2Currency = dataRow.GetData(account2CurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[account2Currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", account2Currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2, exists := accountMap[account2Name]
if !exists {
currency := user.DefaultCurrency
if accountCurrencyColumnExists {
currency = dataRow.GetData(account2CurrencyColumnIdx)
if _, ok := validators.AllCurrencyNames[currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2 = c.createNewAccountModel(user.Uid, account2Name, currency)
account2 = c.createNewAccountModel(user.Uid, account2Name, account2Currency)
allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2
}
if account2CurrencyColumnExists {
if account2.Currency != dataRow.GetData(account2CurrencyColumnIdx) {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", dataRow.GetData(account2CurrencyColumnIdx), dataRowIndex, account2.Currency, user.Uid)
if account2.Currency != account2Currency {
log.Errorf(ctx, "[data_table_transaction_data_converter.parseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", account2Currency, dataRowIndex, account2.Currency, user.Uid)
return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
account2Currency = account2.Currency
}
relatedAccountId = account2.AccountId
@@ -313,11 +320,14 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
}
}
if tagsColumnExists {
tagNames := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator)
var tagIds []string
var tagNames []string
for i := 0; i < len(tagNames); i++ {
tagName := tagNames[i]
if tagsColumnExists {
tagNameItems := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator)
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
if tagName == "" {
continue
@@ -330,6 +340,12 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
if tag != nil {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
tagNames = append(tagNames, tagName)
}
}
@@ -339,21 +355,30 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
description = dataRow.GetData(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",
transaction := &models.ImportTransaction{
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",
},
TagIds: tagIds,
OriginalCategoryName: subCategoryName,
OriginalSourceAccountName: accountName,
OriginalSourceAccountCurrency: accountCurrency,
OriginalDestinationAccountName: account2Name,
OriginalDestinationAccountCurrency: account2Currency,
OriginalTagNames: tagNames,
}
allNewTransactions = append(allNewTransactions, transaction)
@@ -73,7 +73,7 @@ func (c *ezBookKeepingTransactionDataPlainTextConverter) ToExportedContent(ctx c
}
// ParseImportedData returns the imported data by parsing the transaction plain text data
func (c *ezBookKeepingTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, 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) {
func (c *ezBookKeepingTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) {
dataTable, err := createNewezbookkeepingTransactionPlainTextDataTable(string(data), c.columnSeparator, c.lineSeparator)
if err != nil {
@@ -147,7 +147,9 @@ func createNewezbookkeepingTransactionPlainTextDataTable(content string, columnS
return nil, errs.ErrOperationFailed
}
headerLineItems := strings.Split(allLines[0], columnSeparator)
headerLine := allLines[0]
headerLine = strings.ReplaceAll(headerLine, "\r", "")
headerLineItems := strings.Split(headerLine, columnSeparator)
return &ezBookKeepingTransactionPlainTextDataTable{
columnSeparator: columnSeparator,
-29
View File
@@ -1,29 +0,0 @@
package converters
import "github.com/mayswind/ezbookkeeping/pkg/models"
// ImportedTransactionSlice represents the slice data structure of import transaction data
type ImportedTransactionSlice []*models.Transaction
// Len returns the count of items
func (s ImportedTransactionSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s ImportedTransactionSlice) 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 ImportedTransactionSlice) 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
}
@@ -1,53 +0,0 @@
package converters
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestImportTransactionSliceLess(t *testing.T) {
var transactionSlice ImportedTransactionSlice
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)
}
+1 -1
View File
@@ -14,7 +14,7 @@ type TransactionDataExporter interface {
// TransactionDataImporter defines the structure of transaction data importer
type TransactionDataImporter interface {
// ParseImportedData returns the imported data
ParseImportedData(ctx core.Context, 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)
ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error)
}
// TransactionDataConverter defines the structure of transaction data converter
@@ -11,4 +11,5 @@ const (
DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 6
)
+3
View File
@@ -22,6 +22,9 @@ var (
ErrParameterInvalid = NewNormalError(NormalSubcategoryGlobal, 12, http.StatusBadRequest, "parameter invalid")
ErrFormatInvalid = NewNormalError(NormalSubcategoryGlobal, 13, http.StatusBadRequest, "format invalid")
ErrNumberInvalid = NewNormalError(NormalSubcategoryGlobal, 14, http.StatusBadRequest, "number invalid")
ErrNoFilesUpload = NewNormalError(NormalSubcategoryGlobal, 15, http.StatusBadRequest, "no files uploaded")
ErrUploadedFileEmpty = NewNormalError(NormalSubcategoryGlobal, 16, http.StatusBadRequest, "uploaded file is empty")
ErrExceedMaxUploadFileSize = NewNormalError(NormalSubcategoryGlobal, 17, http.StatusBadRequest, "uploaded file size exceeds the maximum allowed size")
)
// GetParameterInvalidMessage returns specific error message for invalid parameter error
+7 -8
View File
@@ -4,12 +4,11 @@ import "net/http"
// Error codes related to transaction categories
var (
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
ErrImportFileTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 7, http.StatusBadRequest, "import file type not supported")
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
)
+3
View File
@@ -29,4 +29,7 @@ var (
ErrCannotUseHiddenTransactionTag = NewNormalError(NormalSubcategoryTransaction, 22, http.StatusBadRequest, "cannot use hidden transaction tag")
ErrTransactionHasTooManyTags = NewNormalError(NormalSubcategoryTransaction, 23, http.StatusBadRequest, "transaction has too many tags")
ErrTransactionHasTooManyPictures = NewNormalError(NormalSubcategoryTransaction, 24, http.StatusBadRequest, "transaction has too many pictures")
ErrImportFileTypeIsEmpty = NewSystemError(NormalSubcategoryTransaction, 25, http.StatusBadRequest, "import file type is empty")
ErrImportFileTypeNotSupported = NewSystemError(NormalSubcategoryTransaction, 26, http.StatusBadRequest, "import file type not supported")
ErrNoDataToImport = NewSystemError(NormalSubcategoryTransaction, 27, http.StatusBadRequest, "no data to import")
)
@@ -22,6 +22,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
buildBooleanSetting("p", config.EnableTransactionPictures),
buildBooleanSetting("s", config.EnableScheduledTransaction),
buildBooleanSetting("e", config.EnableDataExport),
buildBooleanSetting("i", config.EnableDataImport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
}
+145
View File
@@ -0,0 +1,145 @@
package models
import "github.com/mayswind/ezbookkeeping/pkg/utils"
// ImportTransaction represents the imported transaction data
type ImportTransaction struct {
*Transaction
TagIds []string
OriginalCategoryName string
OriginalSourceAccountName string
OriginalSourceAccountCurrency string
OriginalDestinationAccountName string
OriginalDestinationAccountCurrency string
OriginalTagNames []string
}
// ImportTransactionResponse represents a view-object of the imported transaction data
type ImportTransactionResponse struct {
Type TransactionType `json:"type"`
CategoryId int64 `json:"categoryId,string"`
OriginalCategoryName string `json:"originalCategoryName"`
Time int64 `json:"time"`
UtcOffset int16 `json:"utcOffset"`
SourceAccountId int64 `json:"sourceAccountId,string"`
OriginalSourceAccountName string `json:"originalSourceAccountName"`
OriginalSourceAccountCurrency string `json:"originalSourceAccountCurrency"`
DestinationAccountId int64 `json:"destinationAccountId,string,omitempty"`
OriginalDestinationAccountName string `json:"originalDestinationAccountName,omitempty"`
OriginalDestinationAccountCurrency string `json:"originalDestinationAccountCurrency,omitempty"`
SourceAmount int64 `json:"sourceAmount"`
DestinationAmount int64 `json:"destinationAmount,omitempty"`
TagIds []string `json:"tagIds"`
OriginalTagNames []string `json:"originalTagNames"`
Comment string `json:"comment"`
GeoLocation *TransactionGeoLocationResponse `json:"geoLocation,omitempty"`
}
// ImportTransactionResponsePageWrapper represents a response of imported transaction which contains items and count
type ImportTransactionResponsePageWrapper struct {
Items []*ImportTransactionResponse `json:"items"`
TotalCount int64 `json:"totalCount"`
}
// ToImportTransactionResponse returns the a view-objects according to imported transaction data
func (t ImportTransaction) ToImportTransactionResponse() *ImportTransactionResponse {
var transactionType TransactionType
if t.Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionType = TRANSACTION_TYPE_MODIFY_BALANCE
} else if t.Type == TRANSACTION_DB_TYPE_EXPENSE {
transactionType = TRANSACTION_TYPE_EXPENSE
} else if t.Type == TRANSACTION_DB_TYPE_INCOME {
transactionType = TRANSACTION_TYPE_INCOME
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT {
transactionType = TRANSACTION_TYPE_TRANSFER
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN {
transactionType = TRANSACTION_TYPE_TRANSFER
} else {
return nil
}
geoLocation := &TransactionGeoLocationResponse{}
if t.GeoLongitude != 0 || t.GeoLatitude != 0 {
geoLocation.Longitude = t.GeoLongitude
geoLocation.Latitude = t.GeoLatitude
} else {
geoLocation = nil
}
return &ImportTransactionResponse{
Type: transactionType,
CategoryId: t.CategoryId,
OriginalCategoryName: t.OriginalCategoryName,
Time: utils.GetUnixTimeFromTransactionTime(t.TransactionTime),
UtcOffset: t.TimezoneUtcOffset,
SourceAccountId: t.AccountId,
OriginalSourceAccountName: t.OriginalSourceAccountName,
OriginalSourceAccountCurrency: t.OriginalSourceAccountCurrency,
DestinationAccountId: t.RelatedAccountId,
OriginalDestinationAccountName: t.OriginalDestinationAccountName,
OriginalDestinationAccountCurrency: t.OriginalDestinationAccountCurrency,
SourceAmount: t.Amount,
DestinationAmount: t.RelatedAccountAmount,
TagIds: t.TagIds,
OriginalTagNames: t.OriginalTagNames,
Comment: t.Comment,
GeoLocation: geoLocation,
}
}
// ImportedTransactionSlice represents the slice data structure of import transaction data
type ImportedTransactionSlice []*ImportTransaction
// Len returns the count of items
func (s ImportedTransactionSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s ImportedTransactionSlice) 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 ImportedTransactionSlice) Less(i, j int) bool {
if s[i].Type != s[j].Type && (s[i].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE || s[j].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE) {
if s[i].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return true
} else if s[j].Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
return false
}
}
return s[i].TransactionTime < s[j].TransactionTime
}
// ToTransactionsList returns the a list of transactions
func (s ImportedTransactionSlice) ToTransactionsList() []*Transaction {
transactions := make([]*Transaction, s.Len())
for i := 0; i < s.Len(); i++ {
transactions[i] = s[i].Transaction
}
return transactions
}
// ToImportTransactionResponseList returns the a list of view-objects according to imported transaction data
func (s ImportedTransactionSlice) ToImportTransactionResponseList() []*ImportTransactionResponse {
transactionResps := make([]*ImportTransactionResponse, 0, s.Len())
for i := 0; i < s.Len(); i++ {
importedTransaction := s[i]
importedTransactionResp := importedTransaction.ToImportTransactionResponse()
if importedTransactionResp == nil {
continue
}
transactionResps = append(transactionResps, importedTransactionResp)
}
return transactionResps
}
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestImportTransactionSliceLess(t *testing.T) {
var transactionSlice ImportedTransactionSlice
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 1,
Type: TRANSACTION_DB_TYPE_EXPENSE,
TransactionTime: 1,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 2,
Type: TRANSACTION_DB_TYPE_INCOME,
TransactionTime: 2,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 3,
Type: TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 10,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 4,
Type: TRANSACTION_DB_TYPE_TRANSFER_IN,
TransactionTime: 3,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 5,
Type: TRANSACTION_DB_TYPE_MODIFY_BALANCE,
TransactionTime: 11,
},
})
transactionSlice = append(transactionSlice, &ImportTransaction{
Transaction: &Transaction{
TransactionId: 6,
Type: 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)
}
+6
View File
@@ -115,6 +115,12 @@ type TransactionModifyRequest struct {
GeoLocation *TransactionGeoLocationRequest `json:"geoLocation" binding:"omitempty"`
}
// TransactionImportRequest represents all parameters of transaction import request
type TransactionImportRequest struct {
Transactions []*TransactionCreateRequest `json:"transactions"`
ClientSessionId string `json:"clientSessionId"`
}
// TransactionCountRequest represents transaction count request
type TransactionCountRequest struct {
Type TransactionDbType `form:"type" binding:"min=0,max=4"`
+7 -1
View File
@@ -138,6 +138,8 @@ const (
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
defaultImportFileMaxSize uint32 = 10485760 // 10MB
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
)
@@ -282,7 +284,9 @@ type Config struct {
MaxAvatarFileSize uint32
// Data
EnableDataExport bool
EnableDataExport bool
EnableDataImport bool
MaxImportFileSize uint32
// Notification
AfterRegisterNotification NotificationConfig
@@ -768,6 +772,8 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str
func loadDataConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableDataExport = getConfigItemBoolValue(configFile, sectionName, "enable_export", false)
config.EnableDataImport = getConfigItemBoolValue(configFile, sectionName, "enable_import", false)
config.MaxImportFileSize = getConfigItemUint32Value(configFile, sectionName, "max_import_file_size", defaultImportFileMaxSize)
return nil
}