diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini
index 89585531..adcd0684 100644
--- a/conf/ezbookkeeping.ini
+++ b/conf/ezbookkeeping.ini
@@ -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 =
@@ -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
diff --git a/pkg/api/data_managements.go b/pkg/api/data_managements.go
index 415e72b1..2a1db694 100644
--- a/pkg/api/data_managements.go
+++ b/pkg/api/data_managements.go
@@ -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
diff --git a/pkg/api/transaction_templates.go b/pkg/api/transaction_templates.go
index 80d3965b..f7b5be61 100644
--- a/pkg/api/transaction_templates.go
+++ b/pkg/api/transaction_templates.go
@@ -3,6 +3,7 @@ package api
import (
"sort"
"strings"
+ "time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
@@ -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)
@@ -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)
@@ -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)
@@ -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,
@@ -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 &&
@@ -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)
@@ -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 {
@@ -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++ {
@@ -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 {
@@ -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,
@@ -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()
}
diff --git a/pkg/core/context_cron.go b/pkg/core/context_cron.go
index f911bdf4..0e4c3c58 100644
--- a/pkg/core/context_cron.go
+++ b/pkg/core/context_cron.go
@@ -10,7 +10,8 @@ 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
@@ -18,11 +19,17 @@ 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,
}
}
diff --git a/pkg/cron/cron_container.go b/pkg/cron/cron_container.go
index 3f3610b1..4d30d961 100644
--- a/pkg/cron/cron_container.go
+++ b/pkg/cron/cron_container.go
@@ -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) {
diff --git a/pkg/cron/cron_job.go b/pkg/cron/cron_job.go
index d425bd1f..f6df315c 100644
--- a/pkg/cron/cron_job.go
+++ b/pkg/cron/cron_job.go
@@ -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()
diff --git a/pkg/cron/cron_job_period.go b/pkg/cron/cron_job_period.go
index fa67be8a..286d9472 100644
--- a/pkg/cron/cron_job_period.go
+++ b/pkg/cron/cron_job_period.go
@@ -1,6 +1,7 @@
package cron
import (
+ "fmt"
"time"
"github.com/go-co-op/gocron/v2"
@@ -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
@@ -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
diff --git a/pkg/cron/cron_job_period_test.go b/pkg/cron/cron_job_period_test.go
index 3928c3ad..cc51df40 100644
--- a/pkg/cron/cron_job_period_test.go
+++ b/pkg/cron/cron_job_period_test.go
@@ -97,6 +97,61 @@ func TestCronJobNextRunTimeWithFixedHourPeriod(t *testing.T) {
assert.Nil(t, err)
}
+func TestCronJobNextRunTimeWithEvery15MinutesPeriod(t *testing.T) {
+ scheduler, err := gocron.NewScheduler(
+ gocron.WithLocation(time.Local),
+ )
+ assert.Nil(t, err)
+
+ expectedSecond := uint32(23)
+
+ job := CronJob{
+ Name: "TestCronJobWithEvery15MinutesPeriod",
+ Description: "The test cron job",
+ Period: CronJobEvery15MinutesPeriod{
+ Second: expectedSecond,
+ },
+ Run: func(c *core.CronContext) error {
+ return nil
+ },
+ }
+
+ assert.Equal(t, 15*time.Minute, job.Period.GetInterval())
+
+ gocronJob, err := scheduler.NewJob(
+ job.Period.ToJobDefinition(),
+ gocron.NewTask(job.doRun),
+ gocron.WithName(job.Name),
+ gocron.WithSingletonMode(gocron.LimitModeReschedule),
+ )
+ assert.Nil(t, err)
+
+ scheduler.Start()
+
+ nextRunTime, err := gocronJob.NextRun()
+ assert.Nil(t, err)
+
+ nextMinuteTime := time.Now()
+
+ if (nextMinuteTime.Minute() == 0 || nextMinuteTime.Minute() == 15 || nextMinuteTime.Minute() == 30 || nextMinuteTime.Minute() == 45) && nextMinuteTime.Second() < int(expectedSecond) {
+ // Do Nothing
+ } else {
+ nextMinute := ((nextMinuteTime.Minute() / 15) + 1) * 15
+ minuteDiff := nextMinute - nextMinuteTime.Minute()
+ nextMinuteTime = nextMinuteTime.Add(time.Duration(int64(minuteDiff) * int64(time.Minute)))
+ }
+
+ assert.Equal(t, nextMinuteTime.Year(), nextRunTime.Year())
+ assert.Equal(t, nextMinuteTime.Month(), nextRunTime.Month())
+ assert.Equal(t, nextMinuteTime.Day(), nextRunTime.Day())
+ assert.Equal(t, nextMinuteTime.Hour(), nextRunTime.Hour())
+ assert.Equal(t, nextMinuteTime.Minute(), nextRunTime.Minute())
+ assert.Equal(t, int(expectedSecond), nextRunTime.Second())
+
+ err = scheduler.Shutdown()
+ assert.Nil(t, err)
+}
+
func TestCronJobNextRunTimeWithFixedTimePeriod(t *testing.T) {
scheduler, err := gocron.NewScheduler(
gocron.WithLocation(time.Local),
diff --git a/pkg/cron/cron_jobs.go b/pkg/cron/cron_jobs.go
index 65201dfe..618776ec 100644
--- a/pkg/cron/cron_jobs.go
+++ b/pkg/cron/cron_jobs.go
@@ -1,6 +1,8 @@
package cron
import (
+ "time"
+
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/services"
)
@@ -16,3 +18,15 @@ var RemoveExpiredTokensJob = &CronJob{
return services.Tokens.DeleteAllExpiredTokens(c)
},
}
+
+// CreateScheduledTransactionJob represents the cron job which periodically create transaction by scheduled transaction template
+var CreateScheduledTransactionJob = &CronJob{
+ Name: "CreateScheduledTransaction",
+ Description: "Periodically create transaction by scheduled transaction template.",
+ Period: CronJobEvery15MinutesPeriod{
+ Second: 0,
+ },
+ Run: func(c *core.CronContext) error {
+ return services.Transactions.CreateScheduledTransactions(c, time.Now().Unix(), c.GetInterval())
+ },
+}
diff --git a/pkg/errs/transaction_template.go b/pkg/errs/transaction_template.go
index cbc2d683..dff20bb4 100644
--- a/pkg/errs/transaction_template.go
+++ b/pkg/errs/transaction_template.go
@@ -4,7 +4,9 @@ import "net/http"
// Error codes related to transaction templates
var (
- ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
- ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
- ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
+ ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
+ ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
+ ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
+ ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
+ ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
)
diff --git a/pkg/middlewares/server_settings_cookie.go b/pkg/middlewares/server_settings_cookie.go
index 021b55b8..65845793 100644
--- a/pkg/middlewares/server_settings_cookie.go
+++ b/pkg/middlewares/server_settings_cookie.go
@@ -19,6 +19,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
buildBooleanSetting("r", config.EnableUserRegister),
buildBooleanSetting("f", config.EnableUserForgetPassword),
buildBooleanSetting("v", config.EnableUserVerifyEmail),
+ buildBooleanSetting("s", config.EnableScheduledTransaction),
buildBooleanSetting("e", config.EnableDataExport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
}
diff --git a/pkg/models/data_management.go b/pkg/models/data_management.go
index c7ab7e8f..dc2bf305 100644
--- a/pkg/models/data_management.go
+++ b/pkg/models/data_management.go
@@ -7,9 +7,10 @@ type ClearDataRequest struct {
// DataStatisticsResponse represents a view-object of user data statistic
type DataStatisticsResponse struct {
- TotalAccountCount int64 `json:"totalAccountCount,string"`
- TotalTransactionCategoryCount int64 `json:"totalTransactionCategoryCount,string"`
- TotalTransactionTagCount int64 `json:"totalTransactionTagCount,string"`
- TotalTransactionCount int64 `json:"totalTransactionCount,string"`
- TotalTransactionTemplateCount int64 `json:"totalTransactionTemplateCount,string"`
+ TotalAccountCount int64 `json:"totalAccountCount,string"`
+ TotalTransactionCategoryCount int64 `json:"totalTransactionCategoryCount,string"`
+ TotalTransactionTagCount int64 `json:"totalTransactionTagCount,string"`
+ TotalTransactionCount int64 `json:"totalTransactionCount,string"`
+ TotalTransactionTemplateCount int64 `json:"totalTransactionTemplateCount,string"`
+ TotalScheduledTransactionCount int64 `json:"totalScheduledTransactionCount,string"`
}
diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go
index 5e255ad1..6ed05da5 100644
--- a/pkg/models/transaction.go
+++ b/pkg/models/transaction.go
@@ -49,6 +49,7 @@ type Transaction struct {
GeoLongitude float64 `xorm:"INDEX(IDX_transaction_uid_deleted_time_longitude_latitude)"`
GeoLatitude float64 `xorm:"INDEX(IDX_transaction_uid_deleted_time_longitude_latitude)"`
CreatedIp string `xorm:"VARCHAR(39)"`
+ ScheduledCreated bool
CreatedUnixTime int64
UpdatedUnixTime int64
DeletedUnixTime int64
diff --git a/pkg/models/transaction_template.go b/pkg/models/transaction_template.go
index b3c71a7c..0c690fd3 100644
--- a/pkg/models/transaction_template.go
+++ b/pkg/models/transaction_template.go
@@ -11,30 +11,45 @@ type TransactionTemplateType byte
// Transaction template types
const (
- TRANSACTION_TEMPLATE_TYPE_NORMAL TransactionTemplateType = 1
+ TRANSACTION_TEMPLATE_TYPE_NORMAL TransactionTemplateType = 1
+ TRANSACTION_TEMPLATE_TYPE_SCHEDULE TransactionTemplateType = 2
+)
+
+// TransactionScheduleFrequencyType represents transaction template schedule frequency type
+type TransactionScheduleFrequencyType byte
+
+// Transaction template schedule frequency types
+const (
+ TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED TransactionScheduleFrequencyType = 0
+ TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY TransactionScheduleFrequencyType = 1
+ TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY TransactionScheduleFrequencyType = 2
)
// TransactionTemplate represents transaction template stored in database
type TransactionTemplate struct {
- TemplateId int64 `xorm:"PK"`
- Uid int64 `xorm:"INDEX(IDX_transaction_uid_deleted_template_type_order) NOT NULL"`
- Deleted bool `xorm:"INDEX(IDX_transaction_uid_deleted_template_type_order) NOT NULL"`
- TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_uid_deleted_template_type_order) NOT NULL"`
- Name string `xorm:"VARCHAR(32) NOT NULL"`
- Type TransactionType `xorm:"NOT NULL"`
- CategoryId int64 `xorm:"NOT NULL"`
- AccountId int64 `xorm:"NOT NULL"`
- TagIds string `xorm:"VARCHAR(255) NOT NULL"`
- Amount int64 `xorm:"NOT NULL"`
- RelatedAccountId int64 `xorm:"NOT NULL"`
- RelatedAccountAmount int64 `xorm:"NOT NULL"`
- HideAmount bool `xorm:"NOT NULL"`
- Comment string `xorm:"VARCHAR(255) NOT NULL"`
- DisplayOrder int32 `xorm:"INDEX(IDX_transaction_uid_deleted_template_type_order) NOT NULL"`
- Hidden bool `xorm:"NOT NULL"`
- CreatedUnixTime int64
- UpdatedUnixTime int64
- DeletedUnixTime int64
+ TemplateId int64 `xorm:"PK"`
+ Uid int64 `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) NOT NULL"`
+ Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
+ TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
+ Name string `xorm:"VARCHAR(32) NOT NULL"`
+ Type TransactionType `xorm:"NOT NULL"`
+ CategoryId int64 `xorm:"NOT NULL"`
+ AccountId int64 `xorm:"NOT NULL"`
+ ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
+ ScheduledFrequency string `xorm:"VARCHAR(100)"`
+ ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
+ ScheduledTimezoneUtcOffset int16
+ TagIds string `xorm:"VARCHAR(255) NOT NULL"`
+ Amount int64 `xorm:"NOT NULL"`
+ RelatedAccountId int64 `xorm:"NOT NULL"`
+ RelatedAccountAmount int64 `xorm:"NOT NULL"`
+ HideAmount bool `xorm:"NOT NULL"`
+ Comment string `xorm:"VARCHAR(255) NOT NULL"`
+ DisplayOrder int32 `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) NOT NULL"`
+ Hidden bool `xorm:"NOT NULL"`
+ CreatedUnixTime int64
+ UpdatedUnixTime int64
+ DeletedUnixTime int64
}
// TransactionTemplateListRequest represents all parameters of transaction template list request
@@ -49,18 +64,21 @@ type TransactionTemplateGetRequest struct {
// TransactionTemplateCreateRequest represents all parameters of transaction template creation request
type TransactionTemplateCreateRequest struct {
- TemplateType TransactionTemplateType `json:"templateType"`
- Name string `json:"name" binding:"required,notBlank,max=32"`
- Type TransactionType `json:"type" binding:"required"`
- CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
- SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
- DestinationAccountId int64 `json:"destinationAccountId,string" binding:"min=0"`
- SourceAmount int64 `json:"sourceAmount" binding:"min=-99999999999,max=99999999999"`
- DestinationAmount int64 `json:"destinationAmount" binding:"min=-99999999999,max=99999999999"`
- HideAmount bool `json:"hideAmount"`
- TagIds []string `json:"tagIds"`
- Comment string `json:"comment" binding:"max=255"`
- ClientSessionId string `json:"clientSessionId"`
+ TemplateType TransactionTemplateType `json:"templateType"`
+ Name string `json:"name" binding:"required,notBlank,max=32"`
+ Type TransactionType `json:"type" binding:"required"`
+ CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
+ SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
+ DestinationAccountId int64 `json:"destinationAccountId,string" binding:"min=0"`
+ SourceAmount int64 `json:"sourceAmount" binding:"min=-99999999999,max=99999999999"`
+ DestinationAmount int64 `json:"destinationAmount" binding:"min=-99999999999,max=99999999999"`
+ HideAmount bool `json:"hideAmount"`
+ TagIds []string `json:"tagIds"`
+ Comment string `json:"comment" binding:"max=255"`
+ ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
+ ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
+ ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
+ ClientSessionId string `json:"clientSessionId"`
}
// TransactionTemplateModifyNameRequest represents all parameters of transaction template name modification request
@@ -71,17 +89,20 @@ type TransactionTemplateModifyNameRequest struct {
// TransactionTemplateModifyRequest represents all parameters of transaction template modification request
type TransactionTemplateModifyRequest struct {
- Id int64 `json:"id,string" binding:"required,min=1"`
- Name string `json:"name" binding:"required,notBlank,max=32"`
- Type TransactionType `json:"type" binding:"required"`
- CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
- SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
- DestinationAccountId int64 `json:"destinationAccountId,string" binding:"min=0"`
- SourceAmount int64 `json:"sourceAmount" binding:"min=-99999999999,max=99999999999"`
- DestinationAmount int64 `json:"destinationAmount" binding:"min=-99999999999,max=99999999999"`
- HideAmount bool `json:"hideAmount"`
- TagIds []string `json:"tagIds"`
- Comment string `json:"comment" binding:"max=255"`
+ Id int64 `json:"id,string" binding:"required,min=1"`
+ Name string `json:"name" binding:"required,notBlank,max=32"`
+ Type TransactionType `json:"type" binding:"required"`
+ CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
+ SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
+ DestinationAccountId int64 `json:"destinationAccountId,string" binding:"min=0"`
+ SourceAmount int64 `json:"sourceAmount" binding:"min=-99999999999,max=99999999999"`
+ DestinationAmount int64 `json:"destinationAmount" binding:"min=-99999999999,max=99999999999"`
+ HideAmount bool `json:"hideAmount"`
+ TagIds []string `json:"tagIds"`
+ Comment string `json:"comment" binding:"max=255"`
+ ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
+ ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
+ ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
}
// TransactionTemplateHideRequest represents all parameters of transaction template hiding request
@@ -108,14 +129,54 @@ type TransactionTemplateDeleteRequest struct {
type TransactionTemplateInfoResponse struct {
*TransactionInfoResponse
- TemplateType TransactionTemplateType `json:"templateType"`
- Name string `json:"name"`
- DisplayOrder int32 `json:"displayOrder"`
- Hidden bool `json:"hidden"`
+ TemplateType TransactionTemplateType `json:"templateType"`
+ Name string `json:"name"`
+ ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType,omitempty"`
+ ScheduledFrequency *string `json:"scheduledFrequency,omitempty"`
+ ScheduledAt *int16 `json:"scheduledAt,omitempty"`
+ DisplayOrder int32 `json:"displayOrder"`
+ Hidden bool `json:"hidden"`
}
-// ToTransactionInfoResponse returns a view-object according to database model
-func (t *TransactionTemplate) ToTransactionInfoResponse(utcOffset int16) *TransactionInfoResponse {
+// GetTagIds returns all tag ids of the transaction template
+func (t *TransactionTemplate) GetTagIds() []int64 {
+ tagIds := make([]string, 0)
+
+ if t.TagIds != "" {
+ tagIds = strings.Split(t.TagIds, ",")
+ }
+
+ result, _ := utils.StringArrayToInt64Array(tagIds)
+
+ return result
+}
+
+// ToTransactionTemplateInfoResponse returns a view-object according to database model
+func (t *TransactionTemplate) ToTransactionTemplateInfoResponse(serverUtcOffset int16) *TransactionTemplateInfoResponse {
+ utcOffset := serverUtcOffset
+
+ if t.TemplateType == TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
+ utcOffset = t.ScheduledTimezoneUtcOffset
+ }
+
+ response := &TransactionTemplateInfoResponse{
+ TransactionInfoResponse: t.toTransactionInfoResponse(utcOffset),
+ TemplateType: t.TemplateType,
+ Name: t.Name,
+ DisplayOrder: t.DisplayOrder,
+ Hidden: t.Hidden,
+ }
+
+ if t.TemplateType == TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
+ response.ScheduledFrequencyType = &t.ScheduledFrequencyType
+ response.ScheduledFrequency = &t.ScheduledFrequency
+ response.ScheduledAt = &t.ScheduledAt
+ }
+
+ return response
+}
+
+func (t *TransactionTemplate) toTransactionInfoResponse(utcOffset int16) *TransactionInfoResponse {
tagIds := make([]string, 0, 0)
if t.TagIds != "" {
@@ -141,17 +202,6 @@ func (t *TransactionTemplate) ToTransactionInfoResponse(utcOffset int16) *Transa
}
}
-// ToTransactionTemplateInfoResponse returns a view-object according to database model
-func (t *TransactionTemplate) ToTransactionTemplateInfoResponse(utcOffset int16) *TransactionTemplateInfoResponse {
- return &TransactionTemplateInfoResponse{
- TransactionInfoResponse: t.ToTransactionInfoResponse(utcOffset),
- TemplateType: t.TemplateType,
- Name: t.Name,
- DisplayOrder: t.DisplayOrder,
- Hidden: t.Hidden,
- }
-}
-
// TransactionTemplateInfoResponseSlice represents the slice data structure of TransactionTemplateInfoResponse
type TransactionTemplateInfoResponseSlice []*TransactionTemplateInfoResponse
diff --git a/pkg/services/base.go b/pkg/services/base.go
index 143997b2..35144270 100644
--- a/pkg/services/base.go
+++ b/pkg/services/base.go
@@ -26,7 +26,7 @@ func (s *ServiceUsingDB) TokenDB(uid int64) *datastore.Database {
return s.container.TokenStore.Choose(uid)
}
-// TokenDBByIndex returns the datastore by index
+// TokenDBByIndex returns the datastore which contains user token by index
func (s *ServiceUsingDB) TokenDBByIndex(index int) *datastore.Database {
return s.container.TokenStore.Get(index)
}
@@ -41,6 +41,16 @@ func (s *ServiceUsingDB) UserDataDB(uid int64) *datastore.Database {
return s.container.UserDataStore.Choose(uid)
}
+// UserDataDBByIndex returns the datastore which contains user data by index
+func (s *ServiceUsingDB) UserDataDBByIndex(index int) *datastore.Database {
+ return s.container.UserDataStore.Get(index)
+}
+
+// UserDataDBCount returns the count of datastores which contains user data
+func (s *ServiceUsingDB) UserDataDBCount() int {
+ return s.container.UserDataStore.Count()
+}
+
// ServiceUsingConfig represents a service that need to use config
type ServiceUsingConfig struct {
container *settings.ConfigContainer
diff --git a/pkg/services/transaction_templates.go b/pkg/services/transaction_templates.go
index bf3be393..ba28f565 100644
--- a/pkg/services/transaction_templates.go
+++ b/pkg/services/transaction_templates.go
@@ -29,7 +29,7 @@ var (
}
)
-// GetTotalNormalTemplateCountByUid returns total template count of user
+// GetTotalNormalTemplateCountByUid returns total normal template count of user
func (s *TransactionTemplateService) GetTotalNormalTemplateCountByUid(c core.Context, uid int64) (int64, error) {
if uid <= 0 {
return 0, errs.ErrUserIdInvalid
@@ -40,6 +40,17 @@ func (s *TransactionTemplateService) GetTotalNormalTemplateCountByUid(c core.Con
return count, err
}
+// GetTotalScheduledTemplateCountByUid returns total scheduled transaction count of user
+func (s *TransactionTemplateService) GetTotalScheduledTemplateCountByUid(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=? AND template_type=?", uid, false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE).Count(&models.TransactionTemplate{})
+
+ return count, err
+}
+
// GetAllTemplatesByUid returns all transaction template models of user
func (s *TransactionTemplateService) GetAllTemplatesByUid(c core.Context, uid int64, templateType models.TransactionTemplateType) ([]*models.TransactionTemplate, error) {
if uid <= 0 {
@@ -125,7 +136,7 @@ func (s *TransactionTemplateService) ModifyTemplate(c core.Context, template *mo
template.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(template.Uid).DoTransaction(c, func(sess *xorm.Session) error {
- updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
+ updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_at", "scheduled_timezone_utc_offset", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
if err != nil {
return err
diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go
index 9dd32b89..f193e350 100644
--- a/pkg/services/transactions.go
+++ b/pkg/services/transactions.go
@@ -11,6 +11,7 @@ import (
"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"
@@ -429,6 +430,135 @@ func (s *TransactionService) CreateTransaction(c core.Context, transaction *mode
})
}
+// 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_at>=? AND scheduled_at", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, minScheduledAt, maxScheduledAt).Find(&templates)
+
+ if err != nil {
+ return err
+ }
+
+ allTemplates = append(allTemplates, templates...)
+ }
+
+ if len(allTemplates) < 1 {
+ return nil
+ }
+
+ log.Infof(c, "[transactions.CreateScheduledTransactions] should process %d scheduled transaction templates now (scheduled at from %d to %d)", len(allTemplates), minScheduledAt, maxScheduledAt)
+
+ successCount := 0
+ skipCount := 0
+ failedCount := 0
+
+ for i := 0; i < len(allTemplates); i++ {
+ template := allTemplates[i]
+
+ if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED {
+ skipCount++
+ log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" disabled scheduled transaction frequency", template.TemplateId)
+ continue
+ }
+
+ if (template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY &&
+ template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY) ||
+ template.ScheduledFrequency == "" {
+ skipCount++
+ log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid scheduled transaction frequency", template.TemplateId)
+ continue
+ }
+
+ frequencyValues, err := utils.StringArrayToInt64Array(strings.Split(template.ScheduledFrequency, ","))
+
+ if err != nil {
+ skipCount++
+ log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid scheduled transaction frequency, because %s", template.TemplateId, err.Error())
+ continue
+ }
+
+ frequencyValueSet := utils.ToSet(frequencyValues)
+ templateTimeZone := time.FixedZone("Template Timezone", int(template.ScheduledTimezoneUtcOffset)*60)
+ transactionUnixTime := todayFirstUnixTimeInUTC + int64(template.ScheduledAt)*60
+ transactionTime := time.Unix(transactionUnixTime, 0).In(templateTimeZone)
+
+ if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY && !frequencyValueSet[int64(transactionTime.Weekday())] {
+ skipCount++
+ log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %s", template.TemplateId, startTimeInUTC.Weekday())
+ continue
+ } else if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY && !frequencyValueSet[int64(transactionTime.Day())] {
+ skipCount++
+ log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d of month", template.TemplateId, startTimeInUTC.Day())
+ 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)
+
+ 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", template.TemplateId)
+ }
+ }
+
+ 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) error {
if transaction.Uid <= 0 {
diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go
index 294c513a..3dca3c8c 100644
--- a/pkg/settings/setting.go
+++ b/pkg/settings/setting.go
@@ -248,6 +248,7 @@ type Config struct {
// Cron
EnableRemoveExpiredTokens bool
+ EnableCreateScheduledTransaction bool
// Secret
SecretKeyNoSet bool
@@ -270,6 +271,7 @@ type Config struct {
EnableUserForceVerifyEmail bool
EnableUserForgetPassword bool
ForgetPasswordRequireVerifyEmail bool
+ EnableScheduledTransaction bool
AvatarProvider core.UserAvatarProviderType
// Data
@@ -679,6 +681,7 @@ func loadDuplicateCheckerConfiguration(config *Config, configFile *ini.File, sec
func loadCronConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableRemoveExpiredTokens = getConfigItemBoolValue(configFile, sectionName, "enable_remove_expired_tokens", false)
+ config.EnableCreateScheduledTransaction = getConfigItemBoolValue(configFile, sectionName, "enable_create_scheduled_transaction", false)
return nil
}
@@ -737,6 +740,7 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str
config.EnableUserForceVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "enable_force_email_verify", false)
config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false)
config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false)
+ config.EnableScheduledTransaction = getConfigItemBoolValue(configFile, sectionName, "enable_scheduled_transaction", false)
if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == string(core.USER_AVATAR_PROVIDER_INTERNAL) {
config.AvatarProvider = core.USER_AVATAR_PROVIDER_INTERNAL
diff --git a/pkg/utils/slices.go b/pkg/utils/slices.go
index 2f6e9d0f..f6342e65 100644
--- a/pkg/utils/slices.go
+++ b/pkg/utils/slices.go
@@ -57,3 +57,15 @@ func ToUniqueInt64Slice(items []int64) []int64 {
return uniqueItems
}
+
+// ToSet returns a map where the keys are the items in the specified array
+func ToSet(items []int64) map[int64]bool {
+ itemExistMap := make(map[int64]bool)
+
+ for i := 0; i < len(items); i++ {
+ item := items[i]
+ itemExistMap[item] = true
+ }
+
+ return itemExistMap
+}
diff --git a/pkg/utils/slices_test.go b/pkg/utils/slices_test.go
index 16f742ce..1ae8bc71 100644
--- a/pkg/utils/slices_test.go
+++ b/pkg/utils/slices_test.go
@@ -130,3 +130,25 @@ func TestToUniqueInt64Slice_NilOrEmpty(t *testing.T) {
actualValue = ToUniqueInt64Slice(arr)
assert.Equal(t, expectedValue, actualValue)
}
+
+func TestToSet(t *testing.T) {
+ arr := []int64{0, 1, 2, 3, 2, 4, 0}
+ actualValue := ToSet(arr)
+ assert.Equal(t, 5, len(actualValue))
+ assert.Equal(t, true, actualValue[0])
+ assert.Equal(t, true, actualValue[1])
+ assert.Equal(t, true, actualValue[2])
+ assert.Equal(t, true, actualValue[3])
+ assert.Equal(t, true, actualValue[4])
+ assert.Equal(t, false, actualValue[5])
+}
+
+func TestToSet_NilOrEmpty(t *testing.T) {
+ var arr []int64 = nil
+ actualValue := ToSet(arr)
+ assert.Equal(t, 0, len(actualValue))
+
+ arr = []int64{}
+ actualValue = ToSet(arr)
+ assert.Equal(t, 0, len(actualValue))
+}
diff --git a/src/components/desktop/ScheduleFrequencySelect.vue b/src/components/desktop/ScheduleFrequencySelect.vue
new file mode 100644
index 00000000..22853482
--- /dev/null
+++ b/src/components/desktop/ScheduleFrequencySelect.vue
@@ -0,0 +1,208 @@
+
+