scheduled transaction supports start time and end time (#36)

This commit is contained in:
MaysWind
2025-02-28 00:14:52 +08:00
parent d769e833e7
commit 377a4899b7
22 changed files with 500 additions and 17 deletions
+64 -3
View File
@@ -156,7 +156,12 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
@@ -260,6 +265,34 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
if templateModifyReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
startUnixTime := startTime.Unix()
newTemplate.ScheduledStartTime = &startUnixTime
}
if templateModifyReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
endUnixTime := endTime.Unix()
newTemplate.ScheduledEndTime = &endUnixTime
}
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
if newTemplate.Name == template.Name &&
@@ -277,6 +310,8 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
@@ -419,7 +454,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
return true, nil
}
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
@@ -441,9 +476,35 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
if templateCreateReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
startUnixTime := startTime.Unix()
template.ScheduledStartTime = &startUnixTime
}
if templateCreateReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
endUnixTime := endTime.Unix()
template.ScheduledEndTime = &endUnixTime
}
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
return template
return template, nil
}
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
+7 -6
View File
@@ -4,10 +4,11 @@ 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")
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
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")
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
ErrScheduledTransactionTemplateStartDataLaterThanEndDate = NewNormalError(NormalSubcategoryTemplate, 6, http.StatusBadRequest, "scheduled transaction start date is later than end time")
)
+25 -4
View File
@@ -2,6 +2,7 @@ package models
import (
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -29,15 +30,17 @@ const (
type TransactionTemplate struct {
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"`
Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
Name string `xorm:"VARCHAR(64) 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)"`
ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledFrequency string `xorm:"VARCHAR(100)"`
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
ScheduledStartTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledEndTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledTimezoneUtcOffset int16
TagIds string `xorm:"VARCHAR(255) NOT NULL"`
Amount int64 `xorm:"NOT NULL"`
@@ -77,6 +80,8 @@ type TransactionTemplateCreateRequest struct {
Comment string `json:"comment" binding:"max=255"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
ClientSessionId string `json:"clientSessionId"`
}
@@ -102,6 +107,8 @@ type TransactionTemplateModifyRequest struct {
Comment string `json:"comment" binding:"max=255"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
}
@@ -133,6 +140,8 @@ type TransactionTemplateInfoResponse struct {
Name string `json:"name"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType,omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency,omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledAt *int16 `json:"scheduledAt,omitempty"`
DisplayOrder int32 `json:"displayOrder"`
Hidden bool `json:"hidden"`
@@ -171,6 +180,18 @@ func (t *TransactionTemplate) ToTransactionTemplateInfoResponse(serverUtcOffset
response.ScheduledFrequencyType = &t.ScheduledFrequencyType
response.ScheduledFrequency = &t.ScheduledFrequency
response.ScheduledAt = &t.ScheduledAt
templateTimeZone := time.FixedZone("Template Timezone", int(t.ScheduledTimezoneUtcOffset)*60)
if t.ScheduledStartTime != nil {
startDate := utils.FormatUnixTimeToLongDate(*t.ScheduledStartTime, templateTimeZone)
response.ScheduledStartDate = &startDate
}
if t.ScheduledEndTime != nil {
endDate := utils.FormatUnixTimeToLongDate(*t.ScheduledEndTime, templateTimeZone)
response.ScheduledEndDate = &endDate
}
}
return response
+1 -1
View File
@@ -136,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", "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)
updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_start_time", "scheduled_end_time", "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
+13 -1
View File
@@ -398,7 +398,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
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)
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
if err != nil {
return err
@@ -457,6 +457,18 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
continue
}
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is earlier than the start time %d", template.TemplateId, *template.ScheduledStartTime)
continue
}
if template.ScheduledEndTime != nil && *template.ScheduledEndTime < transactionUnixTime {
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is later than the end time %d", template.TemplateId, *template.ScheduledEndTime)
continue
}
var transactionDbType models.TransactionDbType
if template.Type == models.TRANSACTION_TYPE_EXPENSE {
+30
View File
@@ -9,6 +9,7 @@ import (
)
const (
longDateFormat = "2006-01-02"
longDateTimeFormat = "2006-01-02 15:04:05"
longDateTimeWithTimezoneFormat = "2006-01-02 15:04:05Z07:00"
longDateTimeWithTimezoneFormat2 = "2006-01-02 15:04:05 Z0700"
@@ -42,6 +43,17 @@ func ParseNumericYearMonth(yearMonth string) (int32, int32, error) {
return year, month, nil
}
// FormatUnixTimeToLongDate returns a textual representation of the unix time formatted by long date time format
func FormatUnixTimeToLongDate(unixTime int64, timezone *time.Location) string {
t := parseFromUnixTime(unixTime)
if timezone != nil {
t = t.In(timezone)
}
return t.Format(longDateFormat)
}
// FormatUnixTimeToLongDateTime returns a textual representation of the unix time formatted by long date time format
func FormatUnixTimeToLongDateTime(unixTime int64, timezone *time.Location) string {
t := parseFromUnixTime(unixTime)
@@ -119,6 +131,24 @@ func GetMaxUnixTimeWithSameLocalDateTime(unixTime int64, currentUtcOffset int16)
return unixTime + int64(currentUtcOffset)*60 - westernmostTimezoneUtcOffset*60
}
// ParseFromLongDateFirstTime parses a formatted string in long date format
func ParseFromLongDateFirstTime(t string, utcOffset int16) (time.Time, error) {
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
return time.ParseInLocation(longDateFormat, t, timezone)
}
// ParseFromLongDateLastTime parses a formatted string in long date format
func ParseFromLongDateLastTime(t string, utcOffset int16) (time.Time, error) {
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
lastTime, err := time.ParseInLocation(longDateFormat, t, timezone)
if err != nil {
return lastTime, err
}
return lastTime.Add(24 * time.Hour).Add(-1 * time.Nanosecond), nil
}
// ParseFromLongDateTimeToMinUnixTime parses a formatted string in long date time format to minimal unix time (the westernmost timezone)
func ParseFromLongDateTimeToMinUnixTime(t string) (time.Time, error) {
timezone := time.FixedZone("Timezone", easternmostTimezoneUtcOffset*60)
+32
View File
@@ -18,6 +18,20 @@ func TestParseNumericYearMonth(t *testing.T) {
assert.Equal(t, expectedMonth, actualMonth)
}
func TestFormatUnixTimeToLongDate(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8
expectedValue := "2021-03-31"
actualValue := FormatUnixTimeToLongDate(unixTime, utcTimezone)
assert.Equal(t, expectedValue, actualValue)
expectedValue = "2021-04-01"
actualValue = FormatUnixTimeToLongDate(unixTime, utc8Timezone)
assert.Equal(t, expectedValue, actualValue)
}
func TestFormatUnixTimeToLongDateTime(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
@@ -106,6 +120,24 @@ func TestGetMaxUnixTimeWithSameLocalDateTime(t *testing.T) {
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateFirstTime(t *testing.T) {
expectedValue := int64(1690819200)
actualTime, err := ParseFromLongDateFirstTime("2023-08-01", 480)
assert.Equal(t, nil, err)
actualValue := actualTime.Unix()
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateLastTime(t *testing.T) {
expectedValue := int64(1690905599)
actualTime, err := ParseFromLongDateLastTime("2023-08-01", 480)
assert.Equal(t, nil, err)
actualValue := actualTime.Unix()
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateTimeToMinUnixTime(t *testing.T) {
expectedValue := int64(1690797600)
actualTime, err := ParseFromLongDateTimeToMinUnixTime("2023-08-01 00:00:00")