diff --git a/cmd/webserver.go b/cmd/webserver.go index 85987a98..6d3cbe68 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -185,6 +185,9 @@ func startWebServer(c *cli.Context) error { apiV1Route.POST("/users/2fa/recovery/regenerate.json", bindApi(api.TwoFactorAuthorizations.TwoFactorRecoveryCodeRegenerateHandler)) } + // Overview + apiV1Route.GET("/overviews/transaction.json", bindApi(api.Overviews.TransactionOverviewHandler)) + // Accounts apiV1Route.GET("/accounts/list.json", bindApi(api.Accounts.AccountListHandler)) apiV1Route.GET("/accounts/get.json", bindApi(api.Accounts.AccountGetHandler)) diff --git a/pkg/api/overviews.go b/pkg/api/overviews.go new file mode 100644 index 00000000..d1c9f1b4 --- /dev/null +++ b/pkg/api/overviews.go @@ -0,0 +1,103 @@ +package api + +import ( + "strings" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" + "github.com/mayswind/lab/pkg/utils" +) + +// OverviewApi represents overview api +type OverviewApi struct { + transactions *services.TransactionService +} + +// Initialize an overview api singleton instance +var ( + Overviews = &OverviewApi{ + transactions: services.Transactions, + } +) + +// TransactionOverviewHandler returns transaction over of current user +func (a *OverviewApi) TransactionOverviewHandler(c *core.Context) (interface{}, *errs.Error) { + var transactionOverviewReq models.TransactionOverviewRequest + err := c.ShouldBindQuery(&transactionOverviewReq) + + if err != nil { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + items := strings.Split(transactionOverviewReq.Query, "|") + requestItems := make([]*models.TransactionOverviewRequestItem, 0, len(items)) + + for i := 0; i < len(items); i++ { + itemValues := strings.Split(items[i], "_") + + if len(itemValues) != 3 { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request item failed, because its not valid item, content is \"%s\"", items[i]) + continue + } + + startTime, err := utils.StringToInt64(itemValues[1]) + + if err != nil { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request item start time failed, because %s", err.Error()) + continue + } + + endTime, err := utils.StringToInt64(itemValues[2]) + + if err != nil { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request item end time failed, because %s", err.Error()) + continue + } + + requestItem := &models.TransactionOverviewRequestItem{ + Name: itemValues[0], + StartTime: startTime, + EndTime: endTime, + } + + requestItems = append(requestItems, requestItem) + } + + if len(requestItems) < 1 { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request failed, because there are no valid items") + return nil, errs.ErrQueryItemsEmpty + } + + if len(requestItems) > 5 { + log.WarnfWithRequestId(c, "[overviews.TransactionOverviewHandler] parse request failed, because there are too many items") + return nil, errs.ErrQueryItemsTooMuch + } + + uid := c.GetCurrentUid() + + overviewResp := make(map[string]*models.TransactionOverviewResponseItem) + + for i := 0; i < len(requestItems); i++ { + requestItem := requestItems[i] + + incomeAmount, expenseAmount, err := a.transactions.GetTotalIncomeAndExpenseByDateRange(uid, requestItem.StartTime, requestItem.EndTime) + + if err != nil { + log.ErrorfWithRequestId(c, "[overviews.TransactionOverviewHandler] failed to get transaction overview item for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + overviewResp[requestItem.Name] = &models.TransactionOverviewResponseItem{ + StartTime: requestItem.StartTime, + EndTime: requestItem.EndTime, + IncomeAmount: incomeAmount, + ExpenseAmount: expenseAmount, + } + } + + return overviewResp, nil +} diff --git a/pkg/errs/error.go b/pkg/errs/error.go index 1d78be07..17192dfd 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -27,6 +27,7 @@ const ( NormalSubcategoryCategory = 6 NormalSubcategoryTag = 7 NormalSubcategoryDataManagement = 8 + NormalSubcategoryOverview = 9 ) // Error represents the specific error returned to user diff --git a/pkg/errs/overview.go b/pkg/errs/overview.go new file mode 100644 index 00000000..0eb3c35b --- /dev/null +++ b/pkg/errs/overview.go @@ -0,0 +1,9 @@ +package errs + +import "net/http" + +// Error codes related to overview +var ( + ErrQueryItemsEmpty = NewNormalError(NormalSubcategoryOverview, 0, http.StatusBadRequest, "query items cannot be empty") + ErrQueryItemsTooMuch = NewNormalError(NormalSubcategoryOverview, 1, http.StatusBadRequest, "query items too much") +) diff --git a/pkg/models/overview.go b/pkg/models/overview.go new file mode 100644 index 00000000..48e62d3c --- /dev/null +++ b/pkg/models/overview.go @@ -0,0 +1,21 @@ +package models + +// TransactionOverviewRequest represents all parameters of transaction overview request +type TransactionOverviewRequest struct { + Query string `form:"query"` +} + +// TransactionOverviewRequestItem represents an item of transaction overview request +type TransactionOverviewRequestItem struct { + Name string + StartTime int64 + EndTime int64 +} + +// TransactionOverviewResponseItem represents an item of transaction overview +type TransactionOverviewResponseItem struct { + StartTime int64 `json:"startTime"` + EndTime int64 `json:"endTime"` + IncomeAmount int64 `json:"incomeAmount"` + ExpenseAmount int64 `json:"expenseAmount"` +} diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index c87bdcf2..81415541 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -44,6 +44,12 @@ type Transaction struct { DeletedUnixTime int64 } +type TransactionTotalAmount struct { + Uid int64 + Type TransactionDbType + TotalAmount int64 `xorm:"NOT NULL"` +} + // TransactionCreateRequest represents all parameters of transaction creation request type TransactionCreateRequest struct { Type TransactionType `json:"type" binding:"required"` diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go index 94d63ab4..14b16943 100644 --- a/pkg/services/transactions.go +++ b/pkg/services/transactions.go @@ -922,6 +922,38 @@ func (s *TransactionService) GetRelatedTransferTransaction(originalTransaction * return relatedTransaction } +// GetTotalIncomeAndExpenseByDateRange returns the total income and expense amount by specific date range +func (s *TransactionService) GetTotalIncomeAndExpenseByDateRange(uid int64, startUnixTime int64, endUnixTime int64) (int64, int64, error) { + if uid <= 0 { + return 0, 0, errs.ErrUserIdInvalid + } + + startTransactionTime := utils.GetMinTransactionTimeFromUnixTime(startUnixTime) + endTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(endUnixTime) + + var transactionTotalAmounts []*models.Transaction + err := s.UserDataDB(uid).Select("uid, type, SUM(amount) as 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, startTransactionTime, endTransactionTime).GroupBy("type").Find(&transactionTotalAmounts) + + if err != nil { + return 0, 0, err + } + + var incomeAmount int64 + var expenseAmount int64 + + for i := 0; i < len(transactionTotalAmounts); i++ { + transactionTotalAmount := transactionTotalAmounts[i] + + if transactionTotalAmount.Type == models.TRANSACTION_DB_TYPE_INCOME { + incomeAmount = transactionTotalAmount.Amount + } else if transactionTotalAmount.Type == models.TRANSACTION_DB_TYPE_EXPENSE { + expenseAmount = transactionTotalAmount.Amount + } + } + + return incomeAmount, expenseAmount, nil +} + 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 { diff --git a/src/lib/services.js b/src/lib/services.js index a63d5473..1a4db829 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -159,6 +159,27 @@ export default { password }); }, + getTransactionOverview: ( { today, thisWeek, thisMonth, thisYear } ) => { + const queryParams = []; + + if (today) { + queryParams.push(`today_${today.startTime}_${today.endTime}`); + } + + if (thisWeek) { + queryParams.push(`thisWeek_${thisWeek.startTime}_${thisWeek.endTime}`); + } + + if (thisMonth) { + queryParams.push(`thisMonth_${thisMonth.startTime}_${thisMonth.endTime}`); + } + + if (thisYear) { + queryParams.push(`thisYear_${thisYear.startTime}_${thisYear.endTime}`); + } + + return axios.get('v1/overviews/transaction.json' + (queryParams.length ? '?query=' + queryParams.join('|') : '')); + }, getAllAccounts: ({ visibleOnly }) => { return axios.get('v1/accounts/list.json?visible_only=' + !!visibleOnly); }, diff --git a/src/locales/en.js b/src/locales/en.js index f49e9261..8d1954f8 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -10,7 +10,9 @@ export default { 'format': { 'date': { 'long': 'MM/DD/YYYY', - 'yearMonth': 'YYYY-MM' + 'year': 'YYYY', + 'yearMonth': 'YYYY-M', + 'monthDay': 'M/D' }, 'datetime': { 'long': 'MM/DD/YYYY HH:mm:ss', @@ -362,6 +364,8 @@ export default { 'transaction tag name is empty': 'Transaction tag title is empty', 'transaction tag name already exists': 'Transaction tag title already exists', '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', }, 'parameter': { 'id': 'ID', @@ -496,6 +500,10 @@ export default { 'Sign Up': 'Sign Up', 'Transaction List': 'Transaction List', 'Account List': 'Account List', + 'This Week': 'This Week', + 'This Month': 'This Month', + 'This Year': 'This Year', + 'Unable to get transaction overview': 'Unable to get transaction overview', 'Net assets': 'Net assets', 'Total assets': 'Total assets', 'Total liabilities': 'Total liabilities', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index 795a8244..5eb64c5e 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -10,7 +10,9 @@ export default { 'format': { 'date': { 'long': 'YYYY年MM月DD日', - 'yearMonth': 'YYYY年MM月' + 'year': 'YYYY年', + 'yearMonth': 'YYYY年M月', + 'monthDay': 'M月D日' }, 'datetime': { 'long': 'YYYY年MM月DD日 HH:mm:ss', @@ -362,6 +364,8 @@ export default { 'transaction tag name is empty': '交易标签标题不能为空', 'transaction tag name already exists': '交易标签标题已经存在', 'transaction tag is in use and cannot be deleted': '交易标签正在被使用,无法删除', + 'query items cannot be empty': '请求项目不能为空', + 'query items too much': '请求项目过多', }, 'parameter': { 'id': 'ID', @@ -496,6 +500,10 @@ export default { 'Sign Up': '注册', 'Transaction List': '交易列表', 'Account List': '账户列表', + 'This Week': '本周', + 'This Month': '本月', + 'This Year': '今年', + 'Unable to get transaction overview': '无法获取交易概要', 'Net assets': '净资产', 'Total assets': '总资产', 'Total liabilities': '总负债', diff --git a/src/store/index.js b/src/store/index.js index 1d4dd7f5..a4ae2236 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -40,6 +40,9 @@ import { UPDATE_TAG_VISIBILITY_IN_TRANSACTION_TAG_LIST, REMOVE_TAG_FROM_TRANSACTION_TAG_LIST, UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE, + + LOAD_TRANSACTION_OVERVIEW, + UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, } from './mutations.js'; import { @@ -79,6 +82,10 @@ import { clearExchangeRatesFromLocalStorage, } from './exchangeRates.js'; +import { + loadTransactionOverview +} from './overview.js'; + import { loadAllAccounts, getAccount, @@ -154,6 +161,8 @@ const stores = { allTransactionTags: [], allTransactionTagsMap: {}, transactionTagListStateInvalid: true, + transactionOverview: {}, + transactionOverviewStateInvalid: true, }, getters: { // user @@ -706,6 +715,12 @@ const stores = { [UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE] (state, invalidState) { state.transactionTagListStateInvalid = invalidState; }, + [LOAD_TRANSACTION_OVERVIEW] (state, transactionOverview) { + state.transactionOverview = transactionOverview; + }, + [UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE] (state, invalidState) { + state.transactionOverviewStateInvalid = invalidState; + }, }, actions: { // user @@ -734,6 +749,9 @@ const stores = { // exchange rates getLatestExchangeRates, + // overview + loadTransactionOverview, + // account loadAllAccounts, saveAccount, diff --git a/src/store/mutations.js b/src/store/mutations.js index 0c87f166..c90938fb 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -36,3 +36,6 @@ export const CHANGE_TAG_DISPLAY_ORDER_IN_TRANSACTION_TAG_LIST = 'CHANGE_TAG_DISP export const UPDATE_TAG_VISIBILITY_IN_TRANSACTION_TAG_LIST = 'UPDATE_TAG_VISIBILITY_IN_TRANSACTION_TAG_LIST'; export const REMOVE_TAG_FROM_TRANSACTION_TAG_LIST = 'REMOVE_TAG_FROM_TRANSACTION_TAG_LIST'; export const UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE = 'UPDATE_TRANSACTION_TAG_LIST_INVALID_STATE'; + +export const LOAD_TRANSACTION_OVERVIEW = 'LOAD_TRANSACTION_OVERVIEW'; +export const UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE = 'UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE'; diff --git a/src/store/overview.js b/src/store/overview.js new file mode 100644 index 00000000..e6a66900 --- /dev/null +++ b/src/store/overview.js @@ -0,0 +1,53 @@ +import services from '../lib/services.js'; +import logger from '../lib/logger.js'; + +import { + LOAD_TRANSACTION_OVERVIEW, + UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, +} from './mutations.js'; + +export function loadTransactionOverview(context, { dateRange, force }) { + if (!force && !context.state.transactionOverviewStateInvalid) { + return new Promise((resolve) => { + resolve(context.state.transactionOverview); + }); + } + + return new Promise((resolve, reject) => { + services.getTransactionOverview({ + today: dateRange.today, + thisWeek: dateRange.thisWeek, + thisMonth: dateRange.thisMonth, + thisYear: dateRange.thisYear + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to get transaction overview' }); + return; + } + + context.commit(LOAD_TRANSACTION_OVERVIEW, data.result); + + if (context.state.transactionOverviewStateInvalid) { + context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, false); + } + + resolve(data.result); + }).catch(error => { + if (force) { + logger.error('failed to force load transaction overview', error); + } else { + logger.error('failed to load transaction overview', error); + } + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to get transaction overview' }); + } else { + reject(error); + } + }); + }); +} diff --git a/src/store/transaction.js b/src/store/transaction.js index daf32345..d900ff43 100644 --- a/src/store/transaction.js +++ b/src/store/transaction.js @@ -14,6 +14,7 @@ import { REMOVE_TRANSACTION_FROM_TRANSACTION_LIST, UPDATE_TRANSACTION_LIST_INVALID_STATE, UPDATE_ACCOUNT_LIST_INVALID_STATE, + UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, } from './mutations.js'; const emptyTransactionResult = { @@ -169,6 +170,7 @@ export function saveTransaction(context, { transaction, defaultCurrency }) { } context.commit(UPDATE_ACCOUNT_LIST_INVALID_STATE, true); + context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); resolve(data.result); }).catch(error => { @@ -216,6 +218,7 @@ export function deleteTransaction(context, { transaction, defaultCurrency, befor } context.commit(UPDATE_ACCOUNT_LIST_INVALID_STATE, true); + context.commit(UPDATE_TRANSACTION_OVERVIEW_INVALID_STATE, true); resolve(data.result); }).catch(error => { diff --git a/src/views/mobile/Home.vue b/src/views/mobile/Home.vue index 3f193c16..8f833f20 100644 --- a/src/views/mobile/Home.vue +++ b/src/views/mobile/Home.vue @@ -1,12 +1,184 @@ - + - - - + + + + + + + + + Today + + + + + 0.00 USD + + + 0.00 USD + + + + + + + + + + This week + + + + + 0.00 USD + + + 0.00 USD + + + + + + + + + + This month + + + + + 0.00 USD + + + 0.00 USD + + + + + + + + + + This year + + + + + 0.00 USD + + + 0.00 USD + + + + + + + + + + + + + + + + {{ $t('Today' )}} + + + + + {{ transactionOverview.today.incomeAmount | currency(defaultCurrency) }} + + + {{ transactionOverview.today.expenseAmount | currency(defaultCurrency) }} + + + + + + + + + + {{ $t('This Week' )}} + + + + + {{ transactionOverview.thisWeek.incomeAmount | currency(defaultCurrency) }} + + + {{ transactionOverview.thisWeek.expenseAmount | currency(defaultCurrency) }} + + + + + + + + + + {{ $t('This Month' )}} + + + + + {{ transactionOverview.thisMonth.incomeAmount | currency(defaultCurrency) }} + + + {{ transactionOverview.thisMonth.expenseAmount | currency(defaultCurrency) }} + + + + + + + + + + {{ $t('This Year' )}} + + + + + {{ transactionOverview.thisYear.incomeAmount | currency(defaultCurrency) }} + + + {{ transactionOverview.thisYear.expenseAmount | currency(defaultCurrency) }} + + + + + + @@ -33,10 +205,118 @@