Skip to content

Commit

Permalink
support setting timezone type for the time range of statistical data
Browse files Browse the repository at this point in the history
  • Loading branch information
mayswind committed Apr 13, 2024
1 parent 14f6de8 commit f4530e1
Show file tree
Hide file tree
Showing 18 changed files with 398 additions and 31 deletions.
18 changes: 16 additions & 2 deletions pkg/api/transactions.go
Expand Up @@ -238,8 +238,15 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.Context) (any, *e
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}

utcOffset, err := c.GetClientTimezoneOffset()

if err != nil {
log.WarnfWithRequestId(c, "[transactions.TransactionStatisticsHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}

uid := c.GetCurrentUid()
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime)
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, utcOffset, statisticReq.UseTransactionTimezone)

if err != nil {
log.ErrorfWithRequestId(c, "[transactions.TransactionStatisticsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
Expand Down Expand Up @@ -292,6 +299,13 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.Context) (any, *errs
return nil, errs.ErrQueryItemsTooMuch
}

utcOffset, err := c.GetClientTimezoneOffset()

if err != nil {
log.WarnfWithRequestId(c, "[transactions.TransactionAmountsHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}

uid := c.GetCurrentUid()

accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
Expand All @@ -307,7 +321,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.Context) (any, *errs
for i := 0; i < len(requestItems); i++ {
requestItem := requestItems[i]

incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime)
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, utcOffset, transactionAmountsReq.UseTransactionTimezone)

if err != nil {
log.ErrorfWithRequestId(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
Expand Down
8 changes: 5 additions & 3 deletions pkg/models/transaction.go
Expand Up @@ -135,13 +135,15 @@ type TransactionListInMonthByPageRequest struct {

// TransactionStatisticRequest represents all parameters of transaction statistic request
type TransactionStatisticRequest struct {
StartTime int64 `form:"start_time" binding:"min=0"`
EndTime int64 `form:"end_time" binding:"min=0"`
StartTime int64 `form:"start_time" binding:"min=0"`
EndTime int64 `form:"end_time" binding:"min=0"`
UseTransactionTimezone bool `form:"use_transaction_timezone"`
}

// TransactionAmountsRequest represents all parameters of transaction amounts request
type TransactionAmountsRequest struct {
Query string `form:"query"`
Query string `form:"query"`
UseTransactionTimezone bool `form:"use_transaction_timezone"`
}

// TransactionAmountsRequestItem represents an item of transaction amounts request
Expand Down
170 changes: 151 additions & 19 deletions pkg/services/transactions.go
Expand Up @@ -15,6 +15,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)

const pageCountForLoadTransactionAmounts = 1000

// TransactionService represents transaction service
type TransactionService struct {
ServiceUsingDB
Expand Down Expand Up @@ -1004,65 +1006,195 @@ func (s *TransactionService) GetRelatedTransferTransaction(originalTransaction *
}

// 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) (map[int64]int64, map[int64]int64, error) {
func (s *TransactionService) GetAccountsTotalIncomeAndExpense(c *core.Context, uid int64, startUnixTime int64, endUnixTime int64, utcOffset int16, useTransactionTimezone bool) (map[int64]int64, map[int64]int64, error) {
if uid <= 0 {
return nil, nil, errs.ErrUserIdInvalid
}

clientLocation := time.FixedZone("Client Timezone", int(utcOffset)*60)
startLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(startUnixTime, clientLocation)
endLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(endUnixTime, clientLocation)

startUnixTime = utils.GetMinUnixTimeWithSameLocalDateTime(startUnixTime, utcOffset)
endUnixTime = utils.GetMaxUnixTimeWithSameLocalDateTime(endUnixTime, utcOffset)

startTransactionTime := utils.GetMinTransactionTimeFromUnixTime(startUnixTime)
endTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(endUnixTime)

var transactionTotalAmounts []*models.Transaction
err := s.UserDataDB(uid).NewSession(c).Select("type, account_id, 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, account_id").Find(&transactionTotalAmounts)
condition := "uid=? AND deleted=? AND (type=? OR type=?) AND transaction_time>=? AND transaction_time<=?"
conditionParams := make([]any, 0, 4)
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 err != nil {
return nil, nil, err
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(transactionTotalAmounts); i++ {
transactionTotalAmount := transactionTotalAmounts[i]
for i := 0; i < len(allTransactions); i++ {
transaction := allTransactions[i]
timeZone := clientLocation

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 transactionTotalAmount.Type == models.TRANSACTION_DB_TYPE_INCOME {
incomeAmounts[transactionTotalAmount.AccountId] = transactionTotalAmount.Amount
} else if transactionTotalAmount.Type == models.TRANSACTION_DB_TYPE_EXPENSE {
expenseAmounts[transactionTotalAmount.AccountId] = transactionTotalAmount.Amount
if !exists {
totalAmounts = 0
}

totalAmounts += transaction.Amount
amountsMap[transaction.AccountId] = totalAmounts
}

return incomeAmounts, expenseAmounts, nil
}

// GetAccountsAndCategoriesTotalIncomeAndExpense returns the every accounts and categories total income and expense amount by specific date range
func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c *core.Context, uid int64, startUnixTime int64, endUnixTime int64) ([]*models.Transaction, error) {
func (s *TransactionService) GetAccountsAndCategoriesTotalIncomeAndExpense(c *core.Context, uid int64, startUnixTime int64, endUnixTime int64, utcOffset int16, useTransactionTimezone bool) ([]*models.Transaction, error) {
if uid <= 0 {
return nil, errs.ErrUserIdInvalid
}

clientLocation := time.FixedZone("Client Timezone", int(utcOffset)*60)
startLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(startUnixTime, clientLocation)
endLocalDateTime := utils.FormatUnixTimeToNumericLocalDateTime(endUnixTime, clientLocation)

startUnixTime = utils.GetMinUnixTimeWithSameLocalDateTime(startUnixTime, utcOffset)
endUnixTime = utils.GetMaxUnixTimeWithSameLocalDateTime(endUnixTime, utcOffset)

startTransactionTime := utils.GetMinTransactionTimeFromUnixTime(startUnixTime)
endTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(endUnixTime)

condition := "uid=? AND deleted=? AND (type=? OR type=?)"
conditionParams := make([]any, 0, 8)
conditionParams := make([]any, 0, 4)
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 startUnixTime > 0 {
condition = condition + " AND transaction_time>=?"
conditionParams = append(conditionParams, utils.GetMinTransactionTimeFromUnixTime(startUnixTime))
}

if endUnixTime > 0 {
condition = condition + " AND transaction_time<=?"
conditionParams = append(conditionParams, utils.GetMaxTransactionTimeFromUnixTime(endUnixTime))
}

var transactionTotalAmounts []*models.Transaction
err := s.UserDataDB(uid).NewSession(c).Select("category_id, account_id, SUM(amount) as amount").Where(condition, conditionParams...).GroupBy("category_id, account_id").Find(&transactionTotalAmounts)
minTransactionTime := startTransactionTime
maxTransactionTime := endTransactionTime
var allTransactions []*models.Transaction

if err != nil {
return nil, err
for maxTransactionTime > 0 {
var transactions []*models.Transaction

finalConditionParams := make([]any, 0, 6)
finalConditionParams = append(finalConditionParams, conditionParams...)

if startUnixTime > 0 {
finalConditionParams = append(finalConditionParams, minTransactionTime)
}

if endUnixTime > 0 {
finalConditionParams = append(finalConditionParams, maxTransactionTime)
}

err := s.UserDataDB(uid).NewSession(c).Select("category_id, 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, err
}

allTransactions = append(allTransactions, transactions...)

if len(transactions) < pageCountForLoadTransactionAmounts {
maxTransactionTime = 0
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 := clientLocation

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
}

groupKey := fmt.Sprintf("%s_%s", transaction.CategoryId, transaction.AccountId)
totalAmounts, exists := transactionTotalAmountsMap[groupKey]

if !exists {
totalAmounts = &models.Transaction{
CategoryId: transaction.CategoryId,
AccountId: transaction.AccountId,
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
Expand Down
28 changes: 28 additions & 0 deletions pkg/utils/datetimes.go
Expand Up @@ -44,6 +44,34 @@ func FormatUnixTimeToYearMonth(unixTime int64, timezone *time.Location) string {
return t.Format(yearMonthDateTimeFormat)
}

// FormatUnixTimeToNumericLocalDateTime returns numeric year, month, day, hour, minute and second of specified unix time
func FormatUnixTimeToNumericLocalDateTime(unixTime int64, timezone *time.Location) int64 {
t := parseFromUnixTime(unixTime)

if timezone != nil {
t = t.In(timezone)
}

localDateTime := int64(t.Year())
localDateTime = localDateTime*100 + int64(t.Month())
localDateTime = localDateTime*100 + int64(t.Day())
localDateTime = localDateTime*100 + int64(t.Hour())
localDateTime = localDateTime*100 + int64(t.Minute())
localDateTime = localDateTime*100 + int64(t.Second())

return localDateTime
}

// GetMinUnixTimeWithSameLocalDateTime returns the minimum UnixTime for date with the same local date
func GetMinUnixTimeWithSameLocalDateTime(unixTime int64, currentUtcOffset int16) int64 {
return unixTime + int64(currentUtcOffset)*60 - easternmostTimezoneUtcOffset*60
}

// GetMaxUnixTimeWithSameLocalDateTime returns the maximum UnixTime for date with the same local date
func GetMaxUnixTimeWithSameLocalDateTime(unixTime int64, currentUtcOffset int16) int64 {
return unixTime + int64(currentUtcOffset)*60 - westernmostTimezoneUtcOffset*60
}

// ParseFromLongDateTimeToMinUnixTime parses a formatted string in long date time format to minimal unix time (the westernmost timezone)
func ParseFromLongDateTimeToMinUnixTime(t string) (time.Time, error) {
timezone := time.FixedZone("Timezone", easternmostTimezoneUtcOffset*60)
Expand Down
32 changes: 32 additions & 0 deletions pkg/utils/datetimes_test.go
Expand Up @@ -35,6 +35,38 @@ func TestFormatUnixTimeToYearMonth(t *testing.T) {
assert.Equal(t, expectedValue, actualValue)
}

func TestFormatUnixTimeToNumericLocalDateTime(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8

expectedValue := int64(20210331220123)
actualValue := FormatUnixTimeToNumericLocalDateTime(unixTime, utcTimezone)
assert.Equal(t, expectedValue, actualValue)

expectedValue = int64(20210401060123)
actualValue = FormatUnixTimeToNumericLocalDateTime(unixTime, utc8Timezone)
assert.Equal(t, expectedValue, actualValue)
}

func TestGetMinUnixTimeWithSameLocalDateTime(t *testing.T) {
expectedValue := int64(1690797600)
actualValue := GetMinUnixTimeWithSameLocalDateTime(1690819200, 480)
assert.Equal(t, expectedValue, actualValue)

actualValue = GetMinUnixTimeWithSameLocalDateTime(1690873200, -420)
assert.Equal(t, expectedValue, actualValue)
}

func TestGetMaxUnixTimeWithSameLocalDateTime(t *testing.T) {
expectedValue := int64(1690891200)
actualValue := GetMaxUnixTimeWithSameLocalDateTime(1690819200, 480)
assert.Equal(t, expectedValue, actualValue)

actualValue = GetMaxUnixTimeWithSameLocalDateTime(1690873200, -420)
assert.Equal(t, expectedValue, actualValue)
}

func TestParseFromLongDateTimeToMinUnixTime(t *testing.T) {
expectedValue := int64(1690797600)
actualTime, err := ParseFromLongDateTimeToMinUnixTime("2023-08-01 00:00:00")
Expand Down
11 changes: 10 additions & 1 deletion src/consts/timezone.js
Expand Up @@ -592,7 +592,16 @@ const allAvailableTimezones = [
}
];

const allTimezoneTypesUsedForStatistics = {
ApplicationTimezone: 0,
TransactionTimezone: 1
};

const defaultTimezoneTypesUsedForStatistics = allTimezoneTypesUsedForStatistics.ApplicationTimezone;

export default {
all: allAvailableTimezones,
utcTimezoneName: 'Etc/GMT'
utcTimezoneName: 'Etc/GMT',
allTimezoneTypesUsedForStatistics: allTimezoneTypesUsedForStatistics,
defaultTimezoneTypesUsedForStatistics: defaultTimezoneTypesUsedForStatistics
};

0 comments on commit f4530e1

Please sign in to comment.