package services import ( "fmt" "math" "strings" "time" "xorm.io/builder" "xorm.io/xorm" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/datastore" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/uuid" ) const pageCountForLoadTransactionAmounts = 1000 // TransactionService represents transaction service type TransactionService struct { ServiceUsingDB ServiceUsingUuid } // Initialize a transaction service singleton instance var ( Transactions = &TransactionService{ ServiceUsingDB: ServiceUsingDB{ container: datastore.Container, }, ServiceUsingUuid: ServiceUsingUuid{ container: uuid.Container, }, } ) // GetTotalTransactionCountByUid returns total transaction count of user func (s *TransactionService) GetTotalTransactionCountByUid(c core.Context, uid int64) (int64, error) { if uid <= 0 { return 0, errs.ErrUserIdInvalid } count, err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).Count(&models.Transaction{}) return count, err } // GetAllTransactions returns all transactions func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) { maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix()) var allTransactions []*models.Transaction for maxTransactionTime > 0 { transactions, err := s.GetAllTransactionsByMaxTime(c, uid, maxTransactionTime, pageCount, noDuplicated) if err != nil { return nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < int(pageCount) { maxTransactionTime = 0 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } return allTransactions, nil } // GetAllTransactionsByMaxTime returns all transactions before given time func (s *TransactionService) GetAllTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, count int32, noDuplicated bool) ([]*models.Transaction, error) { return s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, count, false, noDuplicated) } // GetAllSpecifiedTransactions returns all transactions that match given conditions func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) { if maxTransactionTime <= 0 { maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix()) } var allTransactions []*models.Transaction for maxTransactionTime > 0 { transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, 1, pageCount, false, noDuplicated) if err != nil { return nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < int(pageCount) { maxTransactionTime = 0 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } return allTransactions, nil } // GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime returns account statement within time range func (s *TransactionService) GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime(c core.Context, uid int64, pageCount int32, maxTransactionTime int64, minTransactionTime int64, accountId int64, accountCategory models.AccountCategory) ([]*models.TransactionWithAccountBalance, int64, int64, int64, int64, error) { if maxTransactionTime <= 0 { maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix()) } var allTransactions []*models.Transaction for maxTransactionTime > 0 { transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", 1, pageCount, false, true) if err != nil { return nil, 0, 0, 0, 0, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < int(pageCount) { maxTransactionTime = 0 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } allTransactionsAndAccountBalance := make([]*models.TransactionWithAccountBalance, 0, len(allTransactions)) if len(allTransactions) < 1 { return allTransactionsAndAccountBalance, 0, 0, 0, 0, nil } totalInflows := int64(0) totalOutflows := int64(0) openingBalance := int64(0) accumulatedBalance := int64(0) lastAccumulatedBalance := int64(0) for i := len(allTransactions) - 1; i >= 0; i-- { transaction := allTransactions[i] if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { accumulatedBalance = accumulatedBalance + transaction.RelatedAccountAmount } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { accumulatedBalance = accumulatedBalance + transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { accumulatedBalance = accumulatedBalance - transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { accumulatedBalance = accumulatedBalance - transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { accumulatedBalance = accumulatedBalance + transaction.Amount } else { log.Errorf(c, "[transactions.GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime] trasaction type (%d) is invalid (id:%d)", transaction.TransactionId, transaction.Type) return nil, 0, 0, 0, 0, errs.ErrTransactionTypeInvalid } if transaction.TransactionTime < minTransactionTime { openingBalance = accumulatedBalance lastAccumulatedBalance = accumulatedBalance continue } if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if accountCategory.IsAsset() { totalInflows = totalInflows + transaction.RelatedAccountAmount } else if accountCategory.IsLiability() { totalOutflows = totalOutflows - transaction.RelatedAccountAmount } } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { totalInflows = totalInflows + transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { totalOutflows = totalOutflows + transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { totalOutflows = totalOutflows + transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { totalInflows = totalInflows + transaction.Amount } transactionsAndAccountBalance := &models.TransactionWithAccountBalance{ Transaction: transaction, AccountOpeningBalance: lastAccumulatedBalance, AccountClosingBalance: accumulatedBalance, } lastAccumulatedBalance = accumulatedBalance allTransactionsAndAccountBalance = append(allTransactionsAndAccountBalance, transactionsAndAccountBalance) } return allTransactionsAndAccountBalance, totalInflows, totalOutflows, openingBalance, accumulatedBalance, nil } // GetAllAccountsDailyOpeningAndClosingBalance returns daily opening and closing balance of all accounts within time range func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, clientTimezone *time.Location) (map[int32][]*models.TransactionWithAccountBalance, error) { if maxTransactionTime <= 0 { maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix()) } var allTransactions []*models.Transaction for maxTransactionTime > 0 { transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, pageCountForLoadTransactionAmounts, false, false) if err != nil { return nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < pageCountForLoadTransactionAmounts { maxTransactionTime = 0 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } accountDailyLastBalances := make(map[string]*models.TransactionWithAccountBalance) accountDailyBalances := make(map[int32][]*models.TransactionWithAccountBalance) if len(allTransactions) < 1 { return accountDailyBalances, nil } accumulatedBalances := make(map[int64]int64) accumulatedBalancesBeforeStartTime := make(map[int64]int64) for i := len(allTransactions) - 1; i >= 0; i-- { transaction := allTransactions[i] accumulatedBalance := accumulatedBalances[transaction.AccountId] lastAccumulatedBalance := accumulatedBalances[transaction.AccountId] if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { accumulatedBalance = accumulatedBalance + transaction.RelatedAccountAmount } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { accumulatedBalance = accumulatedBalance + transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { accumulatedBalance = accumulatedBalance - transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { accumulatedBalance = accumulatedBalance - transaction.Amount } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { accumulatedBalance = accumulatedBalance + transaction.Amount } else { log.Errorf(c, "[transactions.GetAllTransactionsWithAccountBalanceByMaxTime] trasaction type (%d) is invalid (id:%d)", transaction.TransactionId, transaction.Type) return nil, errs.ErrTransactionTypeInvalid } accumulatedBalances[transaction.AccountId] = accumulatedBalance if transaction.TransactionTime < minTransactionTime { accumulatedBalancesBeforeStartTime[transaction.AccountId] = accumulatedBalance continue } yearMonthDay := utils.FormatUnixTimeToNumericYearMonthDay(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), clientTimezone) groupKey := fmt.Sprintf("%d_%d", yearMonthDay, transaction.AccountId) dailyAccountBalance, exists := accountDailyLastBalances[groupKey] if exists { dailyAccountBalance.AccountClosingBalance = accumulatedBalance } else { dailyAccountBalance = &models.TransactionWithAccountBalance{ Transaction: &models.Transaction{ AccountId: transaction.AccountId, }, AccountOpeningBalance: lastAccumulatedBalance, AccountClosingBalance: accumulatedBalance, } accountDailyLastBalances[groupKey] = dailyAccountBalance } } firstTransactionTime := allTransactions[len(allTransactions)-1].TransactionTime if minTransactionTime > firstTransactionTime { firstTransactionTime = minTransactionTime } firstYearMonthDay := utils.FormatUnixTimeToNumericYearMonthDay(utils.GetUnixTimeFromTransactionTime(firstTransactionTime), clientTimezone) // fill in the opening balance for accounts that do not have transactions on the first day for accountId, accumulatedBalance := range accumulatedBalancesBeforeStartTime { if accumulatedBalance == 0 { continue } groupKey := fmt.Sprintf("%d_%d", firstYearMonthDay, accountId) if _, exists := accountDailyLastBalances[groupKey]; exists { continue } accountDailyLastBalances[groupKey] = &models.TransactionWithAccountBalance{ Transaction: &models.Transaction{ AccountId: accountId, }, AccountOpeningBalance: accumulatedBalance, AccountClosingBalance: accumulatedBalance, } } for groupKey, transactionWithAccountBalance := range accountDailyLastBalances { groupKeyParts := strings.Split(groupKey, "_") yearMonthDay, _ := utils.StringToInt32(groupKeyParts[0]) dailyAccountBalances, exists := accountDailyBalances[yearMonthDay] if !exists { dailyAccountBalances = make([]*models.TransactionWithAccountBalance, 0) } dailyAccountBalances = append(dailyAccountBalances, transactionWithAccountBalance) accountDailyBalances[yearMonthDay] = dailyAccountBalances } return accountDailyBalances, nil } // GetTransactionsByMaxTime returns transactions before given time func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) { if uid <= 0 { return nil, errs.ErrUserIdInvalid } var err error var transactionDbType models.TransactionDbType = 0 if transactionType > 0 { transactionDbType, err = transactionType.ToTransactionDbType() if err != nil { return nil, err } } if page < 0 { return nil, errs.ErrPageIndexInvalid } else if page == 0 { page = 1 } if count < 1 { return nil, errs.ErrPageCountInvalid } var transactions []*models.Transaction actualCount := count if needOneMoreItem { actualCount++ } condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, noDuplicated) sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...) sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags) err = sess.Limit(int(actualCount), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions) return transactions, err } // GetTransactionsInMonthByPage returns all transactions in given year and month func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) ([]*models.Transaction, error) { if uid <= 0 { return nil, errs.ErrUserIdInvalid } var err error var transactionDbType models.TransactionDbType = 0 if transactionType > 0 { transactionDbType, err = transactionType.ToTransactionDbType() if err != nil { return nil, err } } minTransactionTime, maxTransactionTime, err := utils.GetTransactionTimeRangeByYearMonth(year, month) if err != nil { return nil, errs.ErrSystemError } var transactions []*models.Transaction condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true) sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...) sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags) err = sess.OrderBy("transaction_time desc").Find(&transactions) transactionsInMonth := make([]*models.Transaction, 0, len(transactions)) for i := 0; i < len(transactions); i++ { transaction := transactions[i] transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) if utils.IsUnixTimeEqualsYearAndMonth(transactionUnixTime, transactionTimeZone, year, month) { transactionsInMonth = append(transactionsInMonth, transaction) } } return transactionsInMonth, err } // GetTransactionByTransactionId returns a transaction model according to transaction id func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid int64, transactionId int64) (*models.Transaction, error) { if uid <= 0 { return nil, errs.ErrUserIdInvalid } if transactionId <= 0 { return nil, errs.ErrTransactionIdInvalid } transaction := &models.Transaction{} has, err := s.UserDataDB(uid).NewSession(c).ID(transactionId).Where("uid=? AND deleted=?", uid, false).Get(transaction) if err != nil { return nil, err } else if !has { return nil, errs.ErrTransactionNotFound } return transaction, nil } // GetAllTransactionCount returns total count of transactions func (s *TransactionService) GetAllTransactionCount(c core.Context, uid int64) (int64, error) { return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "") } // GetTransactionCount returns count of transactions func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) (int64, error) { if uid <= 0 { return 0, errs.ErrUserIdInvalid } var err error var transactionDbType models.TransactionDbType = 0 if transactionType > 0 { transactionDbType, err = transactionType.ToTransactionDbType() if err != nil { return 0, err } } condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true) sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...) sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags) return sess.Count(&models.Transaction{}) } // CreateTransaction saves a new transaction to database func (s *TransactionService) CreateTransaction(c core.Context, transaction *models.Transaction, tagIds []int64, pictureIds []int64) error { if transaction.Uid <= 0 { return errs.ErrUserIdInvalid } // Check whether account id is valid err := s.isAccountIdValid(transaction) if err != nil { return err } now := time.Now().Unix() needTransactionUuidCount := 1 if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { needTransactionUuidCount = 2 } transactionUuids := s.GenerateUuids(uuid.UUID_TYPE_TRANSACTION, uint16(needTransactionUuidCount)) if len(transactionUuids) < needTransactionUuidCount { return errs.ErrSystemIsBusy } tagIds = utils.ToUniqueInt64Slice(tagIds) needTagIndexUuidCount := uint16(len(tagIds)) tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, needTagIndexUuidCount) if len(tagIndexUuids) < int(needTagIndexUuidCount) { return errs.ErrSystemIsBusy } transaction.TransactionId = transactionUuids[0] if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { transaction.RelatedId = transactionUuids[1] } transaction.TransactionTime = utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) transaction.CreatedUnixTime = now transaction.UpdatedUnixTime = now transactionTagIndexes := make([]*models.TransactionTagIndex, len(tagIds)) for i := 0; i < len(tagIds); i++ { transactionTagIndexes[i] = &models.TransactionTagIndex{ TagIndexId: tagIndexUuids[i], Uid: transaction.Uid, Deleted: false, TagId: tagIds[i], TransactionId: transaction.TransactionId, CreatedUnixTime: now, UpdatedUnixTime: now, } } pictureUpdateModel := &models.TransactionPictureInfo{ TransactionId: transaction.TransactionId, UpdatedUnixTime: now, } userDataDb := s.UserDataDB(transaction.Uid) return userDataDb.DoTransaction(c, func(sess *xorm.Session) error { return s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, tagIds, pictureIds, pictureUpdateModel) }) } // BatchCreateTransactions saves new transactions to database func (s *TransactionService) BatchCreateTransactions(c core.Context, uid int64, transactions []*models.Transaction, allTagIds map[int][]int64, processHandler core.TaskProcessUpdateHandler) error { now := time.Now().Unix() currentProcess := float64(0) processUpdateStep := int(math.Max(100.0, float64(len(transactions)/100.0))) needTransactionUuidCount := uint16(0) needTagIndexUuidCount := uint16(0) for i := 0; i < len(transactions); i++ { transaction := transactions[i] if transaction.Uid != uid { return errs.ErrUserIdInvalid } // Check whether account id is valid err := s.isAccountIdValid(transaction) if err != nil { return err } if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { needTransactionUuidCount += 2 } else { needTransactionUuidCount++ } transaction.TransactionTime = utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) transaction.CreatedUnixTime = now transaction.UpdatedUnixTime = now } for index, tagIds := range allTagIds { if index < 0 || index >= len(transactions) { return errs.ErrOperationFailed } uniqueTagIds := utils.ToUniqueInt64Slice(tagIds) needTagIndexUuidCount += uint16(len(uniqueTagIds)) } if needTransactionUuidCount > uint16(65535) || needTagIndexUuidCount > uint16(65535) { return errs.ErrImportTooManyTransaction } transactionUuids := s.GenerateUuids(uuid.UUID_TYPE_TRANSACTION, needTransactionUuidCount) transactionUuidIndex := 0 if len(transactionUuids) < int(needTransactionUuidCount) { return errs.ErrSystemIsBusy } for i := 0; i < len(transactions); i++ { transaction := transactions[i] transaction.TransactionId = transactionUuids[transactionUuidIndex] transactionUuidIndex++ if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { transaction.RelatedId = transactionUuids[transactionUuidIndex] transactionUuidIndex++ } } tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, needTagIndexUuidCount) tagIndexUuidIndex := 0 if len(tagIndexUuids) < int(needTagIndexUuidCount) { return errs.ErrSystemIsBusy } allTransactionTagIndexes := make(map[int64][]*models.TransactionTagIndex) allTransactionTagIds := make(map[int64][]int64) for index, tagIds := range allTagIds { transaction := transactions[index] uniqueTagIds := utils.ToUniqueInt64Slice(tagIds) transactionTagIndexes := make([]*models.TransactionTagIndex, len(uniqueTagIds)) for i := 0; i < len(uniqueTagIds); i++ { transactionTagIndexes[i] = &models.TransactionTagIndex{ TagIndexId: tagIndexUuids[tagIndexUuidIndex], Uid: transaction.Uid, Deleted: false, TagId: uniqueTagIds[i], TransactionId: transaction.TransactionId, CreatedUnixTime: now, UpdatedUnixTime: now, } tagIndexUuidIndex++ } allTransactionTagIndexes[transaction.TransactionId] = transactionTagIndexes allTransactionTagIds[transaction.TransactionId] = uniqueTagIds } userDataDb := s.UserDataDB(uid) return userDataDb.DoTransaction(c, func(sess *xorm.Session) error { for i := 0; i < len(transactions); i++ { transaction := transactions[i] transactionTagIndexes := allTransactionTagIndexes[transaction.TransactionId] transactionTagIds := allTransactionTagIds[transaction.TransactionId] err := s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, transactionTagIds, nil, nil) currentProcess = float64(i) / float64(len(transactions)) * 100 if processHandler != nil && i%processUpdateStep == 0 { processHandler(currentProcess) } if err != nil { transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) log.Errorf(c, "[transactions.BatchCreateTransactions] failed to create trasaction (datetime: %s, type: %s, amount: %d)", utils.FormatUnixTimeToLongDateTime(transactionUnixTime, transactionTimeZone), transaction.Type, transaction.Amount) return err } } return nil }) } // CreateScheduledTransactions saves all scheduled transactions that should be created now func (s *TransactionService) CreateScheduledTransactions(c core.Context, currentUnixTime int64, interval time.Duration) error { var allTemplates []*models.TransactionTemplate intervalMinute := int(interval / time.Minute) currentTime := time.Unix(currentUnixTime, 0) currentMinute := (currentTime.Minute() / intervalMinute) * intervalMinute startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), currentTime.Hour(), currentMinute, 0, 0, time.Local) startTimeInUTC := startTime.In(time.UTC) minutesElapsedOfDayInUtc := startTimeInUTC.Hour()*60 + startTimeInUTC.Minute() secondsElapsedOfDayInUtc := minutesElapsedOfDayInUtc * 60 todayFirstTimeInUTC := startTimeInUTC.Add(time.Duration(-secondsElapsedOfDayInUtc) * time.Second) todayFirstUnixTimeInUTC := todayFirstTimeInUTC.Unix() minScheduledAt := minutesElapsedOfDayInUtc maxScheduledAt := minScheduledAt + intervalMinute for i := 0; i < s.UserDataDBCount(); i++ { var templates []*models.TransactionTemplate err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at transactionUnixTime { skipCount++ log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is earlier than the start time %d", template.TemplateId, *template.ScheduledStartTime) continue } if template.ScheduledEndTime != nil && *template.ScheduledEndTime < transactionUnixTime { skipCount++ log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is later than the end time %d", template.TemplateId, *template.ScheduledEndTime) continue } var transactionDbType models.TransactionDbType if template.Type == models.TRANSACTION_TYPE_EXPENSE { transactionDbType = models.TRANSACTION_DB_TYPE_EXPENSE } else if template.Type == models.TRANSACTION_TYPE_INCOME { transactionDbType = models.TRANSACTION_DB_TYPE_INCOME } else if template.Type == models.TRANSACTION_TYPE_TRANSFER { transactionDbType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT } else { skipCount++ log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid transaction type", template.TemplateId) continue } transaction := &models.Transaction{ Uid: template.Uid, Type: transactionDbType, CategoryId: template.CategoryId, TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()), TimezoneUtcOffset: template.ScheduledTimezoneUtcOffset, AccountId: template.AccountId, Amount: template.Amount, HideAmount: template.HideAmount, Comment: template.Comment, CreatedIp: "127.0.0.1", ScheduledCreated: true, } if template.Type == models.TRANSACTION_TYPE_TRANSFER { transaction.RelatedAccountId = template.RelatedAccountId transaction.RelatedAccountAmount = template.RelatedAccountAmount } tagIds := template.GetTagIds() err = s.CreateTransaction(c, transaction, tagIds, nil) if err == nil { successCount++ log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has created a new trasaction \"id:%d\"", template.TemplateId, transaction.TransactionId) } else { failedCount++ log.Errorf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" failed to create new trasaction, because %s", template.TemplateId, err.Error()) } } log.Infof(c, "[transactions.CreateScheduledTransactions] %d transactions has been created successfully, %d templates does not need to create transactions and %d transactions failed to create", successCount, skipCount, failedCount) return nil } // ModifyTransaction saves an existed transaction to database func (s *TransactionService) ModifyTransaction(c core.Context, transaction *models.Transaction, currentTagIdsCount int, addTagIds []int64, removeTagIds []int64, addPictureIds []int64, removePictureIds []int64) error { if transaction.Uid <= 0 { return errs.ErrUserIdInvalid } needTagIndexUuidCount := uint16(len(addTagIds)) tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, needTagIndexUuidCount) if len(tagIndexUuids) < int(needTagIndexUuidCount) { return errs.ErrSystemIsBusy } updateCols := make([]string, 0, 16) now := time.Now().Unix() transaction.TransactionTime = utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) transaction.UpdatedUnixTime = now updateCols = append(updateCols, "updated_unix_time") addTagIds = utils.ToUniqueInt64Slice(addTagIds) removeTagIds = utils.ToUniqueInt64Slice(removeTagIds) transactionTagIndexes := make([]*models.TransactionTagIndex, len(addTagIds)) for i := 0; i < len(addTagIds); i++ { transactionTagIndexes[i] = &models.TransactionTagIndex{ TagIndexId: tagIndexUuids[i], Uid: transaction.Uid, Deleted: false, TagId: addTagIds[i], TransactionId: transaction.TransactionId, CreatedUnixTime: now, UpdatedUnixTime: now, } } err := s.UserDataDB(transaction.Uid).DoTransaction(c, func(sess *xorm.Session) error { // Get and verify current transaction oldTransaction := &models.Transaction{} has, err := sess.ID(transaction.TransactionId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(oldTransaction) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to get current transaction, because %s", err.Error()) return err } else if !has { return errs.ErrTransactionNotFound } transaction.Type = oldTransaction.Type if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { transaction.RelatedId = oldTransaction.RelatedId } // Check whether account id is valid err = s.isAccountIdValid(transaction) if err != nil { return err } // Get and verify source and destination account (if necessary) sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to get account, because %s", err.Error()) return err } if sourceAccount.Hidden || (destinationAccount != nil && destinationAccount.Hidden) { return errs.ErrCannotModifyTransactionInHiddenAccount } if sourceAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || (destinationAccount != nil && destinationAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS) { return errs.ErrCannotModifyTransactionInParentAccount } if (transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN) && sourceAccount.Currency == destinationAccount.Currency && transaction.Amount != transaction.RelatedAccountAmount { return errs.ErrTransactionSourceAndDestinationAmountNotEqual } if (transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN) && (transaction.Amount < 0 || transaction.RelatedAccountAmount < 0) { return errs.ErrTransferTransactionAmountCannotBeLessThanZero } oldSourceAccount, oldDestinationAccount, err := s.getOldAccountModels(sess, transaction, oldTransaction, sourceAccount, destinationAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to get old account, because %s", err.Error()) return err } if oldSourceAccount.Hidden || (oldDestinationAccount != nil && oldDestinationAccount.Hidden) { return errs.ErrCannotAddTransactionToHiddenAccount } // Append modified columns and verify if transaction.CategoryId != oldTransaction.CategoryId { // Get and verify category err = s.isCategoryValid(sess, transaction) if err != nil { return err } updateCols = append(updateCols, "category_id") } modifyTransactionTime := false if utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) != utils.GetUnixTimeFromTransactionTime(oldTransaction.TransactionTime) { if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { return errs.ErrBalanceModificationTransactionCannotModifyTime } sameSecondLatestTransaction := &models.Transaction{} minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) has, err = sess.Where("uid=? AND deleted=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, false, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to get trasaction time, because %s", err.Error()) return err } if has && sameSecondLatestTransaction.TransactionTime < maxTransactionTime-1 { transaction.TransactionTime = sameSecondLatestTransaction.TransactionTime + 1 } else if has && sameSecondLatestTransaction.TransactionTime == maxTransactionTime-1 { return errs.ErrTooMuchTransactionInOneSecond } updateCols = append(updateCols, "transaction_time") modifyTransactionTime = true } if transaction.TimezoneUtcOffset != oldTransaction.TimezoneUtcOffset { updateCols = append(updateCols, "timezone_utc_offset") } if transaction.AccountId != oldTransaction.AccountId { updateCols = append(updateCols, "account_id") } if transaction.Amount != oldTransaction.Amount { if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { transaction.RelatedAccountAmount = transaction.Amount // Amount IS the new delta updateCols = append(updateCols, "related_account_amount") } updateCols = append(updateCols, "amount") } if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { if transaction.RelatedAccountId != oldTransaction.RelatedAccountId { updateCols = append(updateCols, "related_account_id") } if transaction.RelatedAccountAmount != oldTransaction.RelatedAccountAmount { updateCols = append(updateCols, "related_account_amount") } } if transaction.HideAmount != oldTransaction.HideAmount { updateCols = append(updateCols, "hide_amount") } if transaction.Comment != oldTransaction.Comment { updateCols = append(updateCols, "comment") } if transaction.GeoLongitude != oldTransaction.GeoLongitude { updateCols = append(updateCols, "geo_longitude") } if transaction.GeoLatitude != oldTransaction.GeoLatitude { updateCols = append(updateCols, "geo_latitude") } // Get and verify tags err = s.isTagsValid(sess, transaction, transactionTagIndexes, addTagIds) if err != nil { return err } // Get and verify pictures err = s.isPicturesValid(sess, transaction, addPictureIds) if err != nil { return err } // Not allow to add transaction before balance modification transaction if transaction.Type != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { otherTransactionExists := false if destinationAccount != nil && sourceAccount.AccountId != destinationAccount.AccountId { otherTransactionExists, err = sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND type=? AND (account_id=? OR account_id=?) AND transaction_time>=?", transaction.Uid, false, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, sourceAccount.AccountId, destinationAccount.AccountId, transaction.TransactionTime).Limit(1).Exist(&models.Transaction{}) } else { otherTransactionExists, err = sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND type=? AND account_id=? AND transaction_time>=?", transaction.Uid, false, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, sourceAccount.AccountId, transaction.TransactionTime).Limit(1).Exist(&models.Transaction{}) } if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to get whether other transactions exist, because %s", err.Error()) return err } else if otherTransactionExists { return errs.ErrCannotAddTransactionBeforeBalanceModificationTransaction } } // Update transaction row updatedRows, err := sess.ID(transaction.TransactionId).Cols(updateCols...).Where("uid=? AND deleted=?", transaction.Uid, false).Update(transaction) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction, because %s", err.Error()) return err } else if updatedRows < 1 { return errs.ErrTransactionNotFound } if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { relatedTransaction := s.GetRelatedTransferTransaction(transaction) if utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) != utils.GetUnixTimeFromTransactionTime(relatedTransaction.TransactionTime) { return errs.ErrTooMuchTransactionInOneSecond } relatedUpdateCols := s.getRelatedUpdateColumns(updateCols) updatedRows, err := sess.ID(relatedTransaction.TransactionId).Cols(relatedUpdateCols...).Where("uid=? AND deleted=?", relatedTransaction.Uid, false).Update(relatedTransaction) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update related transaction, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update related transaction") return errs.ErrDatabaseOperationFailed } } // Update transaction tag index if len(removeTagIds) > 0 { tagIndexUpdateModel := &models.TransactionTagIndex{ Deleted: true, DeletedUnixTime: now, } deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("tag_id", removeTagIds).Update(tagIndexUpdateModel) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction tag index, because %s", err.Error()) return err } else if deletedRows < 1 { return errs.ErrTransactionTagNotFound } } if len(transactionTagIndexes) > 0 { for i := 0; i < len(transactionTagIndexes); i++ { transactionTagIndex := transactionTagIndexes[i] transactionTagIndex.TransactionTime = transaction.TransactionTime _, err := sess.Insert(transactionTagIndex) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to add new transaction tag index, because %s", err.Error()) return err } } } else if len(transactionTagIndexes) == 0 && currentTagIdsCount > 0 && modifyTransactionTime { tagIndexUpdateModel := &models.TransactionTagIndex{ TransactionTime: transaction.TransactionTime, } _, err := sess.Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).Update(tagIndexUpdateModel) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction tag index, because %s", err.Error()) return err } } // Update transaction picture if len(removePictureIds) > 0 { pictureUpdateModel := &models.TransactionPictureInfo{ Deleted: true, DeletedUnixTime: now, } deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("picture_id", removePictureIds).Update(pictureUpdateModel) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction picture info, because %s", err.Error()) return err } else if deletedRows < 1 { return errs.ErrTransactionPictureNotFound } } if len(addPictureIds) > 0 { pictureUpdateModel := &models.TransactionPictureInfo{ TransactionId: transaction.TransactionId, UpdatedUnixTime: now, } _, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", addPictureIds).Update(pictureUpdateModel) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update new transaction picture info, because %s", err.Error()) return err } } // Update account table if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.AccountId != oldTransaction.AccountId { return errs.ErrBalanceModificationTransactionCannotChangeAccountId } if transaction.Amount != oldTransaction.Amount && transaction.RelatedAccountAmount != oldTransaction.RelatedAccountAmount { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_INCOME { var oldAccountNewAmount int64 = 0 var newAccountNewAmount int64 = 0 if transaction.AccountId == oldTransaction.AccountId { oldAccountNewAmount = transaction.Amount } else if transaction.AccountId != oldTransaction.AccountId { newAccountNewAmount = transaction.Amount } if oldAccountNewAmount != oldTransaction.Amount { oldSourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if newAccountNewAmount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { var oldAccountNewAmount int64 = 0 var newAccountNewAmount int64 = 0 if transaction.AccountId == oldTransaction.AccountId { oldAccountNewAmount = transaction.Amount } else if transaction.AccountId != oldTransaction.AccountId { newAccountNewAmount = transaction.Amount } if oldAccountNewAmount != oldTransaction.Amount { oldSourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if newAccountNewAmount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { var oldSourceAccountNewAmount int64 = 0 var newSourceAccountNewAmount int64 = 0 if transaction.AccountId == oldTransaction.AccountId { oldSourceAccountNewAmount = transaction.Amount } else if transaction.AccountId != oldTransaction.AccountId { newSourceAccountNewAmount = transaction.Amount } if oldSourceAccountNewAmount != oldTransaction.Amount { oldSourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if newSourceAccountNewAmount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } var oldDestinationAccountNewAmount int64 = 0 var newDestinationAccountNewAmount int64 = 0 if transaction.RelatedAccountId == oldTransaction.RelatedAccountId { oldDestinationAccountNewAmount = transaction.RelatedAccountAmount } else if transaction.RelatedAccountId != oldTransaction.RelatedAccountId { newDestinationAccountNewAmount = transaction.RelatedAccountAmount } if oldDestinationAccountNewAmount != oldTransaction.RelatedAccountAmount { oldDestinationAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(oldDestinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, oldDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldDestinationAccount.Uid, false).Update(oldDestinationAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if newDestinationAccountNewAmount != 0 { destinationAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount) if err != nil { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { return errs.ErrTransactionTypeInvalid } return nil }) if err != nil { return err } return nil } func (s *TransactionService) MoveAllTransactionsBetweenAccounts(c core.Context, uid int64, fromAccountId int64, toAccountId int64) error { if uid <= 0 { return errs.ErrUserIdInvalid } if fromAccountId <= 0 || toAccountId <= 0 { return errs.ErrAccountIdInvalid } if fromAccountId == toAccountId { return errs.ErrCannotMoveTransactionToSameAccount } return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { // get and verify from and to account fromAccount := &models.Account{} has, err := sess.ID(fromAccountId).Where("uid=? AND deleted=?", uid, false).Get(fromAccount) if err != nil { return err } else if !has { return errs.ErrAccountNotFound } toAccount := &models.Account{} has, err = sess.ID(toAccountId).Where("uid=? AND deleted=?", uid, false).Get(toAccount) if err != nil { return err } else if !has { return errs.ErrAccountNotFound } if fromAccount.Hidden || toAccount.Hidden { return errs.ErrCannotMoveTransactionFromOrToHiddenAccount } if fromAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || toAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS { return errs.ErrCannotMoveTransactionFromOrToParentAccount } if fromAccount.Currency != toAccount.Currency { return errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies } // combine balance modification transaction var balanceModificationTransactions []*models.Transaction err = sess.Where("uid=? AND deleted=? AND type=? AND (account_id=? OR account_id=?)", uid, false, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, fromAccountId, toAccountId).Find(&balanceModificationTransactions) if err != nil { return err } if len(balanceModificationTransactions) > 2 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has more than 2 balance modification transactions in account \"id:%d\" and account \"id:%d\", cannot combine balance modification transaction", uid, fromAccountId, toAccountId) return errs.ErrOperationFailed } else if len(balanceModificationTransactions) == 2 && balanceModificationTransactions[0].AccountId != balanceModificationTransactions[1].AccountId { // if two balance modification transactions exist, merge the amounts into the earlier one and delete the later transaction var earlierTransaction *models.Transaction var laterTransaction *models.Transaction if balanceModificationTransactions[0].TransactionTime < balanceModificationTransactions[1].TransactionTime { earlierTransaction = balanceModificationTransactions[0] laterTransaction = balanceModificationTransactions[1] } else { earlierTransaction = balanceModificationTransactions[1] laterTransaction = balanceModificationTransactions[0] } earlierTransaction.Amount += laterTransaction.Amount earlierTransaction.RelatedAccountAmount += laterTransaction.RelatedAccountAmount earlierTransaction.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(earlierTransaction.TransactionId).Cols("amount", "related_account_amount", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(earlierTransaction) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] failed to update earlier balance modification transaction") return errs.ErrDatabaseOperationFailed } laterTransaction.Deleted = true laterTransaction.DeletedUnixTime = time.Now().Unix() deletedRows, err := sess.ID(laterTransaction.TransactionId).Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(laterTransaction) if err != nil { return err } else if deletedRows < 1 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] failed to delete later balance modification transaction") return errs.ErrDatabaseOperationFailed } log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has combined two balance modification transactions \"id:%d\" and \"id:%d\", retained transaction is \"id:%d\"", uid, earlierTransaction.TransactionId, laterTransaction.TransactionId, earlierTransaction.TransactionId) } else if len(balanceModificationTransactions) == 1 { // when merging a new balance modification transaction, if its date is later than the account's earliest transaction, update the balance modification transaction time accordingly anotherAccountId := int64(0) if balanceModificationTransactions[0].AccountId == fromAccountId { anotherAccountId = toAccountId } else if balanceModificationTransactions[0].AccountId == toAccountId { anotherAccountId = fromAccountId } else { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has a balance modification transaction \"id:%d\" which account id is neither \"%d\" nor \"%d\"", uid, balanceModificationTransactions[0].TransactionId, fromAccountId, toAccountId) return errs.ErrOperationFailed } earliestTransaction := &models.Transaction{} has, err := sess.Where("uid=? AND deleted=? AND account_id=?", uid, false, anotherAccountId).OrderBy("transaction_time asc").Limit(1).Get(earliestTransaction) if err != nil { return err } else if has && balanceModificationTransactions[0].TransactionTime > earliestTransaction.TransactionTime { balanceModificationTransaction := balanceModificationTransactions[0] balanceModificationTransaction.TransactionTime = utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(earliestTransaction.TransactionTime) - 1) balanceModificationTransaction.UpdatedUnixTime = time.Now().Unix() if balanceModificationTransaction.TransactionTime < 0 { balanceModificationTransaction.TransactionTime = 0 } updatedRows, err := sess.ID(balanceModificationTransaction.TransactionId).Cols("transaction_time", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(balanceModificationTransaction) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] failed to update balance modification transaction time") return errs.ErrDatabaseOperationFailed } log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has updated balance modification transaction \"id:%d\" time to %d, because earliest transaction time in account \"id:%d\" is %d", uid, balanceModificationTransaction.TransactionId, balanceModificationTransaction.TransactionTime, toAccountId, earliestTransaction.TransactionTime) } } // update all transactions of from account updateModel := &models.Transaction{ AccountId: toAccountId, UpdatedUnixTime: time.Now().Unix(), } updatedRows, err := sess.Cols("account_id", "updated_unix_time").Where("uid=? AND deleted=? AND account_id=?", uid, false, fromAccountId).Update(updateModel) if err != nil { return err } if updatedRows > 0 { log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has moved %d transactions from account \"id:%d\" to account \"id:%d\"", uid, updatedRows, fromAccountId, toAccountId) } // update all related transactions of from account updateRelatedModel := &models.Transaction{ RelatedAccountId: toAccountId, UpdatedUnixTime: time.Now().Unix(), } relatedUpdatedRows, err := sess.Cols("related_account_id", "updated_unix_time").Where("uid=? AND deleted=? AND related_account_id=?", uid, false, fromAccountId).Update(updateRelatedModel) if err != nil { return err } if updatedRows > 0 { log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has moved %d related transactions from account \"id:%d\" to account \"id:%d\"", uid, relatedUpdatedRows, fromAccountId, toAccountId) } // delete all transfer transactions which related account id and account id are both deletedModel := &models.Transaction{ Deleted: true, DeletedUnixTime: time.Now().Unix(), } deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND (type=? OR type=?) AND account_id=? AND related_account_id=?", uid, false, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, models.TRANSACTION_DB_TYPE_TRANSFER_IN, toAccountId, toAccountId).Update(deletedModel) if err != nil { return err } if deletedRows > 0 { log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has deleted %d transactions which account id and related account id are both \"%d\"", uid, deletedRows, toAccountId) } // update account balance if fromAccount.Balance != 0 { toAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(toAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", fromAccount.Balance)).Cols("updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(toAccount) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] failed to update to account balance") return errs.ErrDatabaseOperationFailed } log.Infof(c, "[transactions.MoveAllTransactionsBetweenAccounts] user \"uid:%d\" has updated account \"id:%d\" balance from %d to %d", uid, toAccountId, toAccount.Balance, toAccount.Balance+fromAccount.Balance) fromAccount.Balance = 0 fromAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err = sess.ID(fromAccount.AccountId).Cols("balance", "updated_unix_time").Where("uid=? AND deleted=?", fromAccount.Uid, false).Update(fromAccount) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.MoveAllTransactionsBetweenAccounts] failed to update from account balance") return errs.ErrDatabaseOperationFailed } } return nil }) } // DeleteTransaction deletes an existed transaction from database func (s *TransactionService) DeleteTransaction(c core.Context, uid int64, transactionId int64) error { if uid <= 0 { return errs.ErrUserIdInvalid } now := time.Now().Unix() updateModel := &models.Transaction{ Deleted: true, DeletedUnixTime: now, } tagIndexUpdateModel := &models.TransactionTagIndex{ Deleted: true, DeletedUnixTime: now, } pictureUpdateModel := &models.TransactionPictureInfo{ Deleted: true, DeletedUnixTime: now, } return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { // Get and verify current transaction oldTransaction := &models.Transaction{} has, err := sess.ID(transactionId).Where("uid=? AND deleted=?", uid, false).Get(oldTransaction) if err != nil { return err } else if !has { return errs.ErrTransactionNotFound } // Get and verify source and destination account sourceAccount, destinationAccount, err := s.getAccountModels(sess, oldTransaction) if err != nil { return err } if sourceAccount.Hidden || (destinationAccount != nil && destinationAccount.Hidden) { return errs.ErrCannotDeleteTransactionInHiddenAccount } if sourceAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || (destinationAccount != nil && destinationAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS) { return errs.ErrCannotDeleteTransactionInParentAccount } // Update transaction row to deleted deletedRows, err := sess.ID(oldTransaction.TransactionId).Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel) if err != nil { return err } else if deletedRows < 1 { return errs.ErrTransactionNotFound } if oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { deletedRows, err = sess.ID(oldTransaction.RelatedId).Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel) if err != nil { return err } else if deletedRows < 1 { return errs.ErrTransactionNotFound } } // Update transaction tag index _, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", uid, false, oldTransaction.TransactionId).Update(tagIndexUpdateModel) if err != nil { return err } // Update transaction picture _, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", uid, false, oldTransaction.TransactionId).Update(pictureUpdateModel) if err != nil { return err } // Update account table if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if oldTransaction.RelatedAccountAmount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", oldTransaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.DeleteTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_INCOME { if oldTransaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", oldTransaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.DeleteTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { if oldTransaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", oldTransaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.DeleteTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { if oldTransaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedSourceRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", oldTransaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { return err } else if updatedSourceRows < 1 { log.Errorf(c, "[transactions.DeleteTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if oldTransaction.RelatedAccountAmount != 0 { destinationAccount.UpdatedUnixTime = time.Now().Unix() updatedDestinationRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", oldTransaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount) if err != nil { return err } else if updatedDestinationRows < 1 { log.Errorf(c, "[transactions.DeleteTransaction] failed to update related account balance") return errs.ErrDatabaseOperationFailed } } } else if oldTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { return errs.ErrTransactionTypeInvalid } return err }) } // DeleteAllTransactions deletes all existed transactions from database func (s *TransactionService) DeleteAllTransactions(c core.Context, uid int64, deleteAccount bool) error { if uid <= 0 { return errs.ErrUserIdInvalid } now := time.Now().Unix() updateModel := &models.Transaction{ Deleted: true, DeletedUnixTime: now, } tagIndexUpdateModel := &models.TransactionTagIndex{ Deleted: true, DeletedUnixTime: now, } pictureUpdateModel := &models.TransactionPictureInfo{ Deleted: true, DeletedUnixTime: now, } accountUpdateModel := &models.Account{ Balance: 0, Deleted: deleteAccount, DeletedUnixTime: now, } return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { // Update all transactions to deleted _, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(updateModel) if err != nil { return err } // Update all transaction tag index to deleted _, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(tagIndexUpdateModel) if err != nil { return err } // Update all transaction pictures to deleted _, err = sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(pictureUpdateModel) if err != nil { return err } // Update all accounts to deleted or set amount to zero _, err = sess.Cols("balance", "deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).Update(accountUpdateModel) if err != nil { return err } return nil }) } // DeleteAllTransactionsOfAccount deletes all existed transactions of specific account from database func (s *TransactionService) DeleteAllTransactionsOfAccount(c core.Context, uid int64, accountId int64, pageCount int32) error { if uid <= 0 { return errs.ErrUserIdInvalid } if accountId <= 0 { return errs.ErrAccountIdInvalid } transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", pageCount, true) if err != nil { return err } if len(transactions) < 1 { return nil } for i := 0; i < len(transactions); i++ { transaction := transactions[i] if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { err = s.DeleteTransaction(c, uid, transaction.RelatedId) } else { err = s.DeleteTransaction(c, uid, transaction.TransactionId) } if err != nil { return err } } return nil } // GetRelatedTransferTransaction returns the related transaction for transfer transaction func (s *TransactionService) GetRelatedTransferTransaction(originalTransaction *models.Transaction) *models.Transaction { var relatedType models.TransactionDbType var relatedTransactionTime int64 if originalTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { relatedType = models.TRANSACTION_DB_TYPE_TRANSFER_IN relatedTransactionTime = originalTransaction.TransactionTime + 1 } else if originalTransaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { relatedType = models.TRANSACTION_DB_TYPE_TRANSFER_OUT relatedTransactionTime = originalTransaction.TransactionTime - 1 } else { return nil } relatedTransaction := &models.Transaction{ TransactionId: originalTransaction.RelatedId, Uid: originalTransaction.Uid, Deleted: originalTransaction.Deleted, Type: relatedType, CategoryId: originalTransaction.CategoryId, TransactionTime: relatedTransactionTime, TimezoneUtcOffset: originalTransaction.TimezoneUtcOffset, AccountId: originalTransaction.RelatedAccountId, Amount: originalTransaction.RelatedAccountAmount, RelatedId: originalTransaction.TransactionId, RelatedAccountId: originalTransaction.AccountId, RelatedAccountAmount: originalTransaction.Amount, Comment: originalTransaction.Comment, GeoLongitude: originalTransaction.GeoLongitude, GeoLatitude: originalTransaction.GeoLatitude, CreatedIp: originalTransaction.CreatedIp, CreatedUnixTime: originalTransaction.CreatedUnixTime, UpdatedUnixTime: originalTransaction.UpdatedUnixTime, DeletedUnixTime: originalTransaction.DeletedUnixTime, } return relatedTransaction } // GetAccountsTotalIncomeAndExpense returns the every accounts total income and expense amount by specific date range func (s *TransactionService) GetAccountsTotalIncomeAndExpense(c core.Context, uid int64, startUnixTime int64, endUnixTime int64, excludeAccountIds []int64, excludeCategoryIds []int64, clientTimezone *time.Location, useTransactionTimezone bool) (map[int64]int64, map[int64]int64, error) { if uid <= 0 { return nil, nil, errs.ErrUserIdInvalid } startLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(startUnixTime, clientTimezone) endLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(endUnixTime, clientTimezone) startUnixTime = utils.GetMinUnixTimeWithSameLocalDateTime(startUnixTime, utils.GetTimezoneOffsetMinutes(startUnixTime, clientTimezone)) endUnixTime = utils.GetMaxUnixTimeWithSameLocalDateTime(endUnixTime, utils.GetTimezoneOffsetMinutes(endUnixTime, clientTimezone)) startTransactionTime := utils.GetMinTransactionTimeFromUnixTime(startUnixTime) endTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(endUnixTime) condition := "uid=? AND deleted=? AND (type=? OR type=?)" conditionParams := make([]any, 0, 4+len(excludeAccountIds)+len(excludeCategoryIds)) conditionParams = append(conditionParams, uid) conditionParams = append(conditionParams, false) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) if len(excludeAccountIds) > 0 { var accountIdsCondition strings.Builder accountIdConditionParams := make([]any, 0, len(excludeAccountIds)) for i := 0; i < len(excludeAccountIds); i++ { if i > 0 { accountIdsCondition.WriteString(",") } accountIdsCondition.WriteString("?") accountIdConditionParams = append(accountIdConditionParams, excludeAccountIds[i]) } condition = condition + " AND account_id NOT IN (" + accountIdsCondition.String() + ")" conditionParams = append(conditionParams, accountIdConditionParams...) } if len(excludeCategoryIds) > 0 { var categoryIdsCondition strings.Builder categoryIdConditionParams := make([]any, 0, len(excludeCategoryIds)) for i := 0; i < len(excludeCategoryIds); i++ { if i > 0 { categoryIdsCondition.WriteString(",") } categoryIdsCondition.WriteString("?") categoryIdConditionParams = append(categoryIdConditionParams, excludeCategoryIds[i]) } condition = condition + " AND category_id NOT IN (" + categoryIdsCondition.String() + ")" conditionParams = append(conditionParams, categoryIdConditionParams...) } condition = condition + " AND transaction_time>=? AND transaction_time<=?" minTransactionTime := startTransactionTime maxTransactionTime := endTransactionTime var allTransactions []*models.Transaction for maxTransactionTime > 0 { var transactions []*models.Transaction finalConditionParams := make([]any, 0, 6) finalConditionParams = append(finalConditionParams, conditionParams...) finalConditionParams = append(finalConditionParams, minTransactionTime) finalConditionParams = append(finalConditionParams, maxTransactionTime) err := s.UserDataDB(uid).NewSession(c).Select("type, account_id, transaction_time, timezone_utc_offset, amount").Where(condition, finalConditionParams...).Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions) if err != nil { return nil, nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < pageCountForLoadTransactionAmounts { maxTransactionTime = 0 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } incomeAmounts := make(map[int64]int64) expenseAmounts := make(map[int64]int64) for i := 0; i < len(allTransactions); i++ { transaction := allTransactions[i] timeZone := clientTimezone if useTransactionTimezone { timeZone = time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) } localDateTime := utils.FormatUnixTimeToNumericLocalDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), timeZone) if localDateTime < startLocalDateTime || localDateTime > endLocalDateTime { continue } var amountsMap map[int64]int64 if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { amountsMap = incomeAmounts } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { amountsMap = expenseAmounts } totalAmounts, exists := amountsMap[transaction.AccountId] if !exists { totalAmounts = 0 } totalAmounts += transaction.Amount amountsMap[transaction.AccountId] = totalAmounts } return incomeAmounts, expenseAmounts, nil } // GetAccountsAndCategoriesTotalInflowAndOutflow returns the every accounts and categories total inflows and outflows amount by specific date range func (s *TransactionService) GetAccountsAndCategoriesTotalInflowAndOutflow(c core.Context, uid int64, startUnixTime int64, endUnixTime int64, tagFilters []*models.TransactionTagFilter, noTags bool, keyword string, clientTimezone *time.Location, useTransactionTimezone bool) ([]*models.Transaction, error) { if uid <= 0 { return nil, errs.ErrUserIdInvalid } var startLocalDateTime, endLocalDateTime, startTransactionTime, endTransactionTime int64 if startUnixTime > 0 { startLocalDateTime = utils.FormatUnixTimeToNumericLocalDateTime(startUnixTime, clientTimezone) startUnixTime = utils.GetMinUnixTimeWithSameLocalDateTime(startUnixTime, utils.GetTimezoneOffsetMinutes(startUnixTime, clientTimezone)) startTransactionTime = utils.GetMinTransactionTimeFromUnixTime(startUnixTime) } if endUnixTime > 0 { endLocalDateTime = utils.FormatUnixTimeToNumericLocalDateTime(endUnixTime, clientTimezone) endUnixTime = utils.GetMaxUnixTimeWithSameLocalDateTime(endUnixTime, utils.GetTimezoneOffsetMinutes(endUnixTime, clientTimezone)) endTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(endUnixTime) } condition := "uid=? AND deleted=? AND (type=? OR type=? OR type=? OR type=?)" conditionParams := make([]any, 0, 6) conditionParams = append(conditionParams, uid) conditionParams = append(conditionParams, false) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_IN) minTransactionTime := startTransactionTime maxTransactionTime := endTransactionTime var allTransactions []*models.Transaction for maxTransactionTime >= 0 { var transactions []*models.Transaction finalCondition := condition finalConditionParams := make([]any, 0, 6) finalConditionParams = append(finalConditionParams, conditionParams...) if minTransactionTime > 0 { finalCondition = finalCondition + " AND transaction_time>=?" finalConditionParams = append(finalConditionParams, minTransactionTime) } if maxTransactionTime > 0 { finalCondition = finalCondition + " AND transaction_time<=?" finalConditionParams = append(finalConditionParams, maxTransactionTime) } if keyword != "" { finalCondition = finalCondition + " AND comment LIKE ?" finalConditionParams = append(finalConditionParams, "%%"+keyword+"%%") } sess := s.UserDataDB(uid).NewSession(c).Select("type, category_id, account_id, related_account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...) sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags) err := sess.Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions) if err != nil { return nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < pageCountForLoadTransactionAmounts { maxTransactionTime = -1 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } transactionTotalAmountsMap := make(map[string]*models.Transaction) for i := 0; i < len(allTransactions); i++ { transaction := allTransactions[i] timeZone := clientTimezone if useTransactionTimezone { timeZone = time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) } localDateTime := utils.FormatUnixTimeToNumericLocalDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), timeZone) if (startLocalDateTime > 0 && localDateTime < startLocalDateTime) || (endLocalDateTime > 0 && localDateTime > endLocalDateTime) { continue } groupKey := fmt.Sprintf("%d_%d", transaction.CategoryId, transaction.AccountId) if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { groupKey = fmt.Sprintf("%d_%d_%d_%d", transaction.CategoryId, transaction.AccountId, transaction.RelatedAccountId, transaction.Type) } totalAmounts, exists := transactionTotalAmountsMap[groupKey] if !exists { totalAmounts = &models.Transaction{ Type: transaction.Type, CategoryId: transaction.CategoryId, AccountId: transaction.AccountId, RelatedAccountId: transaction.RelatedAccountId, Amount: 0, } transactionTotalAmountsMap[groupKey] = totalAmounts } totalAmounts.Amount += transaction.Amount } transactionTotalAmounts := make([]*models.Transaction, 0, len(transactionTotalAmountsMap)) for _, totalAmounts := range transactionTotalAmountsMap { transactionTotalAmounts = append(transactionTotalAmounts, totalAmounts) } return transactionTotalAmounts, nil } // GetAccountsAndCategoriesMonthlyInflowAndOutflow returns the every accounts monthly inflows and outflows amount by specific date range func (s *TransactionService) GetAccountsAndCategoriesMonthlyInflowAndOutflow(c core.Context, uid int64, startYear int32, startMonth int32, endYear int32, endMonth int32, tagFilters []*models.TransactionTagFilter, noTags bool, keyword string, clientTimezone *time.Location, useTransactionTimezone bool) (map[int32][]*models.Transaction, error) { if uid <= 0 { return nil, errs.ErrUserIdInvalid } var startTransactionTime, endTransactionTime int64 var err error if startYear > 0 && startMonth > 0 { startTransactionTime, _, err = utils.GetTransactionTimeRangeByYearMonth(startYear, startMonth) if err != nil { return nil, errs.ErrSystemError } } if endYear > 0 && endMonth > 0 { _, endTransactionTime, err = utils.GetTransactionTimeRangeByYearMonth(endYear, endMonth) if err != nil { return nil, errs.ErrSystemError } } condition := "uid=? AND deleted=? AND (type=? OR type=? OR type=? OR type=?)" conditionParams := make([]any, 0, 6) conditionParams = append(conditionParams, uid) conditionParams = append(conditionParams, false) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_IN) minTransactionTime := startTransactionTime maxTransactionTime := endTransactionTime var allTransactions []*models.Transaction for maxTransactionTime >= 0 { var transactions []*models.Transaction finalCondition := condition finalConditionParams := make([]any, 0, 6) finalConditionParams = append(finalConditionParams, conditionParams...) if minTransactionTime > 0 { finalCondition = finalCondition + " AND transaction_time>=?" finalConditionParams = append(finalConditionParams, minTransactionTime) } if maxTransactionTime > 0 { finalCondition = finalCondition + " AND transaction_time<=?" finalConditionParams = append(finalConditionParams, maxTransactionTime) } if keyword != "" { finalCondition = finalCondition + " AND comment LIKE ?" finalConditionParams = append(finalConditionParams, "%%"+keyword+"%%") } sess := s.UserDataDB(uid).NewSession(c).Select("type, category_id, account_id, related_account_id, transaction_time, timezone_utc_offset, amount").Where(finalCondition, finalConditionParams...) sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags) err := sess.Limit(pageCountForLoadTransactionAmounts, 0).OrderBy("transaction_time desc").Find(&transactions) if err != nil { return nil, err } allTransactions = append(allTransactions, transactions...) if len(transactions) < pageCountForLoadTransactionAmounts { maxTransactionTime = -1 break } maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 } startYearMonth := startYear*100 + startMonth endYearMonth := endYear*100 + endMonth transactionsMonthlyAmountsMap := make(map[string]*models.Transaction) transactionsMonthlyAmounts := make(map[int32][]*models.Transaction) for i := 0; i < len(allTransactions); i++ { transaction := allTransactions[i] timeZone := clientTimezone if useTransactionTimezone { timeZone = time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) } yearMonth := utils.FormatUnixTimeToNumericYearMonth(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), timeZone) if (startYearMonth > 0 && yearMonth < startYearMonth) || (endYearMonth > 0 && yearMonth > endYearMonth) { continue } groupKey := fmt.Sprintf("%d_%d_%d", yearMonth, transaction.CategoryId, transaction.AccountId) if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { groupKey = fmt.Sprintf("%d_%d_%d_%d_%d", yearMonth, transaction.CategoryId, transaction.AccountId, transaction.RelatedAccountId, transaction.Type) } transactionAmounts, exists := transactionsMonthlyAmountsMap[groupKey] if !exists { transactionAmounts = &models.Transaction{ Type: transaction.Type, CategoryId: transaction.CategoryId, AccountId: transaction.AccountId, RelatedAccountId: transaction.RelatedAccountId, Amount: 0, } transactionsMonthlyAmountsMap[groupKey] = transactionAmounts } transactionAmounts.Amount += transaction.Amount } for groupKey, transaction := range transactionsMonthlyAmountsMap { groupKeyParts := strings.Split(groupKey, "_") yearMonth, _ := utils.StringToInt32(groupKeyParts[0]) monthlyAmounts, exists := transactionsMonthlyAmounts[yearMonth] if !exists { monthlyAmounts = make([]*models.Transaction, 0, 0) } monthlyAmounts = append(monthlyAmounts, transaction) transactionsMonthlyAmounts[yearMonth] = monthlyAmounts } return transactionsMonthlyAmounts, nil } // GetTransactionMapByList returns a transaction map by a list func (s *TransactionService) GetTransactionMapByList(transactions []*models.Transaction) map[int64]*models.Transaction { transactionMap := make(map[int64]*models.Transaction) for i := 0; i < len(transactions); i++ { transaction := transactions[i] transactionMap[transaction.TransactionId] = transaction } return transactionMap } // GetTransactionIds returns transaction ids list func (s *TransactionService) GetTransactionIds(transactions []*models.Transaction) []int64 { transactionIds := make([]int64, len(transactions)) for i := 0; i < len(transactions); i++ { transactionIds[i] = transactions[i].TransactionId } return transactionIds } func (s *TransactionService) doCreateTransaction(c core.Context, database *datastore.Database, sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64, pictureIds []int64, pictureUpdateModel *models.TransactionPictureInfo) error { // Get and verify source and destination account sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction) if err != nil { return err } if sourceAccount.Hidden || (destinationAccount != nil && destinationAccount.Hidden) { return errs.ErrCannotAddTransactionToHiddenAccount } if sourceAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || (destinationAccount != nil && destinationAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS) { return errs.ErrCannotAddTransactionToParentAccount } if (transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN) && sourceAccount.Currency == destinationAccount.Currency && transaction.Amount != transaction.RelatedAccountAmount { return errs.ErrTransactionSourceAndDestinationAmountNotEqual } if (transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN) && (transaction.Amount < 0 || transaction.RelatedAccountAmount < 0) { return errs.ErrTransferTransactionAmountCannotBeLessThanZero } // Get and verify category err = s.isCategoryValid(sess, transaction) if err != nil { return err } // Get and verify tags err = s.isTagsValid(sess, transaction, transactionTagIndexes, tagIds) if err != nil { return err } // Get and verify pictures err = s.isPicturesValid(sess, transaction, pictureIds) if err != nil { return err } // Verify balance modification transaction and calculate real amount if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { transaction.RelatedAccountId = transaction.AccountId transaction.RelatedAccountAmount = transaction.Amount // Amount IS the delta } else { // Not allow to add transaction before balance modification transaction otherTransactionExists := false if destinationAccount != nil && sourceAccount.AccountId != destinationAccount.AccountId { otherTransactionExists, err = sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND type=? AND (account_id=? OR account_id=?) AND transaction_time>=?", transaction.Uid, false, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, sourceAccount.AccountId, destinationAccount.AccountId, transaction.TransactionTime).Limit(1).Exist(&models.Transaction{}) } else { otherTransactionExists, err = sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND type=? AND account_id=? AND transaction_time>=?", transaction.Uid, false, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, sourceAccount.AccountId, transaction.TransactionTime).Limit(1).Exist(&models.Transaction{}) } if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to get whether other transactions exist, because %s", err.Error()) return err } else if otherTransactionExists { return errs.ErrCannotAddTransactionBeforeBalanceModificationTransaction } } // Insert transaction row var relatedTransaction *models.Transaction if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { relatedTransaction = s.GetRelatedTransferTransaction(transaction) } insertTransactionSavePointName := "insert_transaction" err = database.SetSavePoint(sess, insertTransactionSavePointName) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to set save point \"%s\", because %s", insertTransactionSavePointName, err.Error()) return err } createdRows, err := sess.Insert(transaction) if err != nil || createdRows < 1 { // maybe another transaction has same time if err != nil { log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, because %s, regenerate transaction time value", err.Error()) } else { log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, regenerate transaction time value") } err = database.RollbackToSavePoint(sess, insertTransactionSavePointName) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to rollback to save point \"%s\", because %s", insertTransactionSavePointName, err.Error()) return err } sameSecondLatestTransaction := &models.Transaction{} minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)) has, err := sess.Where("uid=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to get trasaction time, because %s", err.Error()) return err } else if !has { log.Errorf(c, "[transactions.doCreateTransaction] it should have transactions in %d - %d, but result is empty", minTransactionTime, maxTransactionTime) return errs.ErrDatabaseOperationFailed } else if sameSecondLatestTransaction.TransactionTime == maxTransactionTime-1 { return errs.ErrTooMuchTransactionInOneSecond } transaction.TransactionTime = sameSecondLatestTransaction.TransactionTime + 1 createdRows, err := sess.Insert(transaction) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction again, because %s", err.Error()) return err } else if createdRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction again") return errs.ErrDatabaseOperationFailed } } if relatedTransaction != nil { relatedTransaction.TransactionTime = transaction.TransactionTime + 1 if utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime) != utils.GetUnixTimeFromTransactionTime(relatedTransaction.TransactionTime) { return errs.ErrTooMuchTransactionInOneSecond } createdRows, err := sess.Insert(relatedTransaction) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to add related transaction, because %s", err.Error()) return err } else if createdRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to add related transaction") return errs.ErrDatabaseOperationFailed } } err = nil // Insert transaction tag index if len(transactionTagIndexes) > 0 { for i := 0; i < len(transactionTagIndexes); i++ { transactionTagIndex := transactionTagIndexes[i] transactionTagIndex.TransactionTime = transaction.TransactionTime _, err := sess.Insert(transactionTagIndex) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction tag index, because %s", err.Error()) return err } } } // Update transaction picture if len(pictureIds) > 0 { _, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", pictureIds).Update(pictureUpdateModel) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update transaction picture info, because %s", err.Error()) return err } } // Update account table if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.RelatedAccountAmount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { if transaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { if transaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { if transaction.Amount != 0 { sourceAccount.UpdatedUnixTime = time.Now().Unix() updatedSourceRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedSourceRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } if transaction.RelatedAccountAmount != 0 { destinationAccount.UpdatedUnixTime = time.Now().Unix() updatedDestinationRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount) if err != nil { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error()) return err } else if updatedDestinationRows < 1 { log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance") return errs.ErrDatabaseOperationFailed } } } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { return errs.ErrTransactionTypeInvalid } return err } func (s *TransactionService) buildTransactionQueryCondition(uid int64, maxTransactionTime int64, minTransactionTime int64, transactionDbType models.TransactionDbType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, amountFilter string, keyword string, noDuplicated bool) (string, []any) { condition := "uid=? AND deleted=?" conditionParams := make([]any, 0, 16) conditionParams = append(conditionParams, uid) conditionParams = append(conditionParams, false) if maxTransactionTime > 0 { condition = condition + " AND transaction_time<=?" conditionParams = append(conditionParams, maxTransactionTime) } if minTransactionTime > 0 { condition = condition + " AND transaction_time>=?" conditionParams = append(conditionParams, minTransactionTime) } var accountIdsCondition strings.Builder accountIdConditionParams := make([]any, 0, len(accountIds)) for i := 0; i < len(accountIds); i++ { if i > 0 { accountIdsCondition.WriteString(",") } accountIdsCondition.WriteString("?") accountIdConditionParams = append(accountIdConditionParams, accountIds[i]) } if models.TRANSACTION_DB_TYPE_MODIFY_BALANCE <= transactionDbType && transactionDbType <= models.TRANSACTION_DB_TYPE_EXPENSE { condition = condition + " AND type=?" conditionParams = append(conditionParams, transactionDbType) } else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_IN { if len(accountIds) == 0 { condition = condition + " AND type=?" conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) } else if len(accountIds) == 1 { condition = condition + " AND (type=? OR type=?)" conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_IN) } else { // len(accountsIds) > 1 condition = condition + " AND (type=? OR (type=? AND related_account_id NOT IN (" + accountIdsCondition.String() + ")))" conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_IN) conditionParams = append(conditionParams, accountIdConditionParams...) } } else { if noDuplicated { if len(accountIds) == 0 { condition = condition + " AND (type=? OR type=? OR type=? OR type=?)" conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) } else if len(accountIds) == 1 { // Do Nothing } else { // len(accountsIds) > 1 condition = condition + " AND (type=? OR type=? OR type=? OR type=? OR (type=? AND related_account_id NOT IN (" + accountIdsCondition.String() + ")))" conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_INCOME) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_EXPENSE) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_OUT) conditionParams = append(conditionParams, models.TRANSACTION_DB_TYPE_TRANSFER_IN) conditionParams = append(conditionParams, accountIdConditionParams...) } } } if len(categoryIds) > 0 { var conditions strings.Builder for i := 0; i < len(categoryIds); i++ { if i > 0 { conditions.WriteString(",") } conditions.WriteString("?") conditionParams = append(conditionParams, categoryIds[i]) } if conditions.Len() > 1 { condition = condition + " AND category_id IN (" + conditions.String() + ")" } else { condition = condition + " AND category_id = " + conditions.String() } } if len(accountIds) > 0 { if accountIdsCondition.Len() > 1 { condition = condition + " AND account_id IN (" + accountIdsCondition.String() + ")" } else { condition = condition + " AND account_id = " + accountIdsCondition.String() } conditionParams = append(conditionParams, accountIdConditionParams...) } if amountFilter != "" { amountFilterItems := strings.Split(amountFilter, ":") if len(amountFilterItems) == 2 && amountFilterItems[0] == "gt" { value, err := utils.StringToInt64(amountFilterItems[1]) if err == nil { condition = condition + " AND amount > ?" conditionParams = append(conditionParams, value) } } else if len(amountFilterItems) == 2 && amountFilterItems[0] == "lt" { value, err := utils.StringToInt64(amountFilterItems[1]) if err == nil { condition = condition + " AND amount < ?" conditionParams = append(conditionParams, value) } } else if len(amountFilterItems) == 2 && amountFilterItems[0] == "eq" { value, err := utils.StringToInt64(amountFilterItems[1]) if err == nil { condition = condition + " AND amount = ?" conditionParams = append(conditionParams, value) } } else if len(amountFilterItems) == 2 && amountFilterItems[0] == "ne" { value, err := utils.StringToInt64(amountFilterItems[1]) if err == nil { condition = condition + " AND amount <> ?" conditionParams = append(conditionParams, value) } } else if len(amountFilterItems) == 3 && amountFilterItems[0] == "bt" { value1, err := utils.StringToInt64(amountFilterItems[1]) value2, err := utils.StringToInt64(amountFilterItems[2]) if err == nil { condition = condition + " AND amount >= ? AND amount <= ?" conditionParams = append(conditionParams, value1) conditionParams = append(conditionParams, value2) } } else if len(amountFilterItems) == 3 && amountFilterItems[0] == "nb" { value1, err := utils.StringToInt64(amountFilterItems[1]) value2, err := utils.StringToInt64(amountFilterItems[2]) if err == nil { condition = condition + " AND (amount < ? OR amount > ?)" conditionParams = append(conditionParams, value1) conditionParams = append(conditionParams, value2) } } } if keyword != "" { condition = condition + " AND comment LIKE ?" conditionParams = append(conditionParams, "%%"+keyword+"%%") } return condition, conditionParams } func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Session, uid int64, maxTransactionTime int64, minTransactionTime int64, tagFilters []*models.TransactionTagFilter, noTags bool) *xorm.Session { if noTags { subQueryCondition := builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false}) if maxTransactionTime > 0 { subQueryCondition = subQueryCondition.And(builder.Lte{"transaction_time": maxTransactionTime}) } if minTransactionTime > 0 { subQueryCondition = subQueryCondition.And(builder.Gte{"transaction_time": minTransactionTime}) } subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition) sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery) return sess } if len(tagFilters) < 1 { return sess } for i := 0; i < len(tagFilters); i++ { tagFilter := tagFilters[i] subQueryCondition := builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false}) if maxTransactionTime > 0 { subQueryCondition = subQueryCondition.And(builder.Lte{"transaction_time": maxTransactionTime}) } if minTransactionTime > 0 { subQueryCondition = subQueryCondition.And(builder.Gte{"transaction_time": minTransactionTime}) } subQueryCondition = subQueryCondition.And(builder.In("tag_id", tagFilter.TagIds)) subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition) if tagFilter.Type == models.TRANSACTION_TAG_FILTER_HAS_ALL || tagFilter.Type == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL { subQuery = subQuery.GroupBy("transaction_id").Having(fmt.Sprintf("COUNT(DISTINCT tag_id) >= %d", len(tagFilter.TagIds))) } if tagFilter.Type == models.TRANSACTION_TAG_FILTER_HAS_ANY || tagFilter.Type == models.TRANSACTION_TAG_FILTER_HAS_ALL { sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery))) } else if tagFilter.Type == models.TRANSACTION_TAG_FILTER_NOT_HAS_ANY || tagFilter.Type == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL { sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery) } } return sess } func (s *TransactionService) isAccountIdValid(transaction *models.Transaction) error { if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.RelatedAccountId != 0 && transaction.RelatedAccountId != transaction.AccountId { return errs.ErrTransactionDestinationAccountCannotBeSet } } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME || transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { if transaction.RelatedAccountId != 0 { return errs.ErrTransactionDestinationAccountCannotBeSet } else if transaction.RelatedAccountAmount != 0 { return errs.ErrTransactionDestinationAmountCannotBeSet } } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { if transaction.AccountId == transaction.RelatedAccountId { return errs.ErrTransactionSourceAndDestinationIdCannotBeEqual } } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { return errs.ErrTransactionTypeInvalid } else { return errs.ErrTransactionTypeInvalid } return nil } func (s *TransactionService) getAccountModels(sess *xorm.Session, transaction *models.Transaction) (sourceAccount *models.Account, destinationAccount *models.Account, err error) { sourceAccount = &models.Account{} destinationAccount = &models.Account{} has, err := sess.ID(transaction.AccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(sourceAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrSourceAccountNotFound } // check whether the related account is valid if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.RelatedAccountId != 0 && transaction.RelatedAccountId != transaction.AccountId { return nil, nil, errs.ErrAccountIdInvalid } else { destinationAccount = sourceAccount } } else if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME || transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { if transaction.RelatedAccountId != 0 { return nil, nil, errs.ErrAccountIdInvalid } destinationAccount = nil } else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { if transaction.RelatedAccountId <= 0 { return nil, nil, errs.ErrAccountIdInvalid } else { has, err = sess.ID(transaction.RelatedAccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(destinationAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrDestinationAccountNotFound } } } // check whether the parent accounts are valid if sourceAccount.ParentAccountId > 0 && destinationAccount != nil && sourceAccount.ParentAccountId != destinationAccount.ParentAccountId && destinationAccount.ParentAccountId > 0 { var accounts []*models.Account err := sess.Where("uid=? AND deleted=? and (account_id=? or account_id=?)", transaction.Uid, false, sourceAccount.ParentAccountId, destinationAccount.ParentAccountId).Find(&accounts) if err != nil { return nil, nil, err } if len(accounts) < 2 { return nil, nil, errs.ErrAccountNotFound } for i := 0; i < len(accounts); i++ { account := accounts[i] if account.Hidden { return nil, nil, errs.ErrCannotUseHiddenAccount } } } else if sourceAccount.ParentAccountId > 0 && (destinationAccount == nil || sourceAccount.ParentAccountId == destinationAccount.ParentAccountId || destinationAccount.ParentAccountId == 0) { sourceParentAccount := &models.Account{} has, err = sess.ID(sourceAccount.ParentAccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(sourceParentAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrSourceAccountNotFound } if sourceParentAccount.Hidden { return nil, nil, errs.ErrCannotUseHiddenAccount } } else if sourceAccount.ParentAccountId == 0 && destinationAccount != nil && destinationAccount.ParentAccountId > 0 { destinationParentAccount := &models.Account{} has, err = sess.ID(destinationAccount.ParentAccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(destinationParentAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrDestinationAccountNotFound } if destinationParentAccount.Hidden { return nil, nil, errs.ErrCannotUseHiddenAccount } } return sourceAccount, destinationAccount, nil } func (s *TransactionService) getOldAccountModels(sess *xorm.Session, transaction *models.Transaction, oldTransaction *models.Transaction, sourceAccount *models.Account, destinationAccount *models.Account) (oldSourceAccount *models.Account, oldDestinationAccount *models.Account, err error) { oldSourceAccount = &models.Account{} oldDestinationAccount = &models.Account{} if transaction.AccountId == oldTransaction.AccountId { oldSourceAccount = sourceAccount } else { has, err := sess.ID(oldTransaction.AccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(oldSourceAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrSourceAccountNotFound } } if transaction.RelatedAccountId == oldTransaction.RelatedAccountId { oldDestinationAccount = destinationAccount } else { has, err := sess.ID(oldTransaction.RelatedAccountId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(oldDestinationAccount) if err != nil { return nil, nil, err } else if !has { return nil, nil, errs.ErrDestinationAccountNotFound } } return oldSourceAccount, oldDestinationAccount, nil } func (s *TransactionService) getRelatedUpdateColumns(updateCols []string) []string { relatedUpdateCols := make([]string, len(updateCols)) for i := 0; i < len(updateCols); i++ { if updateCols[i] == "account_id" { relatedUpdateCols[i] = "related_account_id" } else if updateCols[i] == "related_account_id" { relatedUpdateCols[i] = "account_id" } else if updateCols[i] == "amount" { relatedUpdateCols[i] = "related_account_amount" } else if updateCols[i] == "related_account_amount" { relatedUpdateCols[i] = "amount" } else { relatedUpdateCols[i] = updateCols[i] } } return relatedUpdateCols } func (s *TransactionService) isCategoryValid(sess *xorm.Session, transaction *models.Transaction) error { if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { if transaction.CategoryId != 0 { return errs.ErrBalanceModificationTransactionCannotSetCategory } } else { category := &models.TransactionCategory{} has, err := sess.ID(transaction.CategoryId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(category) if err != nil { return err } else if !has { return errs.ErrTransactionCategoryNotFound } if category.Hidden { return errs.ErrCannotUseHiddenTransactionCategory } if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId { return errs.ErrCannotUsePrimaryCategoryForTransaction } if (transaction.Type == models.TRANSACTION_DB_TYPE_INCOME && category.Type != models.CATEGORY_TYPE_INCOME) || (transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE && category.Type != models.CATEGORY_TYPE_EXPENSE) || ((transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN) && category.Type != models.CATEGORY_TYPE_TRANSFER) { return errs.ErrTransactionCategoryTypeInvalid } parentCategory := &models.TransactionCategory{} has, err = sess.ID(category.ParentCategoryId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(parentCategory) if err != nil { return err } else if !has { return errs.ErrTransactionCategoryNotFound } if parentCategory.Hidden { return errs.ErrCannotUseHiddenTransactionCategory } } return nil } func (s *TransactionService) isTagsValid(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error { if len(transactionTagIndexes) > 0 { var tags []*models.TransactionTag err := sess.Where("uid=? AND deleted=?", transaction.Uid, false).In("tag_id", tagIds).Find(&tags) if err != nil { return err } tagMap := make(map[int64]*models.TransactionTag) for i := 0; i < len(tags); i++ { if tags[i].Hidden { return errs.ErrCannotUseHiddenTransactionTag } tagMap[tags[i].TagId] = tags[i] } for i := 0; i < len(transactionTagIndexes); i++ { if _, exists := tagMap[transactionTagIndexes[i].TagId]; !exists { return errs.ErrTransactionTagNotFound } } } return nil } func (s *TransactionService) isPicturesValid(sess *xorm.Session, transaction *models.Transaction, pictureIds []int64) error { if len(pictureIds) > 0 { var pictureInfos []*models.TransactionPictureInfo err := sess.Where("uid=? AND deleted=?", transaction.Uid, false).In("picture_id", pictureIds).Find(&pictureInfos) if err != nil { return err } pictureInfoMap := make(map[int64]*models.TransactionPictureInfo) for i := 0; i < len(pictureInfos); i++ { if pictureInfos[i].TransactionId != models.TransactionPictureNewPictureTransactionId && pictureInfos[i].TransactionId != transaction.TransactionId { return errs.ErrTransactionPictureIdInvalid } pictureInfoMap[pictureInfos[i].PictureId] = pictureInfos[i] } for i := 0; i < len(pictureIds); i++ { if _, exists := pictureInfoMap[pictureIds[i]]; !exists { return errs.ErrTransactionPictureNotFound } } } return nil }