Skip to content

Commit

Permalink
support scheduled transaction (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
mayswind committed Aug 25, 2024
1 parent 17d4fec commit d2eaf5c
Show file tree
Hide file tree
Showing 42 changed files with 1,437 additions and 112 deletions.
6 changes: 6 additions & 0 deletions conf/ezbookkeeping.ini
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ duplicate_submissions_interval = 300
# Set to true to clean up expired tokens periodically
enable_remove_expired_tokens = true

# Set to true to create scheduled transactions based on the user's templates
enable_create_scheduled_transaction = true

[security]
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
secret_key =
Expand Down Expand Up @@ -196,6 +199,9 @@ enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false

# Set to true to allow users to create scheduled transaction
enable_scheduled_transaction = true

# User avatar provider, supports the following types:
# "internal": Use the internal object storage to store user avatar (refer to "storage" settings), supports updating avatar by user self
# "gravatar": https://gravatar.com
Expand Down
18 changes: 13 additions & 5 deletions pkg/api/data_managements.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,20 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
return nil, errs.ErrOperationFailed
}

totalScheduledTransactionCount, err := a.templates.GetTotalScheduledTemplateCountByUid(c, uid)

if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total scheduled transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}

dataStatisticsResp := &models.DataStatisticsResponse{
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
TotalScheduledTransactionCount: totalScheduledTransactionCount,
}

return dataStatisticsResp, nil
Expand Down
163 changes: 159 additions & 4 deletions pkg/api/transaction_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"sort"
"strings"
"time"

"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
Expand Down Expand Up @@ -44,11 +45,15 @@ func (a *TransactionTemplatesApi) TemplateListHandler(c *core.WebContext) (any,
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}

if templateListReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateListReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
if templateListReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateListReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateListHandler] template type invalid, type is %d", templateListReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}

if templateListReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

uid := c.GetCurrentUid()
templates, err := a.templates.GetAllTemplatesByUid(c, uid, templateListReq.TemplateType)

Expand Down Expand Up @@ -87,6 +92,10 @@ func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.WebContext) (any, *
return nil, errs.Or(err, errs.ErrOperationFailed)
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)

Expand All @@ -103,16 +112,34 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}

if templateCreateReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateCreateReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
if templateCreateReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateCreateReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] template type invalid, type is %d", templateCreateReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}

if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

if templateCreateReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] transaction type invalid, type is %d", templateCreateReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}

if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateCreateReq.ScheduledFrequencyType == nil ||
templateCreateReq.ScheduledFrequency == nil ||
templateCreateReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}

if *templateCreateReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateCreateReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}

uid := c.GetCurrentUid()

maxOrderId, err := a.templates.GetMaxDisplayOrder(c, uid, templateCreateReq.TemplateType)
Expand Down Expand Up @@ -185,6 +212,24 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
return nil, errs.Or(err, errs.ErrOperationFailed)
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateModifyReq.ScheduledFrequencyType == nil ||
templateModifyReq.ScheduledFrequency == nil ||
templateModifyReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}

if *templateModifyReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateModifyReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}

newTemplate := &models.TransactionTemplate{
TemplateId: template.TemplateId,
Uid: uid,
Expand All @@ -200,6 +245,13 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
Comment: templateModifyReq.Comment,
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
newTemplate.ScheduledFrequencyType = *templateModifyReq.ScheduledFrequencyType
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
}

if newTemplate.Name == template.Name &&
newTemplate.Type == template.Type &&
newTemplate.CategoryId == template.CategoryId &&
Expand All @@ -210,7 +262,16 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
newTemplate.RelatedAccountAmount == template.RelatedAccountAmount &&
newTemplate.HideAmount == template.HideAmount &&
newTemplate.Comment == template.Comment {
return nil, errs.ErrNothingWillBeUpdated
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
return nil, errs.ErrNothingWillBeUpdated
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
}
}
}

err = a.templates.ModifyTemplate(c, newTemplate)
Expand Down Expand Up @@ -242,6 +303,18 @@ func (a *TransactionTemplatesApi) TemplateHideHandler(c *core.WebContext) (any,
}

uid := c.GetCurrentUid()

template, err := a.templates.GetTemplateByTemplateId(c, uid, templateHideReq.Id)

if err != nil {
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

err = a.templates.HideTemplate(c, uid, []int64{templateHideReq.Id}, templateHideReq.Hidden)

if err != nil {
Expand All @@ -264,6 +337,20 @@ func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.WebContext) (any,
}

uid := c.GetCurrentUid()

if len(templateMoveReq.NewDisplayOrders) > 0 {
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateMoveReq.NewDisplayOrders[0].Id)

if err != nil {
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateMoveReq.NewDisplayOrders[0].Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
}

templates := make([]*models.TransactionTemplate, len(templateMoveReq.NewDisplayOrders))

for i := 0; i < len(templateMoveReq.NewDisplayOrders); i++ {
Expand Down Expand Up @@ -299,6 +386,18 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
}

uid := c.GetCurrentUid()

template, err := a.templates.GetTemplateByTemplateId(c, uid, templateDeleteReq.Id)

if err != nil {
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}

if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}

