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
+5
View File
@@ -317,6 +317,11 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler)) apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler)) apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
}
// Transaction Pictures // Transaction Pictures
if config.EnableTransactionPictures { if config.EnableTransactionPictures {
apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler)) apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler))
+6
View File
@@ -221,6 +221,12 @@ max_user_avatar_size = 1048576
# Set to true to allow users to export their data # Set to true to allow users to export their data
enable_export = true enable_export = true
# Set to true to allow users to import their data
enable_import = true
# Maximum allowed import file size (1 - 4294967295 bytes)
max_import_file_size = 10485760
[notification] [notification]
# Set to true to display custom notification in home page every time users register # Set to true to display custom notification in home page every time users register
enable_notification_after_register = false enable_notification_after_register = false
+242 -12
View File
@@ -1,11 +1,13 @@
package api package api
import ( import (
"io"
"sort" "sort"
"strings" "strings"
orderedmap "github.com/wk8/go-ordered-map/v2" 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/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -23,12 +25,14 @@ const maximumPicturesCountOfTransaction = 10
type TransactionsApi struct { type TransactionsApi struct {
ApiUsingConfig ApiUsingConfig
ApiUsingDuplicateChecker ApiUsingDuplicateChecker
transactions *services.TransactionService ezBookKeepingCsvConverter converters.TransactionDataConverter
transactionCategories *services.TransactionCategoryService ezBookKeepingTsvConverter converters.TransactionDataConverter
transactionTags *services.TransactionTagService transactions *services.TransactionService
transactionPictures *services.TransactionPictureService transactionCategories *services.TransactionCategoryService
accounts *services.AccountService transactionTags *services.TransactionTagService
users *services.UserService transactionPictures *services.TransactionPictureService
accounts *services.AccountService
users *services.UserService
} }
// Initialize a transaction api singleton instance // Initialize a transaction api singleton instance
@@ -40,12 +44,14 @@ var (
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{ ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
container: duplicatechecker.Container, container: duplicatechecker.Container,
}, },
transactions: services.Transactions, ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter,
transactionCategories: services.TransactionCategories, ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter,
transactionTags: services.TransactionTags, transactions: services.Transactions,
transactionPictures: services.TransactionPictures, transactionCategories: services.TransactionCategories,
accounts: services.Accounts, transactionTags: services.TransactionTags,
users: services.Users, 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 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 { 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)) 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 return errs.ErrOperationFailed
} }
err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions) err = l.transactions.BatchCreateTransactions(c, user.Uid, newTransactions.ToTransactionsList())
if err != nil { if err != nil {
log.BootErrorf(c, "[user_data.ImportTransaction] failed to create transaction, because %s", err.Error()) 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 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 { 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) 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 return nil, nil, nil, nil, errs.ErrOperationFailed
@@ -127,7 +127,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
tagMap = make(map[string]*models.TransactionTag) tagMap = make(map[string]*models.TransactionTag)
} }
allNewTransactions := make(ImportedTransactionSlice, 0, dataTable.DataRowCount()) allNewTransactions := make(models.ImportedTransactionSlice, 0, dataTable.DataRowCount())
allNewAccounts := make([]*models.Account, 0) allNewAccounts := make([]*models.Account, 0)
allNewSubCategories := make([]*models.TransactionCategory, 0) allNewSubCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0) allNewTags := make([]*models.TransactionTag, 0)
@@ -177,6 +177,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
} }
categoryId := int64(0) categoryId := int64(0)
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType) transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
@@ -186,7 +187,7 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
subCategoryName := dataRow.GetData(subCategoryColumnIdx) subCategoryName = dataRow.GetData(subCategoryColumnIdx)
if subCategoryName == "" { 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) 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 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] account, exists := accountMap[accountName]
if !exists { if !exists {
currency := user.DefaultCurrency account = c.createNewAccountModel(user.Uid, accountName, accountCurrency)
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)
allNewAccounts = append(allNewAccounts, account) allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account accountMap[accountName] = account
} }
if accountCurrencyColumnExists { if accountCurrencyColumnExists {
if account.Currency != dataRow.GetData(accountCurrencyColumnIdx) { 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\"", dataRow.GetData(accountCurrencyColumnIdx), dataRowIndex, account.Currency, user.Uid) 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 return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
} }
} else if exists {
accountCurrency = account.Currency
} }
amount, err := utils.ParseAmount(dataRow.GetData(amountColumnIdx)) amount, err := utils.ParseAmount(dataRow.GetData(amountColumnIdx))
@@ -246,39 +249,43 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
relatedAccountId := int64(0) relatedAccountId := int64(0)
relatedAccountAmount := int64(0) relatedAccountAmount := int64(0)
account2Name := ""
account2Currency := ""
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name := dataRow.GetData(account2ColumnIdx) account2Name = dataRow.GetData(account2ColumnIdx)
if account2Name == "" { 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) 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 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] account2, exists := accountMap[account2Name]
if !exists { if !exists {
currency := user.DefaultCurrency account2 = c.createNewAccountModel(user.Uid, account2Name, account2Currency)
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)
allNewAccounts = append(allNewAccounts, account2) allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2 accountMap[account2Name] = account2
} }
if account2CurrencyColumnExists { if account2CurrencyColumnExists {
if account2.Currency != dataRow.GetData(account2CurrencyColumnIdx) { 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\"", dataRow.GetData(account2CurrencyColumnIdx), dataRowIndex, account2.Currency, user.Uid) 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 return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
} }
} else if exists {
account2Currency = account2.Currency
} }
relatedAccountId = account2.AccountId relatedAccountId = account2.AccountId
@@ -313,11 +320,14 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
} }
} }
if tagsColumnExists { var tagIds []string
tagNames := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator) var tagNames []string
for i := 0; i < len(tagNames); i++ { if tagsColumnExists {
tagName := tagNames[i] tagNameItems := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator)
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
if tagName == "" { if tagName == "" {
continue continue
@@ -330,6 +340,12 @@ func (c *DataTableTransactionDataConverter) parseImportedData(ctx core.Context,
allNewTags = append(allNewTags, tag) allNewTags = append(allNewTags, tag)
tagMap[tagName] = 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) description = dataRow.GetData(descriptionColumnIdx)
} }
transaction := &models.Transaction{ transaction := &models.ImportTransaction{
Uid: user.Uid, Transaction: &models.Transaction{
Type: transactionDbType, Uid: user.Uid,
CategoryId: categoryId, Type: transactionDbType,
TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()), CategoryId: categoryId,
TimezoneUtcOffset: timezoneOffset, TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()),
AccountId: account.AccountId, TimezoneUtcOffset: timezoneOffset,
Amount: amount, AccountId: account.AccountId,
HideAmount: false, Amount: amount,
RelatedAccountId: relatedAccountId, HideAmount: false,
RelatedAccountAmount: relatedAccountAmount, RelatedAccountId: relatedAccountId,
Comment: description, RelatedAccountAmount: relatedAccountAmount,
GeoLongitude: geoLongitude, Comment: description,
GeoLatitude: geoLatitude, GeoLongitude: geoLongitude,
CreatedIp: "127.0.0.1", 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) 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 // 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) dataTable, err := createNewezbookkeepingTransactionPlainTextDataTable(string(data), c.columnSeparator, c.lineSeparator)
if err != nil { if err != nil {
@@ -147,7 +147,9 @@ func createNewezbookkeepingTransactionPlainTextDataTable(content string, columnS
return nil, errs.ErrOperationFailed 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{ return &ezBookKeepingTransactionPlainTextDataTable{
columnSeparator: columnSeparator, 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 // TransactionDataImporter defines the structure of transaction data importer
type TransactionDataImporter interface { type TransactionDataImporter interface {
// ParseImportedData returns the imported data // 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 // TransactionDataConverter defines the structure of transaction data converter
@@ -11,4 +11,5 @@ const (
DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3 DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4 DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5 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") ErrParameterInvalid = NewNormalError(NormalSubcategoryGlobal, 12, http.StatusBadRequest, "parameter invalid")
ErrFormatInvalid = NewNormalError(NormalSubcategoryGlobal, 13, http.StatusBadRequest, "format invalid") ErrFormatInvalid = NewNormalError(NormalSubcategoryGlobal, 13, http.StatusBadRequest, "format invalid")
ErrNumberInvalid = NewNormalError(NormalSubcategoryGlobal, 14, http.StatusBadRequest, "number 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 // 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 // Error codes related to transaction categories
var ( var (
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error") ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found") ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed") ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented") ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy") ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported") ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported") ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
ErrImportFileTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 7, http.StatusBadRequest, "import file type not supported")
) )
+3
View File
@@ -29,4 +29,7 @@ var (
ErrCannotUseHiddenTransactionTag = NewNormalError(NormalSubcategoryTransaction, 22, http.StatusBadRequest, "cannot use hidden transaction tag") ErrCannotUseHiddenTransactionTag = NewNormalError(NormalSubcategoryTransaction, 22, http.StatusBadRequest, "cannot use hidden transaction tag")
ErrTransactionHasTooManyTags = NewNormalError(NormalSubcategoryTransaction, 23, http.StatusBadRequest, "transaction has too many tags") ErrTransactionHasTooManyTags = NewNormalError(NormalSubcategoryTransaction, 23, http.StatusBadRequest, "transaction has too many tags")
ErrTransactionHasTooManyPictures = NewNormalError(NormalSubcategoryTransaction, 24, http.StatusBadRequest, "transaction has too many pictures") 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("p", config.EnableTransactionPictures),
buildBooleanSetting("s", config.EnableScheduledTransaction), buildBooleanSetting("s", config.EnableScheduledTransaction),
buildBooleanSetting("e", config.EnableDataExport), buildBooleanSetting("e", config.EnableDataExport),
buildBooleanSetting("i", config.EnableDataImport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)), 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"` 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 // TransactionCountRequest represents transaction count request
type TransactionCountRequest struct { type TransactionCountRequest struct {
Type TransactionDbType `form:"type" binding:"min=0,max=4"` Type TransactionDbType `form:"type" binding:"min=0,max=4"`
+7 -1
View File
@@ -138,6 +138,8 @@ const (
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
defaultImportFileMaxSize uint32 = 10485760 // 10MB
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
) )
@@ -282,7 +284,9 @@ type Config struct {
MaxAvatarFileSize uint32 MaxAvatarFileSize uint32
// Data // Data
EnableDataExport bool EnableDataExport bool
EnableDataImport bool
MaxImportFileSize uint32
// Notification // Notification
AfterRegisterNotification NotificationConfig 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 { func loadDataConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableDataExport = getConfigItemBoolValue(configFile, sectionName, "enable_export", false) 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 return nil
} }
+4 -4
View File
@@ -52,12 +52,12 @@ export default {
this.showState = true; this.showState = true;
if (isString(text)) { if (isString(text)) {
this.titleContent = this.$t(title); this.titleContent = this.$t(title, options);
this.textContent = this.$t(text); this.textContent = this.$t(text, options);
} else { } else {
this.titleContent = this.$t('global.app.title');
this.textContent = this.$t(title);
options = text; options = text;
this.titleContent = this.$t('global.app.title');
this.textContent = this.$t(title, options);
} }
if (options && options.color) { if (options && options.color) {
+13 -5
View File
@@ -2,8 +2,8 @@
<div class="d-flex" :style="`min-width: ${minWidth}px`" v-if="minWidth"></div> <div class="d-flex" :style="`min-width: ${minWidth}px`" v-if="minWidth"></div>
<v-slide-group class="slide-group-with-stepper mb-10 hidden-xs" show-arrows> <v-slide-group class="slide-group-with-stepper mb-10 hidden-xs" show-arrows>
<v-slide-group-item :key="idx" v-for="(step, idx) in steps"> <v-slide-group-item :key="idx" v-for="(step, idx) in steps">
<div class="cursor-pointer mx-1" <div class="mx-1"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx) }" :class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx), 'cursor-pointer': isClickable }"
@click="changeStep(step)"> @click="changeStep(step)">
<div class="d-flex align-center gap-x-2"> <div class="d-flex align-center gap-x-2">
<div class="d-flex align-center gap-2"> <div class="d-flex align-center gap-2">
@@ -23,8 +23,8 @@
</v-slide-group> </v-slide-group>
<v-slide-group class="slide-group-with-stepper mb-3 hidden-sm-and-up" direction="vertical"> <v-slide-group class="slide-group-with-stepper mb-3 hidden-sm-and-up" direction="vertical">
<v-slide-group-item :key="idx" v-for="(step, idx) in steps"> <v-slide-group-item :key="idx" v-for="(step, idx) in steps">
<div class="cursor-pointer mx-1 mb-3" <div class="mx-1 mb-3"
:class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx) }" :class="{ 'slide-group-step-active': isStepActive(step), 'slide-group-step-completed': isStepCompleted(idx), 'cursor-pointer': isClickable }"
@click="changeStep(step)"> @click="changeStep(step)">
<div class="d-flex align-center gap-x-2"> <div class="d-flex align-center gap-x-2">
<div class="d-flex align-center gap-2"> <div class="d-flex align-center gap-2">
@@ -48,14 +48,22 @@ export default {
props: [ props: [
'steps', 'steps',
'currentStep', 'currentStep',
'clickable',
'minWidth' 'minWidth'
], ],
emits: [ emits: [
'step:change' 'step:change'
], ],
computed: {
isClickable() {
return this.clickable !== 'false' && this.clickable !== false;
}
},
methods: { methods: {
changeStep(step) { changeStep(step) {
this.$emit('step:change', step.name); if (this.isClickable) {
this.$emit('step:change', step.name);
}
}, },
isStepActive(step) { isStepActive(step) {
return this.currentStep === step.name; return this.currentStep === step.name;
+15 -1
View File
@@ -1,5 +1,19 @@
const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp'; const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp';
const supportedImportFileTypes = [
{
type: 'ezbookkeeping_csv',
name: 'ezbookkeeping Data Export File (CSV)',
extensions: '.csv'
},
{
type: 'ezbookkeeping_tsv',
name: 'ezbookkeeping Data Export File (TSV)',
extensions: '.tsv'
}
];
export default { export default {
supportedImageExtensions: supportedImageExtensions supportedImageExtensions: supportedImageExtensions,
supportedImportFileTypes: supportedImportFileTypes
} }
+2
View File
@@ -14,6 +14,7 @@ import { VBtnToggle } from 'vuetify/components/VBtnToggle';
import { VCard, VCardActions, VCardItem, VCardSubtitle, VCardText, VCardTitle } from 'vuetify/components/VCard'; import { VCard, VCardActions, VCardItem, VCardSubtitle, VCardText, VCardTitle } from 'vuetify/components/VCard';
import { VCheckbox, VCheckboxBtn } from 'vuetify/components/VCheckbox'; import { VCheckbox, VCheckboxBtn } from 'vuetify/components/VCheckbox';
import { VChip } from 'vuetify/components/VChip'; import { VChip } from 'vuetify/components/VChip';
import { VDataTable } from 'vuetify/components/VDataTable';
import { VDialog } from 'vuetify/components/VDialog'; import { VDialog } from 'vuetify/components/VDialog';
import { VDivider } from 'vuetify/components/VDivider'; import { VDivider } from 'vuetify/components/VDivider';
import { VExpansionPanel, VExpansionPanelText, VExpansionPanelTitle, VExpansionPanels } from 'vuetify/components/VExpansionPanel'; import { VExpansionPanel, VExpansionPanelText, VExpansionPanelTitle, VExpansionPanels } from 'vuetify/components/VExpansionPanel';
@@ -131,6 +132,7 @@ const vuetify = createVuetify({
VCheckbox, VCheckbox,
VCheckboxBtn, VCheckboxBtn,
VChip, VChip,
VDataTable,
VDialog, VDialog,
VDivider, VDivider,
VExpansionPanel, VExpansionPanel,
+18
View File
@@ -7,6 +7,7 @@ import timezoneConstants from '@/consts/timezone.js';
import currencyConstants from '@/consts/currency.js'; import currencyConstants from '@/consts/currency.js';
import colorConstants from '@/consts/color.js'; import colorConstants from '@/consts/color.js';
import accountConstants from '@/consts/account.js'; import accountConstants from '@/consts/account.js';
import fileConstants from '@/consts/file.js';
import categoryConstants from '@/consts/category.js'; import categoryConstants from '@/consts/category.js';
import transactionConstants from '@/consts/transaction.js'; import transactionConstants from '@/consts/transaction.js';
import templateConstants from '@/consts/template.js'; import templateConstants from '@/consts/template.js';
@@ -1252,6 +1253,22 @@ function getAllDisplayExchangeRates(exchangeRatesData, translateFn) {
return availableExchangeRates; return availableExchangeRates;
} }
function getAllSupportedImportFileTypes(translateFn) {
const allSupportedImportFileTypes = [];
for (let i = 0; i < fileConstants.supportedImportFileTypes.length; i++) {
const fileType = fileConstants.supportedImportFileTypes[i];
allSupportedImportFileTypes.push({
type: fileType.type,
displayName: translateFn(fileType.name),
extensions: fileType.extensions
});
}
return allSupportedImportFileTypes;
}
function getEnableDisableOptions(translateFn) { function getEnableDisableOptions(translateFn) {
return [{ return [{
value: true, value: true,
@@ -1595,6 +1612,7 @@ export function i18nFunctions(i18nGlobal) {
getAllTransactionScheduledFrequencyTypes: () => getAllTransactionScheduledFrequencyTypes(i18nGlobal.t), getAllTransactionScheduledFrequencyTypes: () => getAllTransactionScheduledFrequencyTypes(i18nGlobal.t),
getAllTransactionDefaultCategories: (categoryType, locale) => getAllTransactionDefaultCategories(categoryType, locale, i18nGlobal.t), getAllTransactionDefaultCategories: (categoryType, locale) => getAllTransactionDefaultCategories(categoryType, locale, i18nGlobal.t),
getAllDisplayExchangeRates: (exchangeRatesData) => getAllDisplayExchangeRates(exchangeRatesData, i18nGlobal.t), getAllDisplayExchangeRates: (exchangeRatesData) => getAllDisplayExchangeRates(exchangeRatesData, i18nGlobal.t),
getAllSupportedImportFileTypes: () => getAllSupportedImportFileTypes(i18nGlobal.t),
getEnableDisableOptions: () => getEnableDisableOptions(i18nGlobal.t), getEnableDisableOptions: () => getEnableDisableOptions(i18nGlobal.t),
getCategorizedAccountsWithDisplayBalance: (allVisibleAccounts, showAccountBalance, defaultCurrency, settingsStore, userStore, exchangeRatesStore) => getCategorizedAccountsWithDisplayBalance(allVisibleAccounts, showAccountBalance, defaultCurrency, userStore, settingsStore, exchangeRatesStore, i18nGlobal.t), getCategorizedAccountsWithDisplayBalance: (allVisibleAccounts, showAccountBalance, defaultCurrency, settingsStore, userStore, exchangeRatesStore) => getCategorizedAccountsWithDisplayBalance(allVisibleAccounts, showAccountBalance, defaultCurrency, userStore, settingsStore, exchangeRatesStore, i18nGlobal.t),
joinMultiText: (textArray) => joinMultiText(textArray, i18nGlobal.t), joinMultiText: (textArray) => joinMultiText(textArray, i18nGlobal.t),
+4
View File
@@ -53,6 +53,10 @@ export function isDataExportingEnabled() {
return getServerSetting('e') === '1'; return getServerSetting('e') === '1';
} }
export function isDataImportingEnabled() {
return getServerSetting('i') === '1';
}
export function getMapProvider() { export function getMapProvider() {
return getServerSetting('m'); return getServerSetting('m');
} }
+12
View File
@@ -437,6 +437,18 @@ export default {
id id
}); });
}, },
parseImportTransaction: ({ fileType, importFile }) => {
return axios.postForm('v1/transactions/parse_import.json', {
fileType: fileType,
file: importFile
});
},
importTransactions: ({ transactions, clientSessionId }) => {
return axios.post('v1/transactions/import.json', {
transactions: transactions,
clientSessionId: clientSessionId
});
},
uploadTransactionPicture: ({ pictureFile, clientSessionId }) => { uploadTransactionPicture: ({ pictureFile, clientSessionId }) => {
return axios.postForm('v1/transaction/pictures/upload.json', { return axios.postForm('v1/transaction/pictures/upload.json', {
picture: pictureFile, picture: pictureFile,
+27 -2
View File
@@ -79,6 +79,9 @@
"everyMultiDaysOfWeek": "Every {days}", "everyMultiDaysOfWeek": "Every {days}",
"everyMultiDaysOfMonth": "Every {days} of month", "everyMultiDaysOfMonth": "Every {days} of month",
"youHaveAccounts": "You have recorded {count} accounts", "youHaveAccounts": "You have recorded {count} accounts",
"selectedCount": "Selected {count} of {totalCount}",
"confirmImportTransactions": "Are you sure you want to import {count} transactions?",
"importTransactionResult": "You have imported {count} transactions successfully.",
"accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.", "accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.",
"resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}" "resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}"
} }
@@ -970,7 +973,6 @@
"system is busy": "System is busy", "system is busy": "System is busy",
"not supported": "Not supported", "not supported": "Not supported",
"image type not supported": "Image type is not supported", "image type not supported": "Image type is not supported",
"import file type not supported": "Import file type is not supported",
"database operation failed": "Database operation failed", "database operation failed": "Database operation failed",
"SMTP server is not enabled": "SMTP server is not enabled", "SMTP server is not enabled": "SMTP server is not enabled",
"incomplete or incorrect submission": "Incomplete or incorrect submission", "incomplete or incorrect submission": "Incomplete or incorrect submission",
@@ -1068,6 +1070,9 @@
"cannot use hidden transaction tag": "You cannot use hidden transaction tag", "cannot use hidden transaction tag": "You cannot use hidden transaction tag",
"transaction has too many tags": "There are too many tags in this transaction", "transaction has too many tags": "There are too many tags in this transaction",
"transaction has too many pictures": "There are too many pictures in this transaction", "transaction has too many pictures": "There are too many pictures in this transaction",
"import file type is empty": "Import file type is empty",
"import file type not supported": "Import file type is not supported",
"no data to import": "No data to import",
"exceed the maximum size of transaction picture file": "The uploaded transaction picture exceeds the maximum allowed file size", "exceed the maximum size of transaction picture file": "The uploaded transaction picture exceeds the maximum allowed file size",
"transaction category id is invalid": "Transaction category ID is invalid", "transaction category id is invalid": "Transaction category ID is invalid",
"transaction category not found": "Transaction category is not found", "transaction category not found": "Transaction category is not found",
@@ -1106,7 +1111,10 @@
"query items have invalid item": "There is invalid item in query items", "query items have invalid item": "There is invalid item in query items",
"parameter invalid": "Parameter is invalid", "parameter invalid": "Parameter is invalid",
"format invalid": "Format is invalid", "format invalid": "Format is invalid",
"number invalid": "Number is invalid" "number invalid": "Number is invalid",
"no files uploaded": "No files uploaded",
"uploaded file is empty": "Uploaded file is empty",
"uploaded file size exceeds the maximum allowed size": "Uploaded file size exceeds the maximum allowed size"
}, },
"parameter": { "parameter": {
"id": "ID", "id": "ID",
@@ -1163,6 +1171,7 @@
"Close": "Close", "Close": "Close",
"Submit": "Submit", "Submit": "Submit",
"Add": "Add", "Add": "Add",
"Import": "Import",
"Apply": "Apply", "Apply": "Apply",
"Save": "Save", "Save": "Save",
"Save Changes": "Save Changes", "Save Changes": "Save Changes",
@@ -1253,6 +1262,9 @@
"Select All": "Select All", "Select All": "Select All",
"Select None": "Select None", "Select None": "Select None",
"Invert Selection": "Invert Selection", "Invert Selection": "Invert Selection",
"Select All in This Page": "Select All in This Page",
"Select None in This Page": "Select None in This Page",
"Invert Selection in This Page": "Invert Selection in This Page",
"Back": "Back", "Back": "Back",
"Load More": "Load More", "Load More": "Load More",
"No data": "No data", "No data": "No data",
@@ -1463,6 +1475,19 @@
"Cannot Initialize Map": "Cannot Initialize Map", "Cannot Initialize Map": "Cannot Initialize Map",
"Unsupported Map Provider": "Unsupported Map Provider", "Unsupported Map Provider": "Unsupported Map Provider",
"Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.", "Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.",
"Import Transactions": "Import Transactions",
"Upload File": "Upload File",
"Upload Transaction Data File": "Upload Transaction Data File",
"Check & Modify": "Check & Modify",
"Check and Modify Your Data": "Check and Modify Your Data",
"Data Import Completed": "Data Import Completed",
"File Type": "File Type",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)",
"Data File": "Data File",
"Click to select import file": "Click to select import file",
"No data to import": "No data to import",
"Unable to parse import file": "Unable to parse import file",
"Tags": "Tags", "Tags": "Tags",
"Your transaction description (optional)": "Your transaction description (optional)", "Your transaction description (optional)": "Your transaction description (optional)",
"Transaction category cannot be blank": "Transaction category cannot be blank", "Transaction category cannot be blank": "Transaction category cannot be blank",
+27 -2
View File
@@ -79,6 +79,9 @@
"everyMultiDaysOfWeek": "每{days}", "everyMultiDaysOfWeek": "每{days}",
"everyMultiDaysOfMonth": "每月{days}", "everyMultiDaysOfMonth": "每月{days}",
"youHaveAccounts": "您已经记录了 {count} 个账户", "youHaveAccounts": "您已经记录了 {count} 个账户",
"selectedCount": "已选择 {count} / {totalCount}",
"confirmImportTransactions": "您确定要导入 {count} 个交易?",
"importTransactionResult": "您已经成功导入 {count} 个交易。",
"accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。", "accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。",
"resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}" "resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}"
} }
@@ -970,7 +973,6 @@
"system is busy": "系统繁忙", "system is busy": "系统繁忙",
"not supported": "不支持", "not supported": "不支持",
"image type not supported": "图片类型不支持", "image type not supported": "图片类型不支持",
"import file type not supported": "导入文件类型不支持",
"database operation failed": "数据库操作失败", "database operation failed": "数据库操作失败",
"SMTP server is not enabled": "SMTP 服务器没有启用", "SMTP server is not enabled": "SMTP 服务器没有启用",
"incomplete or incorrect submission": "提交不完整或不正确", "incomplete or incorrect submission": "提交不完整或不正确",
@@ -1068,6 +1070,9 @@
"cannot use hidden transaction tag": "您不能使用隐藏的交易标签", "cannot use hidden transaction tag": "您不能使用隐藏的交易标签",
"transaction has too many tags": "交易中的标签过多", "transaction has too many tags": "交易中的标签过多",
"transaction has too many pictures": "交易中的图片过多", "transaction has too many pictures": "交易中的图片过多",
"import file type is empty": "导入文件类型为空",
"import file type not supported": "导入文件类型不支持",
"no data to import": "没有可以导入的数据",
"exceed the maximum size of transaction picture file": "上传的交易图片超出了允许的最大文件大小", "exceed the maximum size of transaction picture file": "上传的交易图片超出了允许的最大文件大小",
"transaction category id is invalid": "交易分类ID无效", "transaction category id is invalid": "交易分类ID无效",
"transaction category not found": "交易分类不存在", "transaction category not found": "交易分类不存在",
@@ -1106,7 +1111,10 @@
"query items have invalid item": "请求项目中有非法项目", "query items have invalid item": "请求项目中有非法项目",
"parameter invalid": "参数错误", "parameter invalid": "参数错误",
"format invalid": "格式错误", "format invalid": "格式错误",
"number invalid": "数字错误" "number invalid": "数字错误",
"no files uploaded": "没有上传文件",
"uploaded file is empty": "上传的文件为空",
"uploaded file size exceeds the maximum allowed size": "上传的文件大小超出了允许的最大大小"
}, },
"parameter": { "parameter": {
"id": "ID", "id": "ID",
@@ -1163,6 +1171,7 @@
"Close": "关闭", "Close": "关闭",
"Submit": "提交", "Submit": "提交",
"Add": "添加", "Add": "添加",
"Import": "导入",
"Apply": "应用", "Apply": "应用",
"Save": "保存", "Save": "保存",
"Save Changes": "保存修改", "Save Changes": "保存修改",
@@ -1253,6 +1262,9 @@
"Select All": "全部选择", "Select All": "全部选择",
"Select None": "全部不选", "Select None": "全部不选",
"Invert Selection": "反向选择", "Invert Selection": "反向选择",
"Select All in This Page": "本页全选",
"Select None in This Page": "本页不选",
"Invert Selection in This Page": "本页反选",
"Back": "返回", "Back": "返回",
"Load More": "加载更多", "Load More": "加载更多",
"No data": "没有数据", "No data": "没有数据",
@@ -1463,6 +1475,19 @@
"Cannot Initialize Map": "无法初始化地图", "Cannot Initialize Map": "无法初始化地图",
"Unsupported Map Provider": "不支持的地图提供方", "Unsupported Map Provider": "不支持的地图提供方",
"Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "请刷新页面并重试。如果仍然显示错误,请确保正确设置了服务器地图设置。", "Please refresh the page and try again. If the error persists, ensure that the server's map settings are correctly configured.": "请刷新页面并重试。如果仍然显示错误,请确保正确设置了服务器地图设置。",
"Import Transactions": "导入交易",
"Upload File": "上传文件",
"Upload Transaction Data File": "上传交易数据文件",
"Check & Modify": "检查及修改",
"Check and Modify Your Data": "检查及修改您的数据",
"Data Import Completed": "数据导入完成",
"File Type": "文件类型",
"ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)",
"ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)",
"Data File": "数据文件",
"Click to select import file": "点击选择导入文件",
"No data to import": "没有可以导入的数据",
"Unable to parse import file": "无法解析导入的文件",
"Tags": "标签", "Tags": "标签",
"Your transaction description (optional)": "你的交易描述 (可选)", "Your transaction description (optional)": "你的交易描述 (可选)",
"Transaction category cannot be blank": "交易分类不能为空", "Transaction category cannot be blank": "交易分类不能为空",
+110 -25
View File
@@ -292,6 +292,36 @@ function fillTransactionObject(state, transaction, currentUtcOffset) {
return transaction; return transaction;
} }
function buildBasicSubmitTransaction(transaction, dummyTime) {
const submitTransaction = {
type: transaction.type,
time: dummyTime ? getActualUnixTimeForStore(transaction.time, transaction.utcOffset, getBrowserTimezoneOffsetMinutes()) : transaction.time,
sourceAccountId: transaction.sourceAccountId,
sourceAmount: transaction.sourceAmount,
destinationAccountId: '0',
destinationAmount: 0,
hideAmount: transaction.hideAmount,
tagIds: transaction.tagIds,
comment: transaction.comment,
geoLocation: transaction.geoLocation,
utcOffset: transaction.utcOffset
};
if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
submitTransaction.categoryId = transaction.expenseCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Income) {
submitTransaction.categoryId = transaction.incomeCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
submitTransaction.categoryId = transaction.transferCategory;
submitTransaction.destinationAccountId = transaction.destinationAccountId;
submitTransaction.destinationAmount = transaction.destinationAmount;
} else {
return null;
}
return submitTransaction;
}
export const useTransactionsStore = defineStore('transactions', { export const useTransactionsStore = defineStore('transactions', {
state: () => ({ state: () => ({
transactionsFilter: { transactionsFilter: {
@@ -835,36 +865,16 @@ export const useTransactionsStore = defineStore('transactions', {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const exchangeRatesStore = useExchangeRatesStore(); const exchangeRatesStore = useExchangeRatesStore();
const submitTransaction = { const submitTransaction = buildBasicSubmitTransaction(transaction, true);
type: transaction.type,
time: getActualUnixTimeForStore(transaction.time, transaction.utcOffset, getBrowserTimezoneOffsetMinutes()), if (!submitTransaction) {
sourceAccountId: transaction.sourceAccountId, return Promise.reject('An error occurred');
sourceAmount: transaction.sourceAmount, }
destinationAccountId: '0',
destinationAmount: 0,
hideAmount: transaction.hideAmount,
tagIds: transaction.tagIds,
comment: transaction.comment,
geoLocation: transaction.geoLocation,
utcOffset: transaction.utcOffset
};
if (clientSessionId) { if (clientSessionId) {
submitTransaction.clientSessionId = clientSessionId; submitTransaction.clientSessionId = clientSessionId;
} }
if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
submitTransaction.categoryId = transaction.expenseCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Income) {
submitTransaction.categoryId = transaction.incomeCategory;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
submitTransaction.categoryId = transaction.transferCategory;
submitTransaction.destinationAccountId = transaction.destinationAccountId;
submitTransaction.destinationAmount = transaction.destinationAmount;
} else {
return Promise.reject('An error occurred');
}
if (transaction.pictures && transaction.pictures.length > 0) { if (transaction.pictures && transaction.pictures.length > 0) {
const pictureIds = []; const pictureIds = [];
@@ -1004,6 +1014,81 @@ export const useTransactionsStore = defineStore('transactions', {
}); });
}); });
}, },
parseImportTransaction({ fileType, importFile }) {
return new Promise((resolve, reject) => {
services.parseImportTransaction({ fileType, importFile }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to parse import file' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('Unable to parse import file', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to parse import file' });
} else {
reject(error);
}
});
});
},
importTransactions({ transactions, clientSessionId }) {
const submitTransactions = [];
if (transactions) {
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
if (transaction.type === transactionConstants.allTransactionTypes.Income) {
transaction.incomeCategory = transaction.categoryId;
} else if (transaction.type === transactionConstants.allTransactionTypes.Expense) {
transaction.expenseCategory = transaction.categoryId;
} else if (transaction.type === transactionConstants.allTransactionTypes.Transfer) {
transaction.transferCategory = transaction.categoryId;
}
const submitTransaction = buildBasicSubmitTransaction(transaction, false);
if (!submitTransaction) {
return Promise.reject('An error occurred');
}
submitTransactions.push(submitTransaction);
}
}
return new Promise((resolve, reject) => {
services.importTransactions({
transactions: submitTransactions,
clientSessionId: clientSessionId
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to import transactions' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('Unable to import transactions', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to import transactions' });
} else {
reject(error);
}
});
});
},
uploadTransactionPicture({ pictureFile, clientSessionId }) { uploadTransactionPicture({ pictureFile, clientSessionId }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
services.uploadTransactionPicture({ pictureFile, clientSessionId }).then(response => { services.uploadTransactionPicture({ pictureFile, clientSessionId }).then(response => {
+11
View File
@@ -143,6 +143,17 @@ input[type=number] {
cursor: default !important; cursor: default !important;
} }
.always-cursor-pointer,
.always-cursor-pointer.v-input.v-input--readonly input,
.always-cursor-pointer.v-input.v-input--readonly textarea,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-text-field__prefix,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-text-field__suffix,
.always-cursor-pointer.v-input.v-input--readonly .v-field .v-field__input,
.always-cursor-pointer.v-input.v-input--readonly .v-field.v-field,
.always-cursor-pointer.v-input.v-input--readonly .cursor-pointer {
cursor: pointer !important;
}
.v-table { .v-table {
th { th {
background: rgb(var(--v-table-header-background)) !important; background: rgb(var(--v-table-header-background)) !important;
@@ -53,6 +53,11 @@
</v-list> </v-list>
</v-menu> </v-menu>
</v-btn> </v-btn>
<v-btn class="ml-3" color="default" variant="outlined"
:disabled="loading" @click="importTransaction"
v-if="isDataImportingEnabled">
{{ $t('Import') }}
</v-btn>
<v-btn density="compact" color="default" variant="text" size="24" <v-btn density="compact" color="default" variant="text" size="24"
class="ml-2" :icon="true" :loading="loading" @click="reload"> class="ml-2" :icon="true" :loading="loading" @click="reload">
<template #loader> <template #loader>
@@ -494,6 +499,7 @@
v-model:show="showCustomDateRangeDialog" v-model:show="showCustomDateRangeDialog"
@dateRange:change="changeCustomDateFilter" /> @dateRange:change="changeCustomDateFilter" />
<edit-dialog ref="editDialog" type="transaction" :persistent="true" /> <edit-dialog ref="editDialog" type="transaction" :persistent="true" />
<import-dialog ref="importDialog" :persistent="true" />
<v-dialog width="800" v-model="showFilterAccountDialog"> <v-dialog width="800" v-model="showFilterAccountDialog">
<account-filter-settings-card type="transactionListCurrent" :dialog-mode="true" <account-filter-settings-card type="transactionListCurrent" :dialog-mode="true"
@@ -516,6 +522,7 @@
<script> <script>
import EditDialog from './list/dialogs/EditDialog.vue'; import EditDialog from './list/dialogs/EditDialog.vue';
import ImportDialog from './list/dialogs/ImportDialog.vue';
import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue'; import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue';
import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue'; import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue';
import TransactionTagFilterSettingsCard from '@/views/desktop/common/cards/TransactionTagFilterSettingsCard.vue'; import TransactionTagFilterSettingsCard from '@/views/desktop/common/cards/TransactionTagFilterSettingsCard.vue';
@@ -562,6 +569,7 @@ import {
} from '@/lib/category.js'; } from '@/lib/category.js';
import { getUnifiedSelectedAccountsCurrencyOrDefaultCurrency } from '@/lib/account.js'; import { getUnifiedSelectedAccountsCurrencyOrDefaultCurrency } from '@/lib/account.js';
import { getTransactionDisplayAmount } from '@/lib/transaction.js'; import { getTransactionDisplayAmount } from '@/lib/transaction.js';
import { isDataImportingEnabled } from '@/lib/server_settings.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js'; import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import { import {
@@ -585,6 +593,7 @@ export default {
components: { components: {
TransactionTagFilterSettingsCard, TransactionTagFilterSettingsCard,
EditDialog, EditDialog,
ImportDialog,
AccountFilterSettingsCard, AccountFilterSettingsCard,
CategoryFilterSettingsCard CategoryFilterSettingsCard
}, },
@@ -658,6 +667,9 @@ export default {
return true; return true;
}, },
isDataImportingEnabled() {
return isDataImportingEnabled();
},
currentTimezoneOffsetMinutes() { currentTimezoneOffsetMinutes() {
return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone); return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone);
}, },
@@ -1376,6 +1388,21 @@ export default {
} }
}); });
}, },
importTransaction() {
const self = this;
self.$refs.importDialog.open().then(result => {
if (result && result.message) {
self.$refs.snackbar.showMessage(result.message);
}
self.reload(false);
}).catch(error => {
if (error) {
self.$refs.snackbar.showError(error);
}
});
},
show(transaction) { show(transaction) {
const self = this; const self = this;
@@ -0,0 +1,931 @@
<template>
<v-dialog width="1000" :persistent="!!persistent" v-model="showState">
<v-card class="pa-6 pa-sm-10 pa-md-12">
<template #title>
<div class="d-flex align-center justify-center">
<div class="d-flex w-100 align-center justify-center">
<h4 class="text-h4">{{ $t('Import Transactions') }}</h4>
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</div>
</div>
</template>
<div class="mt-4 cursor-default">
<steps-bar min-width="700" :clickable="false" :steps="allSteps" :current-step="currentStep" />
</div>
<v-window class="disable-tab-transition" v-model="currentStep">
<v-window-item value="uploadFile">
<v-row>
<v-col cols="12" md="12">
<v-select
item-title="displayName"
item-value="type"
:disabled="submitting"
:label="$t('File Type')"
:placeholder="$t('File Type')"
:items="allSupportedImportFileTypes"
v-model="fileType"
/>
</v-col>
<v-col cols="12" md="12">
<v-text-field
readonly
persistent-placeholder
type="text"
class="always-cursor-pointer"
:disabled="submitting"
:label="$t('Data File')"
:placeholder="$t('Click to select import file')"
v-model="fileName"
@click="showOpenFileDialog"
/>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="checkData">
<v-data-table
fixed-header
fixed-footer
show-select
show-expand
class="import-transaction-table"
density="compact"
item-value="index"
:height="importTransactionsTableHeight"
:headers="importTransactionHeaders"
:items="importTransactions"
:no-data-text="$t('No data to import')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
v-model:expanded="expandedTransactions"
>
<template #header.data-table-select>
<v-checkbox readonly class="cursor-pointer"
density="compact" width="28"
:indeterminate="anyButNotAllTransactionSelected"
v-model="allTransactionSelected"
>
<v-menu activator="parent" location="bottom">
<v-list>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All')"
@click="selectAll"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None')"
@click="selectNone"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection')"
@click="selectInvert"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="icons.selectAll"
:title="$t('Select All in This Page')"
@click="selectAllInThisPage"></v-list-item>
<v-list-item :prepend-icon="icons.selectNone"
:title="$t('Select None in This Page')"
@click="selectNoneInThisPage"></v-list-item>
<v-list-item :prepend-icon="icons.selectInverse"
:title="$t('Invert Selection in This Page')"
@click="selectInvertInThisPage"></v-list-item>
</v-list>
</v-menu>
</v-checkbox>
</template>
<template #item.data-table-select="{ item }">
<v-checkbox density="compact"
:disabled="!item.valid"
v-model="item.selected"></v-checkbox>
</template>
<template #item.data-table-expand="{ item, internalItem, toggleExpand }">
<v-icon size="small" :class="{ 'text-error': !item.valid }"
:icon="icons.edit" @click="toggleExpand(internalItem)">
</v-icon>
<v-tooltip activator="parent">{{ $t('Edit') }}</v-tooltip>
</template>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ml-1" variant="flat" color="secondary" size="x-small"
v-if="item.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(item) }}</v-chip>
</template>
<template #item.type="{ value }">
<v-chip label color="secondary" variant="outlined" size="x-small" v-if="value === allTransactionTypes.ModifyBalance">{{ $t('Modify Balance') }}</v-chip>
<v-chip label class="text-income" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Income">{{ $t('Income') }}</v-chip>
<v-chip label class="text-expense" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Expense">{{ $t('Expense') }}</v-chip>
<v-chip label color="primary" variant="outlined" size="x-small" v-else-if="value === allTransactionTypes.Transfer">{{ $t('Transfer') }}</v-chip>
<v-chip label color="default" variant="outlined" size="x-small" v-else>{{ $t('Unknown') }}</v-chip>
</template>
<template #item.categoryId="{ item }">
<div class="d-flex align-center">
<span v-if="item.type === allTransactionTypes.ModifyBalance">-</span>
<ItemIcon size="24px" icon-type="category"
:icon-id="allCategoriesMap[item.categoryId].icon"
:color="allCategoriesMap[item.categoryId].color"
v-if="item.type !== allTransactionTypes.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]"></ItemIcon>
<span class="ml-2" v-if="item.type !== allTransactionTypes.ModifyBalance && item.categoryId && item.categoryId !== '0' && allCategoriesMap[item.categoryId]">
{{ allCategoriesMap[item.categoryId].name }}
</span>
<div class="text-error font-italic" v-else-if="item.type !== allTransactionTypes.ModifyBalance && (!item.categoryId || item.categoryId === '0' || !allCategoriesMap[item.categoryId])">
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalCategoryName }}</span>
</div>
</div>
</template>
<template #item.sourceAmount="{ item }">
<span>{{ getTransactionDisplayAmount(item) }}</span>
<v-icon class="mx-1" size="13" :icon="icons.arrowRight" v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId"></v-icon>
<span v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId">{{ getTransactionDisplayDestinationAmount(item) }}</span>
</template>
<template #item.sourceAccountId="{ item }">
<div class="d-flex align-center">
<span v-if="item.sourceAccountId && item.sourceAccountId !== '0' && allAccountsMap[item.sourceAccountId]">{{ allAccountsMap[item.sourceAccountId].name }}</span>
<div class="text-error font-italic" v-else>
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalSourceAccountName }}</span>
</div>
<v-icon class="mx-1" size="13" :icon="icons.arrowRight" v-if="item.type === allTransactionTypes.Transfer && item.sourceAccountId !== item.destinationAccountId"></v-icon>
<span v-if="item.type === allTransactionTypes.Transfer && item.destinationAccountId && item.destinationAccountId !== '0' && allAccountsMap[item.destinationAccountId]">{{allAccountsMap[item.destinationAccountId].name }}</span>
<div class="text-error font-italic" v-else-if="item.type === allTransactionTypes.Transfer && (!item.destinationAccountId || item.destinationAccountId === '0' || !allAccountsMap[item.destinationAccountId])">
<v-icon class="mr-1" :icon="icons.alert"/>
<span>{{ item.originalDestinationAccountName }}</span>
</div>
</div>
</template>
<template #item.geoLocation="{ item }">
<span class="cursor-pointer" v-if="item.geoLocation">{{ `(${item.geoLocation.longitude}, ${item.geoLocation.latitude})` }}</span>
<span class="cursor-pointer" v-else-if="!item.geoLocation">{{ $t('None') }}</span>
</template>
<template #item.tagIds="{ item }">
<v-chip class="transaction-tag" size="small"
:class="{ 'font-italic': !tagId || tagId === '0' || !allTagsMap[tagId] }"
:prepend-icon="tagId && tagId !== '0' && allTagsMap[tagId] ? icons.tag : icons.alert"
:color="tagId && tagId !== '0' && allTagsMap[tagId] ? 'default' : 'error'"
:text="tagId && tagId !== '0' && allTagsMap[tagId] ? allTagsMap[tagId].name : item.originalTagNames[index]"
:key="tagId"
v-for="(tagId, index) in item.tagIds"/>
<v-chip class="transaction-tag" size="small"
:text="$t('None')"
v-if="!item.tagIds || !item.tagIds.length"/>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length">
<v-row class="py-4" style="width: 400px">
<v-col cols="12" v-if="item.type === allTransactionTypes.Expense">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableExpenseCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Expense])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Expense])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Expense]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Income">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableIncomeCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Income])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Income])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Income]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Transfer">
<two-column-select primary-key-field="id" primary-value-field="id" primary-title-field="name"
primary-icon-field="icon" primary-icon-type="category" primary-color-field="color"
primary-hidden-field="hidden" primary-sub-items-field="subCategories"
secondary-key-field="id" secondary-value-field="id" secondary-title-field="name"
secondary-icon-field="icon" secondary-icon-type="category" secondary-color-field="color"
secondary-hidden-field="hidden"
:disabled="loading || submitting || !hasAvailableTransferCategories"
:show-selection-primary-text="true"
:custom-selection-primary-text="getPrimaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Transfer])"
:custom-selection-secondary-text="getSecondaryCategoryName(item.categoryId, allCategories[allCategoryTypes.Transfer])"
:label="$t('Category')" :placeholder="$t('Category')"
:items="allCategories[allCategoryTypes.Transfer]"
v-model="item.categoryId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12">
<two-column-select primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="loading || submitting || !allVisibleAccounts.length"
:custom-selection-primary-text="getSourceAccountDisplayName(item)"
:label="getSourceAccountTitle(item)"
:placeholder="getSourceAccountTitle(item)"
:items="categorizedAccounts"
v-model="item.sourceAccountId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12" v-if="item.type === allTransactionTypes.Transfer">
<two-column-select primary-key-field="id" primary-value-field="category"
primary-title-field="name" primary-footer-field="displayBalance"
primary-icon-field="icon" primary-icon-type="account"
primary-sub-items-field="accounts"
:primary-title-i18n="true"
secondary-key-field="id" secondary-value-field="id"
secondary-title-field="name" secondary-footer-field="displayBalance"
secondary-icon-field="icon" secondary-icon-type="account" secondary-color-field="color"
:disabled="loading || submitting || !allVisibleAccounts.length"
:custom-selection-primary-text="getDestinationAccountDisplayName(item)"
:label="$t('Destination Account')"
:placeholder="$t('Destination Account')"
:items="categorizedAccounts"
v-model="item.destinationAccountId"
@update:model-value="updateTransactionData(item)">
</two-column-select>
</v-col>
<v-col cols="12">
<v-autocomplete
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
:disabled="loading || submitting"
:label="$t('Tags')"
:placeholder="$t('None')"
:items="allTags"
:no-data-text="$t('No available tag')"
v-model="item.tagIds"
@update:model-value="updateTransactionData(item)"
>
<template #chip="{ props, index }">
<v-chip :class="{ 'font-italic': !isTagValid(item, index) }"
:prepend-icon="isTagValid(item, index) ? icons.tag : icons.alert"
:color="isTagValid(item, index) ? 'default' : 'error'"
:text="isTagValid(item, index) ? allTagsMap[item.tagIds[index]].name : item.originalTagNames[index]"
v-bind="props"/>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="!item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="icons.tag"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-col>
</v-row>
</td>
</tr>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2"
v-if="importTransactions && importTransactions.length > 10">
<span>{{ $t('format.misc.selectedCount', { count: selectedImportTransactionCount, totalCount: importTransactions.length }) }}</span>
<v-spacer/>
<span>{{ $t('Transactions Per Page') }}</span>
<v-select class="ml-2" density="compact" max-width="100"
item-title="title"
item-value="value"
:disabled="loading"
:items="importTransactionsTablePageOptions"
v-model="countPerPage"
/>
<v-pagination density="compact"
:total-visible="6"
:length="totalPageCount"
v-model="currentPage"></v-pagination>
</div>
</template>
</v-data-table>
</v-window-item>
<v-window-item value="finalResult">
<h4 class="text-h4 mb-1">{{ $t('Data Import Completed') }}</h4>
<p class="my-5">{{ $t('format.misc.importTransactionResult', { count: importedCount }) }}</p>
</v-window-item>
</v-window>
<div class="d-flex justify-sm-space-between gap-4 flex-wrap justify-center mt-10">
<v-btn color="secondary" variant="tonal" :disabled="loading || submitting"
:prepend-icon="icons.previous" @click="cancel(false)"
v-if="currentStep !== 'finalResult'">{{ $t('Cancel') }}</v-btn>
<v-btn color="primary" :disabled="loading || submitting || !importFile"
:append-icon="!submitting ? icons.next : null" @click="parseData"
v-if="currentStep === 'uploadFile'">
{{ $t('Next') }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="teal" :disabled="submitting || selectedImportTransactionCount < 1"
:append-icon="!submitting ? icons.next : null" @click="submit"
v-if="currentStep === 'checkData'">
{{ $t('Import') }}
<v-progress-circular indeterminate size="22" class="ml-2" v-if="submitting"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal"
:append-icon="icons.complete"
@click="cancel(true)"
v-if="currentStep === 'finalResult'">{{ $t('Close') }}</v-btn>
</div>
</v-card>
</v-dialog>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
<input ref="fileInput" type="file" style="display: none" :accept="supportedImportFileExtensions" @change="setImportFile($event)" />
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useAccountsStore } from '@/stores/account.js';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import { useTransactionTagsStore } from '@/stores/transactionTag.js';
import { useTransactionsStore } from '@/stores/transaction.js';
import { useOverviewStore } from '@/stores/overview.js';
import { useStatisticsStore } from '@/stores/statistics.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import categoryConstants from '@/consts/category.js';
import transactionConstants from '@/consts/transaction.js';
import { getNameByKeyValue } from '@/lib/common.js';
import { generateRandomUUID } from '@/lib/misc.js';
import logger from '@/lib/logger.js';
import {
parseDateFromUnixTime,
getUnixTime,
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes
} from '@/lib/datetime.js';
import {
getTransactionPrimaryCategoryName,
getTransactionSecondaryCategoryName,
getFirstAvailableCategoryId
} from '@/lib/category.js';
import {
mdiClose,
mdiArrowRight,
mdiCheck,
mdiSelectAll,
mdiSelect,
mdiSelectInverse,
mdiPencilOutline,
mdiAlertOutline,
mdiPound
} from '@mdi/js';
export default {
props: [
'persistent',
'show'
],
expose: [
'open'
],
data() {
return {
showState: false,
clientSessionId: '',
currentStep: 'uploadFile',
fileType: 'ezbookkeeping_csv',
importFile: null,
importTransactions: null,
expandedTransactions: [],
currentPage: 1,
countPerPage: 10,
importedCount: null,
loading: true,
submitting: false,
resolve: null,
reject: null,
icons: {
previous: mdiClose,
next: mdiArrowRight,
complete: mdiCheck,
select: mdiSelect,
selectAll: mdiSelectAll,
selectNone: mdiSelect,
selectInverse: mdiSelectInverse,
edit: mdiPencilOutline,
arrowRight: mdiArrowRight,
alert: mdiAlertOutline,
tag: mdiPound
}
}
},
computed: {
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useTransactionTagsStore, useTransactionsStore, useOverviewStore, useStatisticsStore, useExchangeRatesStore),
defaultCurrency() {
return this.userStore.currentUserDefaultCurrency;
},
allSteps() {
return [
{
name: 'uploadFile',
title: this.$t('Upload File'),
subTitle: this.$t('Upload Transaction Data File')
},
{
name: 'checkData',
title: this.$t('Check & Modify'),
subTitle: this.$t('Check and Modify Your Data')
},
{
name: 'finalResult',
title: this.$t('Complete'),
subTitle: this.$t('Data Import Completed')
}
];
},
allSupportedImportFileTypes() {
return this.$locale.getAllSupportedImportFileTypes();
},
allTransactionTypes() {
return transactionConstants.allTransactionTypes;
},
allCategoryTypes() {
return categoryConstants.allCategoryTypes;
},
allAccounts() {
return this.accountsStore.allPlainAccounts;
},
allVisibleAccounts() {
return this.accountsStore.allVisiblePlainAccounts;
},
categorizedAccounts() {
return this.$locale.getCategorizedAccountsWithDisplayBalance(this.allVisibleAccounts, this.showAccountBalance, this.defaultCurrency, this.settingsStore, this.userStore, this.exchangeRatesStore);
},
allAccountsMap() {
return this.accountsStore.allAccountsMap;
},
allCategories() {
return this.transactionCategoriesStore.allTransactionCategories;
},
allCategoriesMap() {
return this.transactionCategoriesStore.allTransactionCategoriesMap;
},
allTags() {
return this.transactionTagsStore.allTransactionTags;
},
allTagsMap() {
return this.transactionTagsStore.allTransactionTagsMap;
},
hasAvailableExpenseCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Expense] || !this.allCategories[this.allCategoryTypes.Expense].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Expense]);
return firstAvailableCategoryId !== '';
},
hasAvailableIncomeCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Income] || !this.allCategories[this.allCategoryTypes.Income].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Income]);
return firstAvailableCategoryId !== '';
},
hasAvailableTransferCategories() {
if (!this.allCategories || !this.allCategories[this.allCategoryTypes.Transfer] || !this.allCategories[this.allCategoryTypes.Transfer].length) {
return false;
}
const firstAvailableCategoryId = getFirstAvailableCategoryId(this.allCategories[this.allCategoryTypes.Transfer]);
return firstAvailableCategoryId !== '';
},
showAccountBalance() {
return this.settingsStore.appSettings.showAccountBalance;
},
currentTimezoneOffsetMinutes() {
return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone);
},
supportedImportFileExtensions() {
return getNameByKeyValue(this.allSupportedImportFileTypes, this.fileType, 'type', 'extensions');
},
fileName: {
get: function () {
if (this.importFile == null) {
return '';
} else {
return this.importFile.name;
}
}
},
importTransactionsTableHeight() {
if (this.countPerPage <= 10 || !this.importTransactions || this.importTransactions.length <= 10) {
return undefined;
} else {
return 400;
}
},
importTransactionHeaders() {
return [
{ value: 'valid', key: 'data-table-expand', sortable: true, nowrap: true },
{ value: 'time', title: this.$t('Transaction Time'), sortable: true, nowrap: true },
{ value: 'type', title: this.$t('Type'), sortable: true, nowrap: true },
{ value: 'categoryId', title: this.$t('Category'), sortable: true, nowrap: true },
{ value: 'sourceAmount', title: this.$t('Amount'), sortable: true, nowrap: true },
{ value: 'sourceAccountId', title: this.$t('Account'), sortable: true, nowrap: true },
{ value: 'geoLocation', title: this.$t('Geographic Location'), sortable: true, nowrap: true },
{ value: 'tagIds', title: this.$t('Tags'), sortable: true, nowrap: true },
{ value: 'comment', title: this.$t('Description'), sortable: true, nowrap: true },
];
},
importTransactionsTablePageOptions() {
const pageOptions = [];
if (!this.importTransactions || this.importTransactions.length < 1) {
pageOptions.push({ value: -1, title: this.$t('All') });
return pageOptions;
}
const availableCountPerPage = [ 5, 10, 15, 20, 25, 30, 50 ];
for (let i = 0; i < availableCountPerPage.length; i++) {
const count = availableCountPerPage[i];
if (this.importTransactions.length < count) {
break;
}
pageOptions.push({ value: count, title: count.toString() });
}
pageOptions.push({ value: -1, title: this.$t('All') });
return pageOptions;
},
totalPageCount() {
if (!this.importTransactions || this.importTransactions.length < 1) {
return 1;
}
return Math.ceil(this.importTransactions.length / this.countPerPage);
},
selectedImportTransactionCount() {
let count = 0;
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].selected) {
count++;
}
}
return count;
},
anyButNotAllTransactionSelected: {
get: function () {
return this.selectedImportTransactionCount > 0 && this.selectedImportTransactionCount !== this.importTransactions.length;
}
},
allTransactionSelected: {
get: function () {
return this.selectedImportTransactionCount === this.importTransactions.length;
}
}
},
watch: {
fileType: function () {
this.importFile = null;
this.importTransactions = null;
this.expandedTransactions = [];
this.currentPage = 1;
this.countPerPage = 10;
}
},
methods: {
open() {
const self = this;
self.fileType = 'ezbookkeeping_csv';
self.currentStep = 'uploadFile';
self.importFile = null;
self.importTransactions = null;
self.expandedTransactions = [];
self.currentPage = 1;
self.countPerPage = 10;
self.showState = true;
self.clientSessionId = generateRandomUUID();
const promises = [
self.accountsStore.loadAllAccounts({ force: false }),
self.transactionCategoriesStore.loadAllCategories({ force: false }),
self.transactionTagsStore.loadAllTags({ force: false })
];
Promise.all(promises).then(function () {
self.loading = false;
}).catch(error => {
logger.error('failed to load essential data for importing transaction', error);
self.loading = false;
self.showState = false;
if (!error.processed) {
if (self.reject) {
self.reject(error);
}
}
});
return new Promise((resolve, reject) => {
self.resolve = resolve;
self.reject = reject;
});
},
showOpenFileDialog() {
if (this.submitting) {
return;
}
this.$refs.fileInput.click();
},
setImportFile(event) {
if (!event || !event.target || !event.target.files || !event.target.files.length) {
return;
}
this.importFile = event.target.files[0];
event.target.value = null;
},
parseData() {
const self = this;
self.submitting = true;
self.transactionsStore.parseImportTransaction({
fileType: self.fileType,
importFile: self.importFile
}).then(response => {
const parsedTransactions = response.items;
if (parsedTransactions) {
for (let i = 0; i < parsedTransactions.length; i++) {
const transaction = parsedTransactions[i];
transaction.index = i;
transaction.selected = false;
transaction.valid = self.isTransactionValid(transaction);
}
}
self.importTransactions = parsedTransactions;
self.expandedTransactions = [];
self.currentPage = 1;
if (self.importTransactions && self.importTransactions.length >= 0 && self.importTransactions.length < 10) {
self.countPerPage = -1;
} else {
self.countPerPage = 10;
}
self.currentStep = 'checkData';
self.submitting = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
submit() {
const self = this;
const transactions = [];
for (let i = 0; i < self.importTransactions.length; i++) {
const transaction = self.importTransactions[i];
if (transaction.valid && transaction.selected) {
transactions.push(transaction);
}
}
if (transactions.length < 1) {
self.$refs.snackbar.showError('No data to import');
return;
}
self.$refs.confirmDialog.open('format.misc.confirmImportTransactions', {
count: transactions.length
}).then(() => {
self.submitting = true;
self.transactionsStore.importTransactions({
transactions: transactions,
clientSessionId: self.clientSessionId
}).then(response => {
self.importedCount = response;
self.currentStep = 'finalResult';
self.accountsStore.updateAccountListInvalidState(true);
self.transactionsStore.updateTransactionListInvalidState(true);
self.overviewStore.updateTransactionOverviewInvalidState(true);
self.statisticsStore.updateTransactionStatisticsInvalidState(true);
self.submitting = false;
}).catch(error => {
self.submitting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
cancel(completed) {
if (completed) {
if (this.resolve) {
this.resolve();
}
} else {
if (this.reject) {
this.reject();
}
}
this.showState = false;
},
selectAll() {
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].valid) {
this.importTransactions[i].selected = true;
}
}
},
selectNone() {
for (let i = 0; i < this.importTransactions.length; i++) {
this.importTransactions[i].selected = false;
}
},
selectInvert() {
for (let i = 0; i < this.importTransactions.length; i++) {
if (this.importTransactions[i].valid || this.importTransactions[i].selected) {
this.importTransactions[i].selected = !this.importTransactions[i].selected;
}
}
},
selectAllInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i] && this.importTransactions[i].valid) {
this.importTransactions[i].selected = true;
}
}
},
selectNoneInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i]) {
this.importTransactions[i].selected = false;
}
}
},
selectInvertInThisPage() {
for (let i = Math.max(0, (this.currentPage - 1) * this.countPerPage); i < Math.min(this.importTransactions.length, this.currentPage * this.countPerPage); i++) {
if (this.importTransactions[i] && (this.importTransactions[i].valid || this.importTransactions[i].selected)) {
this.importTransactions[i].selected = !this.importTransactions[i].selected;
}
}
},
updateTransactionData(transaction) {
transaction.valid = this.isTransactionValid(transaction);
},
isTransactionValid(transaction) {
if (!transaction) {
return false;
}
if (transaction.type !== this.allTransactionTypes.ModifyBalance && (!transaction.categoryId || transaction.categoryId === '0')) {
return false;
}
if (!transaction.sourceAccountId || transaction.sourceAccountId === '0') {
return false;
}
if (transaction.type === this.allTransactionTypes.Transfer && (!transaction.destinationAccountId || transaction.destinationAccountId === '0')) {
return false;
}
if (transaction.tagIds && transaction.tagIds.length) {
for (let j = 0; j < transaction.tagIds.length; j++) {
if (!transaction.tagIds[j] || transaction.tagIds[j] === '0') {
return false;
}
}
}
return true;
},
isTagValid(transaction, tagIndex) {
if (!transaction || !transaction.tagIds || !transaction.tagIds[tagIndex]) {
return false;
}
if (transaction.tagIds[tagIndex] === '0') {
return false;
}
const tagId = transaction.tagIds[tagIndex];
return !!this.allTagsMap[tagId];
},
getDisplayDateTime(transaction) {
const transactionTime = getUnixTime(parseDateFromUnixTime(transaction.time, transaction.utcOffset, this.currentTimezoneOffsetMinutes));
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, transactionTime);
},
getDisplayTimezone(transaction) {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
},
getTransactionDisplayAmount(transaction) {
let currency = transaction.originalSourceAccountCurrency || this.userStore.currentUserDefaultCurrency;
if (transaction.sourceAccountId && transaction.sourceAccountId !== '0' && this.allAccountsMap[transaction.sourceAccountId]) {
currency = this.allAccountsMap[transaction.sourceAccountId].currency;
}
return this.getDisplayCurrency(transaction.sourceAmount, currency);
},
getTransactionDisplayDestinationAmount(transaction) {
if (transaction.type !== this.allTransactionTypes.Transfer) {
return '-';
}
let currency = transaction.originalDestinationAccountCurrency || this.userStore.currentUserDefaultCurrency;
if (transaction.destinationAccountId && transaction.destinationAccountId !== '0' && this.allAccountsMap[transaction.destinationAccountId]) {
currency = this.allAccountsMap[transaction.destinationAccountId].currency;
}
return this.getDisplayCurrency(transaction.destinationAmount, currency);
},
getPrimaryCategoryName(categoryId, allCategories) {
return getTransactionPrimaryCategoryName(categoryId, allCategories);
},
getSecondaryCategoryName(categoryId, allCategories) {
return getTransactionSecondaryCategoryName(categoryId, allCategories);
},
getSourceAccountTitle(transaction) {
if (transaction.type === this.allTransactionTypes.Expense || transaction.type === this.allTransactionTypes.Income) {
return this.$t('Account');
} else if (transaction.type === this.allTransactionTypes.Transfer) {
return this.$t('Source Account');
} else {
return this.$t('Account');
}
},
getSourceAccountDisplayName(transaction) {
if (transaction.sourceAccountId) {
return getNameByKeyValue(this.allAccounts, transaction.sourceAccountId, 'id', 'name');
} else {
return this.$t('None');
}
},
getDestinationAccountDisplayName(transaction) {
if (transaction.destinationAccountId) {
return getNameByKeyValue(this.allAccounts, transaction.destinationAccountId, 'id', 'name');
} else {
return this.$t('None');
}
}
}
}
</script>
<style>
.import-transaction-table .v-chip.transaction-tag {
margin-right: 4px;
margin-top: 2px;
margin-bottom: 2px;
}
.import-transaction-table .v-chip.transaction-tag > .v-chip__content {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>