From ead072ed3140732e9318c1747926234b0cc21ec9 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Mon, 29 Mar 2021 00:57:16 +0800 Subject: [PATCH] add api for getting total income/expense amounts by month --- cmd/webserver.go | 1 + pkg/api/transactions.go | 109 +++++++++++++++++++++++++++++++++++ pkg/errs/global.go | 1 + pkg/models/transaction.go | 76 ++++++++++++++++++++++++ pkg/services/transactions.go | 67 +++++++++++++++++++++ pkg/utils/datetimes.go | 19 ++++++ src/locales/en.js | 2 + src/locales/zh_Hans.js | 2 + 8 files changed, 277 insertions(+) diff --git a/cmd/webserver.go b/cmd/webserver.go index b1cf9c80..2b8fa6d3 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -206,6 +206,7 @@ func startWebServer(c *cli.Context) error { apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler)) apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler)) apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler)) + apiV1Route.GET("/transactions/amounts/by_month.json", bindApi(api.Transactions.TransactionMonthAmountsHandler)) apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler)) apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler)) apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler)) diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index 2b243577..af28e7eb 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -2,6 +2,7 @@ package api import ( "sort" + "strings" "github.com/mayswind/lab/pkg/core" "github.com/mayswind/lab/pkg/errs" @@ -11,6 +12,8 @@ import ( "github.com/mayswind/lab/pkg/utils" ) +const pageCountForLoadTransactionAmounts = 1000 + // TransactionsApi represents transaction api type TransactionsApi struct { transactions *services.TransactionService @@ -335,6 +338,112 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.Context) (interface{ return amountsResp, nil } +// TransactionMonthAmountsHandler returns every month transaction amounts of current user +func (a *TransactionsApi) TransactionMonthAmountsHandler(c *core.Context) (interface{}, *errs.Error) { + var transactionAmountsReq models.TransactionMonthAmountsRequest + err := c.ShouldBindQuery(&transactionAmountsReq) + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + utcOffset, err := c.GetClientTimezoneOffset() + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] cannot get client timezone offset, because %s", err.Error()) + return nil, errs.ErrClientTimezoneOffsetInvalid + } + + startTime, endTime, err := transactionAmountsReq.GetStartTimeAndEndTime(utcOffset) + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] parse request start or end date failed, because %s", err.Error()) + return nil, errs.ErrParameterInvalid + } + + uid := c.GetCurrentUid() + + accounts, err := a.accounts.GetAllAccountsByUid(uid) + accountMap := a.accounts.GetAccountMapByList(accounts) + + if err != nil { + log.ErrorfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + totalAmounts, err := a.transactions.GetAccountsMonthTotalIncomeAndExpense(uid, startTime, endTime, pageCountForLoadTransactionAmounts) + amountsMap := make(map[string]map[string]*models.TransactionAmountsResponseItemAmountInfo) + + for yearMonth, monthAccountsAmounts := range totalAmounts { + for accountId, monthAccountAmounts := range monthAccountsAmounts { + account, exists := accountMap[accountId] + + if !exists { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] cannot find account for account \"id:%d\" of user \"uid:%d\", because %s", accountId, uid) + continue + } + + monthTotalAmounts, exists := amountsMap[yearMonth] + + if !exists { + monthTotalAmounts = make(map[string]*models.TransactionAmountsResponseItemAmountInfo) + amountsMap[yearMonth] = monthTotalAmounts + } + + monthTotalAmount, exists := monthTotalAmounts[account.Currency] + + if !exists { + monthTotalAmount = &models.TransactionAmountsResponseItemAmountInfo{ + Currency: account.Currency, + IncomeAmount: 0, + ExpenseAmount: 0, + } + } + + monthTotalAmount.IncomeAmount += monthAccountAmounts.TotalIncomeAmount + monthTotalAmount.ExpenseAmount += monthAccountAmounts.TotalExpenseAmount + + monthTotalAmounts[account.Currency] = monthTotalAmount + } + } + + amountsResp := make(models.TransactionMonthAmountsResponseItemSlice, 0) + + for yearMonth, monthTotalAmounts := range amountsMap { + yearMonthItems := strings.Split(yearMonth, "-") + year, err := utils.StringToInt32(yearMonthItems[0]) + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] cannot get year from year-month item \"%s\" for user \"uid:%d\", because %s", yearMonth, uid) + continue + } + + month, err := utils.StringToInt32(yearMonthItems[1]) + + if err != nil { + log.WarnfWithRequestId(c, "[transactions.TransactionMonthAmountsHandler] cannot get month from year-month item \"%s\" for user \"uid:%d\", because %s", yearMonth, uid) + continue + } + + amounts := make([]*models.TransactionAmountsResponseItemAmountInfo, 0, len(monthTotalAmounts)) + + for _, monthTotalAmount := range monthTotalAmounts { + amounts = append(amounts, monthTotalAmount) + } + + amountsResp = append(amountsResp, &models.TransactionMonthAmountsResponseItem{ + Year: year, + Month: month, + Amounts: amounts, + }) + } + + sort.Sort(amountsResp) + + return amountsResp, nil +} + // TransactionGetHandler returns one specific transaction of current user func (a *TransactionsApi) TransactionGetHandler(c *core.Context) (interface{}, *errs.Error) { var transactionGetReq models.TransactionGetRequest diff --git a/pkg/errs/global.go b/pkg/errs/global.go index 1bc46850..5501c67e 100644 --- a/pkg/errs/global.go +++ b/pkg/errs/global.go @@ -19,6 +19,7 @@ var ( ErrQueryItemsEmpty = NewNormalError(NormalSubcategoryGlobal, 9, http.StatusBadRequest, "query items cannot be empty") ErrQueryItemsTooMuch = NewNormalError(NormalSubcategoryGlobal, 10, http.StatusBadRequest, "query items too much") ErrQueryItemsInvalid = NewNormalError(NormalSubcategoryGlobal, 11, http.StatusBadRequest, "query items have invalid item") + ErrParameterInvalid = NewNormalError(NormalSubcategoryGlobal, 12, http.StatusBadRequest, "parameter invalid") ) // GetParameterInvalidMessage returns specific error message for invalid parameter error diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 77a6f9ea..2533e86c 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -1,7 +1,9 @@ package models import ( + "fmt" "strings" + "time" "github.com/mayswind/lab/pkg/errs" "github.com/mayswind/lab/pkg/utils" @@ -135,6 +137,12 @@ type TransactionAmountsRequestItem struct { EndTime int64 } +// TransactionMonthAmountsRequest represents all parameters of transaction month amounts request +type TransactionMonthAmountsRequest struct { + StartYearMonth string `form:"start_year_month"` + EndYearMonth string `form:"end_year_month"` +} + // TransactionGetRequest represents all parameters of transaction getting request type TransactionGetRequest struct { Id int64 `form:"id,string" binding:"required,min=1"` @@ -148,6 +156,16 @@ type TransactionDeleteRequest struct { Id int64 `json:"id,string" binding:"required,min=1"` } +// TransactionAccountsAmount represents transaction accounts amount map +type TransactionAccountsAmount map[int64]*TransactionAccountAmount + +// TransactionAccountAmount represents transaction account amount +type TransactionAccountAmount struct { + AccountId int64 + TotalIncomeAmount int64 + TotalExpenseAmount int64 +} + // TransactionInfoResponse represents a view-object of transaction type TransactionInfoResponse struct { Id int64 `json:"id,string"` @@ -207,6 +225,13 @@ type TransactionAmountsResponseItem struct { Amounts []*TransactionAmountsResponseItemAmountInfo `json:"amounts"` } +// TransactionMonthAmountsResponseItem represents an item of transaction month amounts +type TransactionMonthAmountsResponseItem struct { + Year int `json:"year"` + Month int `json:"month"` + Amounts []*TransactionAmountsResponseItemAmountInfo `json:"amounts"` +} + // TransactionAmountsResponseItemAmountInfo represents amount info for an response item type TransactionAmountsResponseItemAmountInfo struct { Currency string `json:"currency"` @@ -321,6 +346,35 @@ func (t *TransactionAmountsRequest) GetTransactionAmountsRequestItems() ([]*Tran return requestItems, nil } +// GetStartTimeAndEndTime returns start unix time and end unix time by request parameter +func (t *TransactionMonthAmountsRequest) GetStartTimeAndEndTime(utcOffset int16) (int64, int64, error) { + startUnixTime := int64(0) + endUnixTime := time.Now().Unix() + + if t.StartYearMonth != "" { + startTime, err := utils.ParseFromShortDateTime(fmt.Sprintf("%s-1 0:0:0", t.StartYearMonth), utcOffset) + + if err != nil { + return 0, 0, err + } + + startUnixTime = startTime.Unix() + } + + if t.EndYearMonth != "" { + endTime, err := utils.ParseFromShortDateTime(fmt.Sprintf("%s-1 0:0:0", t.EndYearMonth), utcOffset) + + if err != nil { + return 0, 0, err + } + + endTime = endTime.AddDate(0, 1, 0) + endUnixTime = endTime.Unix() - 1 + } + + return startUnixTime, endUnixTime, nil +} + // TransactionInfoResponseSlice represents the slice data structure of TransactionInfoResponse type TransactionInfoResponseSlice []*TransactionInfoResponse @@ -342,3 +396,25 @@ func (s TransactionInfoResponseSlice) Less(i, j int) bool { return s[i].Id > s[j].Id } + +// TransactionMonthAmountsResponseItemSlice represents the slice data structure of TransactionMonthAmountsResponseItem +type TransactionMonthAmountsResponseItemSlice []*TransactionMonthAmountsResponseItem + +// Len returns the count of items +func (s TransactionMonthAmountsResponseItemSlice) Len() int { + return len(s) +} + +// Swap swaps two items +func (s TransactionMonthAmountsResponseItemSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less reports whether the first item is less than the second one +func (s TransactionMonthAmountsResponseItemSlice) Less(i, j int) bool { + if s[i].Year != s[j].Year { + return s[i].Year > s[j].Year + } + + return s[i].Month > s[j].Month +} diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go index 3b95733f..6020fb33 100644 --- a/pkg/services/transactions.go +++ b/pkg/services/transactions.go @@ -969,6 +969,73 @@ func (s *TransactionService) GetAccountsTotalIncomeAndExpense(uid int64, startUn return incomeAmounts, expenseAmounts, nil } +// GetAccountsMonthTotalIncomeAndExpense returns the every accounts total income and expense amount in month by specific date range +func (s *TransactionService) GetAccountsMonthTotalIncomeAndExpense(uid int64, startUnixTime int64, endUnixTime int64, pageCount int) (map[string]models.TransactionAccountsAmount, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + startTransactionTime := utils.GetMinTransactionTimeFromUnixTime(startUnixTime) + endTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(endUnixTime) + + minTransactionTime := startTransactionTime + maxTransactionTime := endTransactionTime + var allTransactions []*models.Transaction + + for maxTransactionTime > 0 { + var transactions []*models.Transaction + + err := s.UserDataDB(uid).Select("uid, type, account_id, transaction_time, timezone_utc_offset, amount").Where("uid=? AND deleted=? AND (type=? OR type=?) AND transaction_time>=? AND transaction_time<=?", uid, false, models.TRANSACTION_DB_TYPE_INCOME, models.TRANSACTION_DB_TYPE_EXPENSE, minTransactionTime, maxTransactionTime).Limit(pageCount, 0).OrderBy("transaction_time desc").Find(&transactions) + + if err != nil { + return nil, err + } + + allTransactions = append(allTransactions, transactions...) + + if len(transactions) < pageCount { + maxTransactionTime = 0 + break + } + + maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1 + } + + totalAmounts := make(map[string]models.TransactionAccountsAmount) + + for i := 0; i < len(allTransactions); i++ { + transaction := allTransactions[i] + transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) + yearMonth := utils.FormatUnixTimeToYearMonth(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone) + + monthAccountsAmounts, exists := totalAmounts[yearMonth] + + if !exists { + monthAccountsAmounts = make(models.TransactionAccountsAmount) + totalAmounts[yearMonth] = monthAccountsAmounts + } + + monthAccountAmount, exists := monthAccountsAmounts[transaction.AccountId] + + if !exists { + monthAccountAmount = &models.TransactionAccountAmount{ + AccountId: transaction.AccountId, + TotalIncomeAmount: 0, + TotalExpenseAmount: 0, + } + monthAccountsAmounts[transaction.AccountId] = monthAccountAmount + } + + if transaction.Type == models.TRANSACTION_DB_TYPE_INCOME { + monthAccountAmount.TotalIncomeAmount += transaction.Amount + } else if transaction.Type == models.TRANSACTION_DB_TYPE_EXPENSE { + monthAccountAmount.TotalExpenseAmount += transaction.Amount + } + } + + return totalAmounts, nil +} + // GetAccountsAndCategoriesTotalIncomeAndExpense returns the every accounts and categories total income and expense amount by specific date range func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(uid int64, startUnixTime int64, endUnixTime int64) ([]*models.Transaction, error) { if uid <= 0 { diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index 8e950085..fde044cf 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -5,6 +5,8 @@ import "time" const ( longDateTimeFormat = "2006-01-02 15:04:05" longDateTimeWithoutSecondFormat = "2006-01-02 15:04" + shortDateTimeFormat = "2006-1-2 15:4:5" + yearMonthDateTimeFormat = "2006-01" ) // FormatUnixTimeToLongDateTimeInServerTimezone returns a textual representation of the unix time formatted by long date time format @@ -23,6 +25,17 @@ func FormatUnixTimeToLongDateTimeWithoutSecond(unixTime int64, timezone *time.Lo return t.Format(longDateTimeWithoutSecondFormat) } +// FormatUnixTimeToYearMonth returns year and month of specified unix time +func FormatUnixTimeToYearMonth(unixTime int64, timezone *time.Location) string { + t := ParseFromUnixTime(unixTime) + + if timezone != nil { + t = t.In(timezone) + } + + return t.Format(yearMonthDateTimeFormat) +} + // ParseFromUnixTime parses a unix time and returns a golang time struct func ParseFromUnixTime(unixTime int64) time.Time { return time.Unix(unixTime, 0) @@ -34,6 +47,12 @@ func ParseFromLongDateTime(t string, utcOffset int16) (time.Time, error) { return time.ParseInLocation(longDateTimeFormat, t, timezone) } +// ParseFromShortDateTime parses a formatted string in short date time format +func ParseFromShortDateTime(t string, utcOffset int16) (time.Time, error) { + timezone := time.FixedZone("Timezone", int(utcOffset)*60) + return time.ParseInLocation(shortDateTimeFormat, t, timezone) +} + // GetMinTransactionTimeFromUnixTime returns the minimum transaction time from unix time func GetMinTransactionTimeFromUnixTime(unixTime int64) int64 { return unixTime * 1000 diff --git a/src/locales/en.js b/src/locales/en.js index 9df430cb..07f4c78b 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -564,6 +564,8 @@ export default { 'transaction tag is in use and cannot be deleted': 'Transaction tag is in use and it cannot be deleted', 'query items cannot be empty': 'There are no query items', 'query items too much': 'There are too many query items', + 'query items have invalid item': 'There is invalid item in query items', + 'parameter invalid': 'Parameter is invalid', }, 'parameter': { 'id': 'ID', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index b32a0007..b6ebdd8f 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -564,6 +564,8 @@ export default { 'transaction tag is in use and cannot be deleted': '交易标签正在被使用,无法删除', 'query items cannot be empty': '请求项目不能为空', 'query items too much': '请求项目过多', + 'query items have invalid item': '请求项目中有非法项目', + 'parameter invalid': '参数错误', }, 'parameter': { 'id': 'ID',