err = a.templates.DeleteTemplate(c, uid, templateDeleteReq.Id)

if err != nil {
Expand All @@ -311,7 +410,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
}

func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
return &models.TransactionTemplate{
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
Name: templateCreateReq.Name,
Expand All @@ -326,4 +425,60 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
Comment: templateCreateReq.Comment,
DisplayOrder: order,
}

if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
template.ScheduledFrequencyType = *templateCreateReq.ScheduledFrequencyType
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
}

return template
}

func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
templateTimeZone := time.FixedZone("Template Timezone", int(scheduledTimezoneUtcOffset)*60)
transactionTime := time.Date(2020, 1, 1, 0, 0, 0, 0, templateTimeZone)
transactionTimeInUTC := transactionTime.In(time.UTC)

minutesElapsedOfDayInUtc := transactionTimeInUTC.Hour()*60 + transactionTimeInUTC.Minute()

return int16(minutesElapsedOfDayInUtc)
}

func (a *TransactionTemplatesApi) getOrderedFrequencyValues(frequencyValue string) string {
if frequencyValue == "" {
return ""
}

items := strings.Split(frequencyValue, ",")
values := make([]int, 0, len(items))
valueExistMap := make(map[int]bool)

for i := 0; i < len(items); i++ {
value, err := utils.StringToInt(items[i])

if err != nil {
continue
}

if _, exists := valueExistMap[value]; !exists {
values = append(values, value)
valueExistMap[value] = true
}
}

sort.Ints(values)

var sortedFrequencyValueBuilder strings.Builder

for i := 0; i < len(values); i++ {
if sortedFrequencyValueBuilder.Len() > 0 {
sortedFrequencyValueBuilder.WriteRune(',')
}

sortedFrequencyValueBuilder.WriteString(utils.IntToString(values[i]))
}

return sortedFrequencyValueBuilder.String()
}
15 changes: 11 additions & 4 deletions pkg/core/context_cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,26 @@ import (
// CronContext represents the cron job context
type CronContext struct {
context.Context
contextId string
contextId string
cronJobInterval time.Duration
}

// GetContextId returns the current context id
func (c *CronContext) GetContextId() string {
return c.contextId
}

// GetInterval returns the current cron job interval
func (c *CronContext) GetInterval() time.Duration {
return c.cronJobInterval
}

// NewCronJobContext returns a new cron job context
func NewCronJobContext(cronJobName string) *CronContext {
func NewCronJobContext(cronJobName string, cronJobInterval time.Duration) *CronContext {
return &CronContext{
Context: context.Background(),
contextId: generateNewRandomCronContextId(cronJobName),
Context: context.Background(),
contextId: generateNewRandomCronContextId(cronJobName),
cronJobInterval: cronJobInterval,
}
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/cron/cron_container.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ func (c *CronJobSchedulerContainer) registerAllJobs(ctx core.Context, config *se
if config.EnableRemoveExpiredTokens {
Container.registerIntervalJob(ctx, RemoveExpiredTokensJob)
}

if config.EnableCreateScheduledTransaction {
Container.registerIntervalJob(ctx, CreateScheduledTransactionJob)
}
}

func (c *CronJobSchedulerContainer) registerIntervalJob(ctx core.Context, job *CronJob) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cron/cron_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type CronJob struct {

func (j *CronJob) doRun() {
start := time.Now()
c := core.NewCronJobContext(j.Name)
c := core.NewCronJobContext(j.Name, j.Period.GetInterval())

if duplicatechecker.Container.Current != nil {
localAddr, err := utils.GetLocalIPAddressesString()
Expand Down
16 changes: 16 additions & 0 deletions pkg/cron/cron_job_period.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cron

import (
"fmt"
"time"

"github.com/go-co-op/gocron/v2"
Expand All @@ -22,6 +23,11 @@ type CronJobFixedHourPeriod struct {
Hour uint32
}

// CronJobEvery15MinutesPeriod represents the period of execution at every 15 minutes
type CronJobEvery15MinutesPeriod struct {
Second uint32
}

// CronJobFixedTimePeriod represents the period of execution at fixed time
type CronJobFixedTimePeriod struct {
Time time.Time
Expand Down Expand Up @@ -52,6 +58,16 @@ func (p CronJobFixedHourPeriod) ToJobDefinition() gocron.JobDefinition {
)
}

// GetInterval returns the interval time of the period of CronJobEvery15MinutesPeriod
func (p CronJobEvery15MinutesPeriod) GetInterval() time.Duration {
return 15 * time.Minute
}

// ToJobDefinition returns the gocron job definition of the period of CronJobEvery15MinutesPeriod
func (p CronJobEvery15MinutesPeriod) ToJobDefinition() gocron.JobDefinition {
return gocron.CronJob(fmt.Sprintf("%d */15 * * * *", p.Second), true)
}

// GetInterval returns the interval time of the period of CronJobFixedTimePeriod
func (p CronJobFixedTimePeriod) GetInterval() time.Duration {
return 0
Expand Down
Loading

0 comments on commit d2eaf5c

Please sign in to comment.