From d2eaf5c6da80cebc5a8409aa73e830dc76a63b9e Mon Sep 17 00:00:00 2001 From: MaysWind Date: Mon, 26 Aug 2024 01:52:52 +0800 Subject: [PATCH] support scheduled transaction (#2) --- conf/ezbookkeeping.ini | 6 + pkg/api/data_managements.go | 18 +- pkg/api/transaction_templates.go | 163 +++++++++++++- pkg/core/context_cron.go | 15 +- pkg/cron/cron_container.go | 4 + pkg/cron/cron_job.go | 2 +- pkg/cron/cron_job_period.go | 16 ++ pkg/cron/cron_job_period_test.go | 55 +++++ pkg/cron/cron_jobs.go | 14 ++ pkg/errs/transaction_template.go | 8 +- pkg/middlewares/server_settings_cookie.go | 1 + pkg/models/data_management.go | 11 +- pkg/models/transaction.go | 1 + pkg/models/transaction_template.go | 170 +++++++++----- pkg/services/base.go | 12 +- pkg/services/transaction_templates.go | 15 +- pkg/services/transactions.go | 130 +++++++++++ pkg/settings/setting.go | 4 + pkg/utils/slices.go | 12 + pkg/utils/slices_test.go | 22 ++ .../desktop/ScheduleFrequencySelect.vue | 208 +++++++++++++++++ .../mobile/ScheduleFrequencySheet.vue | 212 ++++++++++++++++++ src/consts/template.js | 19 +- src/desktop-main.js | 2 + src/lib/common.js | 6 + src/lib/i18n.js | 79 ++++++- src/lib/server_settings.js | 4 + src/lib/services.js | 12 +- src/locales/en.json | 47 ++++ src/locales/zh_Hans.json | 47 ++++ src/mobile-main.js | 2 + src/router/desktop.js | 14 +- src/router/mobile.js | 5 + src/stores/transactionTemplate.js | 7 + src/views/desktop/MainLayout.vue | 19 +- src/views/desktop/templates/ListPage.vue | 29 ++- .../transactions/list/dialogs/EditDialog.vue | 39 +++- .../tabs/UserDataManagementSettingTab.vue | 11 +- src/views/mobile/SettingsPage.vue | 1 + src/views/mobile/templates/ListPage.vue | 21 +- src/views/mobile/transactions/EditPage.vue | 81 ++++++- src/views/mobile/users/DataManagementPage.vue | 5 +- 42 files changed, 1437 insertions(+), 112 deletions(-) create mode 100644 src/components/desktop/ScheduleFrequencySelect.vue create mode 100644 src/components/mobile/ScheduleFrequencySheet.vue 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 + + + + + + + + + + diff --git a/src/components/mobile/ScheduleFrequencySheet.vue b/src/components/mobile/ScheduleFrequencySheet.vue new file mode 100644 index 00000000..0933caaa --- /dev/null +++ b/src/components/mobile/ScheduleFrequencySheet.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/src/consts/template.js b/src/consts/template.js index b806bbc8..29358b27 100644 --- a/src/consts/template.js +++ b/src/consts/template.js @@ -1,7 +1,24 @@ const allTemplateTypes = { - Normal: 1 + Normal: 1, + Schedule: 2, +}; + +const allTemplateScheduledFrequencyTypes = { + Disabled: { + type: 0, + name: 'Disabled' + }, + Weekly: { + type: 1, + name: 'Weekly' + }, + Monthly: { + type: 2, + name: 'Monthly' + } }; export default { allTemplateTypes: allTemplateTypes, + allTemplateScheduledFrequencyTypes: allTemplateScheduledFrequencyTypes, } diff --git a/src/desktop-main.js b/src/desktop-main.js index f24c709f..809dc2a7 100644 --- a/src/desktop-main.js +++ b/src/desktop-main.js @@ -88,6 +88,7 @@ import DateTimeSelect from '@/components/desktop/DateTimeSelect.vue'; import ColorSelect from '@/components/desktop/ColorSelect.vue'; import IconSelect from '@/components/desktop/IconSelect.vue'; import TwoColumnSelect from '@/components/desktop/TwoColumnSelect.vue'; +import ScheduleFrequencySelect from '@/components/desktop/ScheduleFrequencySelect.vue'; import StepsBar from '@/components/desktop/StepsBar.vue'; import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue'; import SnackBar from '@/components/desktop/SnackBar.vue'; @@ -454,6 +455,7 @@ app.component('DateTimeSelect', DateTimeSelect); app.component('ColorSelect', ColorSelect); app.component('IconSelect', IconSelect); app.component('TwoColumnSelect', TwoColumnSelect); +app.component('ScheduleFrequencySelect', ScheduleFrequencySelect); app.component('StepsBar', StepsBar); app.component('ConfirmDialog', ConfirmDialog); app.component('SnackBar', SnackBar); diff --git a/src/lib/common.js b/src/lib/common.js index 7f6e0fad..ba27b70f 100644 --- a/src/lib/common.js +++ b/src/lib/common.js @@ -130,6 +130,12 @@ export function isObjectEmpty(obj) { return true; } +export function sortNumbersArray(array) { + return array.sort(function (num1, num2) { + return num1 - num2; + }); +} + export function getObjectOwnFieldCount(object) { let count = 0; diff --git a/src/lib/i18n.js b/src/lib/i18n.js index 22fd19aa..d1279c95 100644 --- a/src/lib/i18n.js +++ b/src/lib/i18n.js @@ -9,6 +9,7 @@ import colorConstants from '@/consts/color.js'; import accountConstants from '@/consts/account.js'; import categoryConstants from '@/consts/category.js'; import transactionConstants from '@/consts/transaction.js'; +import templateConstants from '@/consts/template.js'; import statisticsConstants from '@/consts/statistics.js'; import apiConstants from '@/consts/api.js'; @@ -17,6 +18,7 @@ import { isString, isNumber, isBoolean, + getNameByKeyValue, copyObjectTo, copyArrayTo } from './common.js'; @@ -311,6 +313,16 @@ function getMonthLongName(monthName, translateFn) { return translateFn(`datetime.${monthName}.long`); } +function getMonthdayOrdinal(monthDay, translateFn) { + return translateFn(`datetime.monthDayOrdinal.${monthDay}`); +} + +function getMonthdayShortName(monthDay, translateFn) { + return translateFn('format.misc.monthDay', { + ordinal: getMonthdayOrdinal(monthDay, translateFn) + }); +} + function getWeekdayShortName(weekDayName, translateFn) { return translateFn(`datetime.${weekDayName}.short`); } @@ -319,6 +331,30 @@ function getWeekdayLongName(weekDayName, translateFn) { return translateFn(`datetime.${weekDayName}.long`); } +function getMultiMonthdayShortNames(monthDays, translateFn) { + if (!monthDays) { + return ''; + } + + if (monthDays.length === 1) { + return translateFn('format.misc.monthDay', { + ordinal: getMonthdayOrdinal(monthDays[0], translateFn) + }); + } else { + return translateFn('format.misc.monthDays', { + multiMonthDays: joinMultiText(monthDays.map(monthDay => + translateFn('format.misc.eachMonthDayInMonthDays', { + ordinal: getMonthdayOrdinal(monthDay, translateFn) + })), translateFn) + }); + } +} + +function getMultiWeekdayLongNames(weekdayTypes, translateFn) { + const allWeekDays = getAllWeekDays(null, translateFn) + return joinMultiText(weekdayTypes.map(type => getNameByKeyValue(allWeekDays, type, 'type', 'displayName')), translateFn); +} + function getI18nLongDateFormat(translateFn, formatTypeValue) { const defaultLongDateFormatTypeName = translateFn('default.longDateFormat'); return getDateTimeFormat(translateFn, datetimeConstants.allLongDateFormat, datetimeConstants.allLongDateFormatArray, 'format.longDate', defaultLongDateFormatTypeName, datetimeConstants.defaultLongDateFormat, formatTypeValue); @@ -534,10 +570,23 @@ function getAllCurrencies(translateFn) { return allCurrencies; } -function getAllWeekDays(translateFn) { +function getAllWeekDays(firstDayOfWeek, translateFn) { const allWeekDays = []; - for (let i = 0; i < datetimeConstants.allWeekDaysArray.length; i++) { + if (!isNumber(firstDayOfWeek)) { + firstDayOfWeek = datetimeConstants.allWeekDays.Sunday.type; + } + + for (let i = firstDayOfWeek; i < datetimeConstants.allWeekDaysArray.length; i++) { + const weekDay = datetimeConstants.allWeekDaysArray[i]; + + allWeekDays.push({ + type: weekDay.type, + displayName: translateFn(`datetime.${weekDay.name}.long`) + }); + } + + for (let i = 0; i < firstDayOfWeek; i++) { const weekDay = datetimeConstants.allWeekDaysArray[i]; allWeekDays.push({ @@ -1078,6 +1127,25 @@ function getAllTransactionEditScopeTypes(translateFn) { return allEditScopeTypes; } +function getAllTransactionScheduledFrequencyTypes(translateFn) { + const allScheduledFrequencyTypes = []; + + for (const typeName in templateConstants.allTemplateScheduledFrequencyTypes) { + if (!Object.prototype.hasOwnProperty.call(templateConstants.allTemplateScheduledFrequencyTypes, typeName)) { + continue; + } + + const frequencyType = templateConstants.allTemplateScheduledFrequencyTypes[typeName]; + + allScheduledFrequencyTypes.push({ + type: frequencyType.type, + displayName: translateFn(frequencyType.name) + }); + } + + return allScheduledFrequencyTypes; +} + function getAllTransactionDefaultCategories(categoryType, locale, translateFn) { const allCategories = {}; const categoryTypes = []; @@ -1442,8 +1510,12 @@ export function i18nFunctions(i18nGlobal) { getAllShortTimeFormats: () => getAllShortTimeFormats(i18nGlobal.t), getMonthShortName: (month) => getMonthShortName(month, i18nGlobal.t), getMonthLongName: (month) => getMonthLongName(month, i18nGlobal.t), + getMonthdayOrdinal: (monthDay) => getMonthdayOrdinal(monthDay, i18nGlobal.t), + getMonthdayShortName: (monthDay) => getMonthdayShortName(monthDay, i18nGlobal.t), getWeekdayShortName: (weekDay) => getWeekdayShortName(weekDay, i18nGlobal.t), getWeekdayLongName: (weekDay) => getWeekdayLongName(weekDay, i18nGlobal.t), + getMultiMonthdayShortNames: (monthdays) => getMultiMonthdayShortNames(monthdays, i18nGlobal.t), + getMultiWeekdayLongNames: (weekdayTypes) => getMultiWeekdayLongNames(weekdayTypes, i18nGlobal.t), formatUnixTimeToLongDateTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nLongDateFormat(i18nGlobal.t, userStore.currentUserLongDateFormat) + ' ' + getI18nLongTimeFormat(i18nGlobal.t, userStore.currentUserLongTimeFormat), utcOffset, currentUtcOffset), formatUnixTimeToShortDateTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nShortDateFormat(i18nGlobal.t, userStore.currentUserShortDateFormat) + ' ' + getI18nShortTimeFormat(i18nGlobal.t, userStore.currentUserShortTimeFormat), utcOffset, currentUtcOffset), formatUnixTimeToLongDate: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nLongDateFormat(i18nGlobal.t, userStore.currentUserLongDateFormat), utcOffset, currentUtcOffset), @@ -1465,7 +1537,7 @@ export function i18nFunctions(i18nGlobal) { getAllTimezones: (includeSystemDefault) => getAllTimezones(includeSystemDefault, i18nGlobal.t), getTimezoneDifferenceDisplayText: (utcOffset) => getTimezoneDifferenceDisplayText(utcOffset, i18nGlobal.t), getAllCurrencies: () => getAllCurrencies(i18nGlobal.t), - getAllWeekDays: () => getAllWeekDays(i18nGlobal.t), + getAllWeekDays: (firstDayOfWeek) => getAllWeekDays(firstDayOfWeek, i18nGlobal.t), getAllDateRanges: (scene, includeCustom) => getAllDateRanges(scene, includeCustom, i18nGlobal.t), getAllRecentMonthDateRanges: (userStore, includeAll, includeCustom) => getAllRecentMonthDateRanges(userStore, includeAll, includeCustom, i18nGlobal.t), getDateRangeDisplayName: (userStore, dateType, startTime, endTime) => getDateRangeDisplayName(userStore, dateType, startTime, endTime, i18nGlobal.t), @@ -1493,6 +1565,7 @@ export function i18nFunctions(i18nGlobal) { getAllStatisticsChartDataTypes: (analysisType) => getAllStatisticsChartDataTypes(i18nGlobal.t, analysisType), getAllStatisticsSortingTypes: () => getAllStatisticsSortingTypes(i18nGlobal.t), getAllTransactionEditScopeTypes: () => getAllTransactionEditScopeTypes(i18nGlobal.t), + getAllTransactionScheduledFrequencyTypes: () => getAllTransactionScheduledFrequencyTypes(i18nGlobal.t), getAllTransactionDefaultCategories: (categoryType, locale) => getAllTransactionDefaultCategories(categoryType, locale, i18nGlobal.t), getAllDisplayExchangeRates: (exchangeRatesData) => getAllDisplayExchangeRates(exchangeRatesData, i18nGlobal.t), getEnableDisableOptions: () => getEnableDisableOptions(i18nGlobal.t), diff --git a/src/lib/server_settings.js b/src/lib/server_settings.js index 1f3b6413..00328820 100644 --- a/src/lib/server_settings.js +++ b/src/lib/server_settings.js @@ -41,6 +41,10 @@ export function isUserVerifyEmailEnabled() { return getServerSetting('v') === '1'; } +export function isUserScheduledTransactionEnabled() { + return getServerSetting('s') === '1'; +} + export function isDataExportingEnabled() { return getServerSetting('e') === '1'; } diff --git a/src/lib/services.js b/src/lib/services.js index 96186e55..d997c316 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -523,7 +523,7 @@ export default { getTransactionTemplate: ({ id }) => { return axios.get('v1/transaction/templates/get.json?id=' + id); }, - addTransactionTemplate: ({ templateType, name, type, categoryId, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, clientSessionId }) => { + addTransactionTemplate: ({ templateType, name, type, categoryId, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, scheduledFrequencyType, scheduledFrequency, utcOffset, clientSessionId }) => { return axios.post('v1/transaction/templates/add.json', { templateType, name, @@ -536,10 +536,13 @@ export default { hideAmount, tagIds, comment, + scheduledFrequencyType, + scheduledFrequency, + utcOffset, clientSessionId }); }, - modifyTransactionTemplate: ({ id, name, type, categoryId, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment }) => { + modifyTransactionTemplate: ({ id, name, type, categoryId, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, scheduledFrequencyType, scheduledFrequency, utcOffset }) => { return axios.post('v1/transaction/templates/modify.json', { id, name, @@ -551,7 +554,10 @@ export default { destinationAmount, hideAmount, tagIds, - comment + comment, + scheduledFrequencyType, + scheduledFrequency, + utcOffset }); }, hideTransactionTemplate: ({ id, hidden }) => { diff --git a/src/locales/en.json b/src/locales/en.json index f14aad81..eda4a76f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -73,6 +73,11 @@ "hoursAheadOfDefaultTimezone": "{hours} hour(s) ahead of default timezone", "hoursMinutesBehindDefaultTimezone": "{hours} hour(s) and {minutes} minutes behind default timezone", "hoursMinutesAheadOfDefaultTimezone": "{hours} hour(s) and {minutes} minutes ahead of default timezone", + "monthDay": "{ordinal} day", + "eachMonthDayInMonthDays": "{ordinal}", + "monthDays": "{multiMonthDays} days", + "everyMultiDaysOfWeek": "Every {days}", + "everyMultiDaysOfMonth": "Every {days} of month", "youHaveAccounts": "You have recorded {count} accounts", "accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.", "resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}" @@ -171,6 +176,39 @@ "December": { "short": "Dec", "long": "December" + }, + "monthDayOrdinal": { + "1": "1th", + "2": "2nd", + "3": "3rd", + "4": "4th", + "5": "5th", + "6": "6th", + "7": "7th", + "8": "8th", + "9": "9th", + "10": "10th", + "11": "11th", + "12": "12th", + "13": "13th", + "14": "14th", + "15": "15th", + "16": "16th", + "17": "17th", + "18": "18th", + "19": "19th", + "20": "20th", + "21": "21th", + "22": "22nd", + "23": "23rd", + "24": "24th", + "25": "25th", + "26": "26th", + "27": "27th", + "28": "28th", + "29": "29th", + "30": "30th", + "31": "31th" } }, "numeral": { @@ -1047,6 +1085,8 @@ "transaction template id is invalid": "Transaction template ID is invalid", "transaction template not found": "Transaction template is not found", "transaction template type is invalid": "Transaction template type is invalid", + "scheduled transaction is not enabled": "Scheduled transaction is not enabled", + "scheduled transaction frequency is invalid": "Scheduled transaction frequency is invalid", "query items cannot be blank": "There are no query items", "query items too much": "There are too many query items", "query items have invalid item": "There is invalid item in query items", @@ -1168,6 +1208,8 @@ "Select Date": "Select Date", "Select Time": "Select Time", "Now": "Now", + "Weekly": "Weekly", + "Monthly": "Monthly", "Custom": "Custom", "Greater than": "Greater than", "Less than": "Less than", @@ -1368,6 +1410,8 @@ "Edit Transaction": "Edit Transaction", "Add Transaction Template": "Add Transaction Template", "Edit Transaction Template": "Edit Transaction Template", + "Add Scheduled Transaction": "Add Scheduled Transaction", + "Edit Scheduled Transaction": "Edit Scheduled Transaction", "Modify Balance": "Modify Balance", "Expense Amount": "Expense Amount", "Income Amount": "Income Amount", @@ -1387,6 +1431,7 @@ "Without Tags": "Without Tags", "Multiple Tags": "Multiple Tags", "Transaction Time": "Transaction Time", + "Scheduled Transaction Frequency": "Scheduled Transaction Frequency", "Transaction Timezone": "Transaction Timezone", "Same time as default timezone": "Same time as default timezone", "Geographic Location": "Geographic Location", @@ -1622,10 +1667,12 @@ "Show Hidden Transaction Tags": "Show Hidden Transaction Tags", "Hide Hidden Transaction Tags": "Hide Hidden Transaction Tags", "Transaction Templates": "Transaction Templates", + "Scheduled Transactions": "Scheduled Transactions", "Template Name": "Template Name", "No available template": "No available template", "Once you add templates, you can long press the Add button on the home page to quickly add a new transaction": "Once you add templates, you can long press the Add button on the home page to quickly add a new transaction", "No available template. Once you add templates, you can quickly add a new transaction using the dropdown menu of the Add button on the transaction list page": "No available template. Once you add templates, you can quickly add a new transaction using the dropdown menu of the Add button on the transaction list page", + "No available scheduled transactions": "No available scheduled transactions", "Unable to retrieve template list": "Unable to retrieve template list", "Template list is up to date": "Template list is up to date", "Template list has been updated": "Template list has been updated", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 3309ba9d..ff566b07 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -73,6 +73,11 @@ "hoursAheadOfDefaultTimezone": "比默认时区早{hours}小时", "hoursMinutesBehindDefaultTimezone": "比默认时区晚{hours}小时{minutes}分", "hoursMinutesAheadOfDefaultTimezone": "比默认时区早{time}小时{minutes}分", + "monthDay": "{ordinal}日", + "eachMonthDayInMonthDays": "{ordinal}日", + "monthDays": "{multiMonthDays}", + "everyMultiDaysOfWeek": "每{days}", + "everyMultiDaysOfMonth": "每月{days}", "youHaveAccounts": "您已经记录了 {count} 个账户", "accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。", "resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}" @@ -171,6 +176,39 @@ "December": { "short": "12月", "long": "十二月" + }, + "monthDayOrdinal": { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + "11": "11", + "12": "12", + "13": "13", + "14": "14", + "15": "15", + "16": "16", + "17": "17", + "18": "18", + "19": "19", + "20": "20", + "21": "21", + "22": "22", + "23": "23", + "24": "24", + "25": "25", + "26": "26", + "27": "27", + "28": "28", + "29": "29", + "30": "30", + "31": "31" } }, "numeral": { @@ -1047,6 +1085,8 @@ "transaction template id is invalid": "交易模板ID无效", "transaction template not found": "交易模板不存在", "transaction template type is invalid": "交易模板类型无效", + "scheduled transaction is not enabled": "定时交易没有启用", + "scheduled transaction frequency is invalid": "定时交易周期无效", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1168,6 +1208,8 @@ "Select Date": "选择日期", "Select Time": "选择时间", "Now": "现在", + "Weekly": "每周", + "Monthly": "每月", "Custom": "自定义", "Greater than": "大于", "Less than": "小于", @@ -1368,6 +1410,8 @@ "Edit Transaction": "编辑交易", "Add Transaction Template": "添加交易模板", "Edit Transaction Template": "编辑交易模板", + "Add Scheduled Transaction": "添加定时交易", + "Edit Scheduled Transaction": "编辑定时交易", "Modify Balance": "修改余额", "Expense Amount": "支出金额", "Income Amount": "收入金额", @@ -1387,6 +1431,7 @@ "Without Tags": "没有标签", "Multiple Tags": "多个标签", "Transaction Time": "交易时间", + "Scheduled Transaction Frequency": "定时交易周期", "Transaction Timezone": "交易时区", "Same time as default timezone": "与默认时区时间相同", "Geographic Location": "地理位置", @@ -1622,10 +1667,12 @@ "Show Hidden Transaction Tags": "显示隐藏的交易标签", "Hide Hidden Transaction Tags": "不显示隐藏的交易标签", "Transaction Templates": "交易模板", + "Scheduled Transactions": "定时交易", "Template Name": "模板名称", "No available template": "没有可用的模板", "Once you add templates, you can long press the Add button on the home page to quickly add a new transaction": "当添加模板后,您可以在主界面长按添加按钮快速添加新的交易", "No available template. Once you add templates, you can quickly add a new transaction using the dropdown menu of the Add button on the transaction list page": "没有可用的模板。当添加模板后,您可以通过交易列表添加按钮的下拉菜单快速添加新的交易", + "No available scheduled transactions": "没有可用的定时交易", "Unable to retrieve template list": "无法获取模板列表", "Template list is up to date": "模板列表已是最新", "Template list has been updated": "模板列表已更新", diff --git a/src/mobile-main.js b/src/mobile-main.js index f9b0b3c0..f1fb7437 100644 --- a/src/mobile-main.js +++ b/src/mobile-main.js @@ -110,6 +110,7 @@ import InformationSheet from '@/components/mobile/InformationSheet.vue'; import NumberPadSheet from '@/components/mobile/NumberPadSheet.vue'; import MapSheet from '@/components/mobile/MapSheet.vue'; import TransactionTagSelectionSheet from '@/components/mobile/TransactionTagSelectionSheet.vue'; +import ScheduleFrequencySheet from '@/components/mobile/ScheduleFrequencySheet.vue'; import TextareaAutoSize from '@/directives/mobile/textareaAutoSize.js'; @@ -188,6 +189,7 @@ app.component('InformationSheet', InformationSheet); app.component('NumberPadSheet', NumberPadSheet); app.component('MapSheet', MapSheet); app.component('TransactionTagSelectionSheet', TransactionTagSelectionSheet); +app.component('ScheduleFrequencySheet', ScheduleFrequencySheet); app.directive('TextareaAutoSize', TextareaAutoSize); diff --git a/src/router/desktop.js b/src/router/desktop.js index 38c4536c..9fdf5780 100644 --- a/src/router/desktop.js +++ b/src/router/desktop.js @@ -1,5 +1,6 @@ import { createRouter, createWebHashHistory } from 'vue-router'; +import templateConstants from '@/consts/template.js'; import userState from '@/lib/userstate.js'; import MainLayout from '@/views/desktop/MainLayout.vue'; @@ -141,7 +142,18 @@ const router = createRouter({ { path: '/template/list', component: TransactionTemplateListPage, - beforeEnter: checkLogin + beforeEnter: checkLogin, + props: { + initType: templateConstants.allTemplateTypes.Normal + } + }, + { + path: '/schedule/list', + component: TransactionTemplateListPage, + beforeEnter: checkLogin, + props: { + initType: templateConstants.allTemplateTypes.Schedule + } }, { path: '/exchange_rates', diff --git a/src/router/mobile.js b/src/router/mobile.js index a3fad734..9e84e6ba 100644 --- a/src/router/mobile.js +++ b/src/router/mobile.js @@ -297,6 +297,11 @@ const routes = [ async: asyncResolve(TemplateListPage), beforeEnter: [checkLogin] }, + { + path: '/schedule/list', + async: asyncResolve(TemplateListPage), + beforeEnter: [checkLogin] + }, { path: '/template/add', async: asyncResolve(TransactionEditPage), diff --git a/src/stores/transactionTemplate.js b/src/stores/transactionTemplate.js index e706be42..5f58d19f 100644 --- a/src/stores/transactionTemplate.js +++ b/src/stores/transactionTemplate.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import transactionConstants from '@/consts/transaction.js'; +import templateConstants from '@/consts/template.js'; import { isDefined, isObject, isArray, isEquals } from '@/lib/common.js'; import services from '@/lib/services.js'; import logger from '@/lib/logger.js'; @@ -230,6 +231,12 @@ export const useTransactionTemplatesStore = defineStore('transactionTemplates', submitTemplate.clientSessionId = clientSessionId; } + if (template.templateType === templateConstants.allTemplateTypes.Schedule) { + submitTemplate.scheduledFrequencyType = template.scheduledFrequencyType; + submitTemplate.scheduledFrequency = template.scheduledFrequency; + submitTemplate.utcOffset = template.utcOffset; + } + if (template.type === transactionConstants.allTransactionTypes.Expense) { submitTemplate.categoryId = template.expenseCategory; } else if (template.type === transactionConstants.allTransactionTypes.Income) { diff --git a/src/views/desktop/MainLayout.vue b/src/views/desktop/MainLayout.vue index 23e60dba..d698138f 100644 --- a/src/views/desktop/MainLayout.vue +++ b/src/views/desktop/MainLayout.vue @@ -67,6 +67,12 @@ {{ $t('Transaction Templates') }} +