feat: recurring transactions
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s
This commit is contained in:
parent
f0a309ea20
commit
448b6f6262
16 changed files with 1956 additions and 4 deletions
427
internal/service/recurring_event.go
Normal file
427
internal/service/recurring_event.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// RecurringEventService manages recurring bills, funds, and transfers and
|
||||
// materializes due events into actual transactions via TransactionService.
|
||||
type RecurringEventService struct {
|
||||
repo repository.RecurringEventRepository
|
||||
txService *TransactionService
|
||||
accountService *AccountService
|
||||
}
|
||||
|
||||
func NewRecurringEventService(
|
||||
repo repository.RecurringEventRepository,
|
||||
txService *TransactionService,
|
||||
accountService *AccountService,
|
||||
) *RecurringEventService {
|
||||
return &RecurringEventService{
|
||||
repo: repo,
|
||||
txService: txService,
|
||||
accountService: accountService,
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRecurringEventInput struct {
|
||||
SpaceID string
|
||||
Kind model.RecurringEventKind
|
||||
SourceAccountID string
|
||||
Title string
|
||||
Amount decimal.Decimal
|
||||
Description string
|
||||
|
||||
Frequency model.RecurringFrequency
|
||||
IntervalCount int
|
||||
DayOfWeek *int
|
||||
DayOfMonth *int
|
||||
MonthOfYear *int
|
||||
FireHour int
|
||||
FireMinute int
|
||||
Timezone string
|
||||
|
||||
// StartDate is the local calendar date (Y-M-D) of the first intended firing
|
||||
// in the event's timezone. The first NextRunAt is computed from StartDate,
|
||||
// FireHour, FireMinute, and Timezone — clamped to the recurrence anchors.
|
||||
StartDate time.Time
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.RecurringEvent, error) {
|
||||
if err := validateRule(input.Kind, input.SourceAccountID, input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, input.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("title is required")
|
||||
}
|
||||
if !input.Amount.IsPositive() {
|
||||
return nil, fmt.Errorf("amount must be greater than zero")
|
||||
}
|
||||
if input.SpaceID == "" {
|
||||
return nil, fmt.Errorf("space id is required")
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(input.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timezone: %w", err)
|
||||
}
|
||||
if input.StartDate.IsZero() {
|
||||
return nil, fmt.Errorf("start date is required")
|
||||
}
|
||||
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var description *string
|
||||
if d := strings.TrimSpace(input.Description); d != "" {
|
||||
description = &d
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
ev := &model.RecurringEvent{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: input.SpaceID,
|
||||
Kind: input.Kind,
|
||||
SourceAccountID: input.SourceAccountID,
|
||||
Title: title,
|
||||
Amount: input.Amount,
|
||||
Description: description,
|
||||
Frequency: input.Frequency,
|
||||
IntervalCount: input.IntervalCount,
|
||||
DayOfWeek: input.DayOfWeek,
|
||||
DayOfMonth: input.DayOfMonth,
|
||||
MonthOfYear: input.MonthOfYear,
|
||||
FireHour: input.FireHour,
|
||||
FireMinute: input.FireMinute,
|
||||
Timezone: input.Timezone,
|
||||
NextRunAt: firstFire.UTC(),
|
||||
Paused: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ev); err != nil {
|
||||
return nil, fmt.Errorf("failed to create recurring event: %w", err)
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
type UpdateRecurringEventInput struct {
|
||||
ID string
|
||||
Kind model.RecurringEventKind
|
||||
SourceAccountID string
|
||||
Title string
|
||||
Amount decimal.Decimal
|
||||
Description string
|
||||
|
||||
Frequency model.RecurringFrequency
|
||||
IntervalCount int
|
||||
DayOfWeek *int
|
||||
DayOfMonth *int
|
||||
MonthOfYear *int
|
||||
FireHour int
|
||||
FireMinute int
|
||||
Timezone string
|
||||
|
||||
// StartDate, if non-zero, recomputes the next firing. If zero, the current
|
||||
// cursor is kept (useful for purely cosmetic edits like renaming).
|
||||
StartDate time.Time
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.RecurringEvent, error) {
|
||||
if err := validateRule(input.Kind, input.SourceAccountID, input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, input.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("title is required")
|
||||
}
|
||||
if !input.Amount.IsPositive() {
|
||||
return nil, fmt.Errorf("amount must be greater than zero")
|
||||
}
|
||||
|
||||
existing, err := s.repo.ByID(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(input.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timezone: %w", err)
|
||||
}
|
||||
|
||||
nextRun := existing.NextRunAt
|
||||
if !input.StartDate.IsZero() {
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextRun = firstFire.UTC()
|
||||
}
|
||||
|
||||
var description *string
|
||||
if d := strings.TrimSpace(input.Description); d != "" {
|
||||
description = &d
|
||||
}
|
||||
|
||||
existing.Kind = input.Kind
|
||||
existing.SourceAccountID = input.SourceAccountID
|
||||
existing.Title = title
|
||||
existing.Amount = input.Amount
|
||||
existing.Description = description
|
||||
existing.Frequency = input.Frequency
|
||||
existing.IntervalCount = input.IntervalCount
|
||||
existing.DayOfWeek = input.DayOfWeek
|
||||
existing.DayOfMonth = input.DayOfMonth
|
||||
existing.MonthOfYear = input.MonthOfYear
|
||||
existing.FireHour = input.FireHour
|
||||
existing.FireMinute = input.FireMinute
|
||||
existing.Timezone = input.Timezone
|
||||
existing.NextRunAt = nextRun
|
||||
|
||||
if err := s.repo.Update(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) Delete(id string) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) SetPaused(id string, paused bool) error {
|
||||
return s.repo.SetPaused(id, paused)
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) Get(id string) (*model.RecurringEvent, error) {
|
||||
return s.repo.ByID(id)
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) ListBySpace(spaceID string) ([]*model.RecurringEvent, error) {
|
||||
return s.repo.BySpaceID(spaceID)
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) ListByAccount(accountID string) ([]*model.RecurringEvent, error) {
|
||||
return s.repo.ByAccountID(accountID)
|
||||
}
|
||||
|
||||
// ProcessDue materializes every recurring event whose next_run_at is at or
|
||||
// before `now`, advancing each cursor and backfilling missed occurrences. One
|
||||
// event's failure is logged but does not stop processing of others.
|
||||
func (s *RecurringEventService) ProcessDue(now time.Time) error {
|
||||
events, err := s.repo.DueBefore(now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list due events: %w", err)
|
||||
}
|
||||
for _, ev := range events {
|
||||
if err := s.fireUntilCaughtUp(ev, now); err != nil {
|
||||
slog.Error("recurring event materialization failed",
|
||||
"error", err, "event_id", ev.ID, "kind", ev.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) fireUntilCaughtUp(ev *model.RecurringEvent, now time.Time) error {
|
||||
loc, err := time.LoadLocation(ev.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timezone %q: %w", ev.Timezone, err)
|
||||
}
|
||||
for !ev.NextRunAt.After(now) {
|
||||
if err := s.materialize(ev); err != nil {
|
||||
return fmt.Errorf("materialize: %w", err)
|
||||
}
|
||||
next, err := nextFireAfter(ev, ev.NextRunAt, loc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute next: %w", err)
|
||||
}
|
||||
last := ev.NextRunAt
|
||||
if err := s.repo.UpdateCursor(ev.ID, next, last); err != nil {
|
||||
return fmt.Errorf("persist cursor: %w", err)
|
||||
}
|
||||
ev.LastRunAt = &last
|
||||
ev.NextRunAt = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringEventService) materialize(ev *model.RecurringEvent) error {
|
||||
desc := ""
|
||||
if ev.Description != nil {
|
||||
desc = *ev.Description
|
||||
}
|
||||
switch ev.Kind {
|
||||
case model.RecurringEventKindBill:
|
||||
_, err := s.txService.PayBill(PayBillInput{
|
||||
AccountID: ev.SourceAccountID,
|
||||
Title: ev.Title,
|
||||
Amount: ev.Amount,
|
||||
OccurredAt: ev.NextRunAt,
|
||||
Description: desc,
|
||||
})
|
||||
return err
|
||||
case model.RecurringEventKindFund:
|
||||
_, err := s.txService.Deposit(DepositInput{
|
||||
AccountID: ev.SourceAccountID,
|
||||
Title: ev.Title,
|
||||
Amount: ev.Amount,
|
||||
OccurredAt: ev.NextRunAt,
|
||||
Description: desc,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unknown recurring event kind: %s", ev.Kind)
|
||||
}
|
||||
|
||||
// ----- Recurrence math -----
|
||||
|
||||
func validateRule(kind model.RecurringEventKind, src string, freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, tz string) error {
|
||||
switch kind {
|
||||
case model.RecurringEventKindBill, model.RecurringEventKindFund:
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("invalid kind: %s", kind)
|
||||
}
|
||||
if src == "" {
|
||||
return fmt.Errorf("source account is required")
|
||||
}
|
||||
if interval < 1 {
|
||||
return fmt.Errorf("interval must be at least 1")
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return fmt.Errorf("invalid time of day")
|
||||
}
|
||||
if tz == "" {
|
||||
return fmt.Errorf("timezone is required")
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return fmt.Errorf("invalid timezone: %w", err)
|
||||
}
|
||||
switch freq {
|
||||
case model.RecurringFrequencyDaily:
|
||||
// no anchors required
|
||||
case model.RecurringFrequencyWeekly:
|
||||
if dow == nil || *dow < 0 || *dow > 6 {
|
||||
return fmt.Errorf("weekly events require day of week")
|
||||
}
|
||||
case model.RecurringFrequencyMonthly:
|
||||
if dom == nil || *dom < 1 || *dom > 31 {
|
||||
return fmt.Errorf("monthly events require day of month")
|
||||
}
|
||||
case model.RecurringFrequencyYearly:
|
||||
if dom == nil || *dom < 1 || *dom > 31 {
|
||||
return fmt.Errorf("yearly events require day of month")
|
||||
}
|
||||
if moy == nil || *moy < 1 || *moy > 12 {
|
||||
return fmt.Errorf("yearly events require month of year")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid frequency: %s", freq)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstFireOnOrAfter computes the first firing in `loc` at or after the local
|
||||
// midnight of startDate, snapped to the recurrence anchors and time-of-day.
|
||||
func firstFireOnOrAfter(freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, loc *time.Location, startDate time.Time) (time.Time, error) {
|
||||
startLocal := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, loc)
|
||||
threshold := startLocal.Add(-time.Nanosecond) // nextFireAfter computes strictly-after; -1ns lets the first candidate land on startDate
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: freq,
|
||||
IntervalCount: interval,
|
||||
DayOfWeek: dow,
|
||||
DayOfMonth: dom,
|
||||
MonthOfYear: moy,
|
||||
FireHour: hour,
|
||||
FireMinute: minute,
|
||||
}
|
||||
return nextFireAfter(ev, threshold, loc)
|
||||
}
|
||||
|
||||
// nextFireAfter computes the next firing strictly after `after`, in UTC.
|
||||
func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location) (time.Time, error) {
|
||||
afterLocal := after.In(loc)
|
||||
switch ev.Frequency {
|
||||
case model.RecurringFrequencyDaily:
|
||||
c := time.Date(afterLocal.Year(), afterLocal.Month(), afterLocal.Day(), ev.FireHour, ev.FireMinute, 0, 0, loc)
|
||||
for !c.After(after) {
|
||||
c = c.AddDate(0, 0, ev.IntervalCount)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyWeekly:
|
||||
if ev.DayOfWeek == nil {
|
||||
return time.Time{}, fmt.Errorf("weekly event missing day of week")
|
||||
}
|
||||
c := time.Date(afterLocal.Year(), afterLocal.Month(), afterLocal.Day(), ev.FireHour, ev.FireMinute, 0, 0, loc)
|
||||
shift := (int(time.Weekday(*ev.DayOfWeek)) - int(c.Weekday()) + 7) % 7
|
||||
c = c.AddDate(0, 0, shift)
|
||||
for !c.After(after) {
|
||||
c = c.AddDate(0, 0, 7*ev.IntervalCount)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyMonthly:
|
||||
if ev.DayOfMonth == nil {
|
||||
return time.Time{}, fmt.Errorf("monthly event missing day of month")
|
||||
}
|
||||
y, m := afterLocal.Year(), afterLocal.Month()
|
||||
c := monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
for !c.After(after) {
|
||||
y, m = addMonths(y, m, ev.IntervalCount)
|
||||
c = monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyYearly:
|
||||
if ev.DayOfMonth == nil || ev.MonthOfYear == nil {
|
||||
return time.Time{}, fmt.Errorf("yearly event missing anchors")
|
||||
}
|
||||
moy := time.Month(*ev.MonthOfYear)
|
||||
y := afterLocal.Year()
|
||||
c := monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
for !c.After(after) {
|
||||
y += ev.IntervalCount
|
||||
c = monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unknown frequency: %s", ev.Frequency)
|
||||
}
|
||||
|
||||
// monthlyCandidate constructs a fire time at year/month, clamping the day to
|
||||
// that month's actual length (e.g. day=31 in February becomes the 28th/29th).
|
||||
func monthlyCandidate(year int, month time.Month, dom, hour, minute int, loc *time.Location) time.Time {
|
||||
last := lastDayOfMonth(year, month, loc)
|
||||
if dom > last {
|
||||
dom = last
|
||||
}
|
||||
return time.Date(year, month, dom, hour, minute, 0, 0, loc)
|
||||
}
|
||||
|
||||
func lastDayOfMonth(year int, month time.Month, loc *time.Location) int {
|
||||
firstOfNext := time.Date(year, month+1, 1, 0, 0, 0, 0, loc)
|
||||
return firstOfNext.AddDate(0, 0, -1).Day()
|
||||
}
|
||||
|
||||
func addMonths(y int, m time.Month, n int) (int, time.Month) {
|
||||
total := (int(m) - 1) + n
|
||||
years := total / 12
|
||||
rem := total % 12
|
||||
if rem < 0 {
|
||||
rem += 12
|
||||
years--
|
||||
}
|
||||
return y + years, time.Month(rem + 1)
|
||||
}
|
||||
183
internal/service/recurring_event_test.go
Normal file
183
internal/service/recurring_event_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
)
|
||||
|
||||
func mustLoad(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestNextFireAfter_Daily(t *testing.T) {
|
||||
loc := mustLoad(t, "America/New_York")
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyDaily,
|
||||
IntervalCount: 1,
|
||||
FireHour: 9,
|
||||
FireMinute: 30,
|
||||
}
|
||||
// "after" = 2026-05-01 14:00 NY → next fire same day's 09:30 already passed,
|
||||
// so should be 2026-05-02 09:30 NY.
|
||||
after := time.Date(2026, 5, 1, 14, 0, 0, 0, loc)
|
||||
got, err := nextFireAfter(ev, after, loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 5, 2, 9, 30, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("daily next: got %v want %v", got.In(loc), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_DailyEvery3Days(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyDaily,
|
||||
IntervalCount: 3,
|
||||
FireHour: 8,
|
||||
FireMinute: 0,
|
||||
}
|
||||
after := time.Date(2026, 5, 1, 8, 0, 1, 0, loc) // 1 second past today's fire
|
||||
got, _ := nextFireAfter(ev, after, loc)
|
||||
want := time.Date(2026, 5, 4, 8, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_Weekly(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Every Tuesday (DayOfWeek=2) at 10:00, after Wed 2026-05-06.
|
||||
dow := 2
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyWeekly,
|
||||
IntervalCount: 1,
|
||||
DayOfWeek: &dow,
|
||||
FireHour: 10,
|
||||
FireMinute: 0,
|
||||
}
|
||||
after := time.Date(2026, 5, 6, 12, 0, 0, 0, loc) // Wed 12:00
|
||||
got, _ := nextFireAfter(ev, after, loc)
|
||||
want := time.Date(2026, 5, 12, 10, 0, 0, 0, loc) // following Tue
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_MonthlyDayClamp(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Every month on day 31. Jan 31 → Feb 28 (2026 not leap).
|
||||
dom := 31
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyMonthly,
|
||||
IntervalCount: 1,
|
||||
DayOfMonth: &dom,
|
||||
FireHour: 0,
|
||||
FireMinute: 0,
|
||||
}
|
||||
after := time.Date(2026, 1, 31, 0, 0, 1, 0, loc)
|
||||
got, _ := nextFireAfter(ev, after, loc)
|
||||
want := time.Date(2026, 2, 28, 0, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
// Then Feb 28 → Mar 31 (anchor preserved).
|
||||
got2, _ := nextFireAfter(ev, want, loc)
|
||||
want2 := time.Date(2026, 3, 31, 0, 0, 0, 0, loc)
|
||||
if !got2.Equal(want2) {
|
||||
t.Errorf("got2 %v want2 %v", got2, want2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_Yearly(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
dom := 15
|
||||
moy := 6
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyYearly,
|
||||
IntervalCount: 1,
|
||||
DayOfMonth: &dom,
|
||||
MonthOfYear: &moy,
|
||||
FireHour: 12,
|
||||
FireMinute: 0,
|
||||
}
|
||||
after := time.Date(2026, 6, 15, 12, 0, 1, 0, loc)
|
||||
got, _ := nextFireAfter(ev, after, loc)
|
||||
want := time.Date(2027, 6, 15, 12, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_TimezoneCrossesUTCBoundary(t *testing.T) {
|
||||
loc := mustLoad(t, "America/Los_Angeles")
|
||||
// 09:00 LA daily. From 2026-05-01 17:00 UTC (10:00 LA), already past, so
|
||||
// next is 2026-05-02 09:00 LA = 16:00 UTC.
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyDaily,
|
||||
IntervalCount: 1,
|
||||
FireHour: 9,
|
||||
FireMinute: 0,
|
||||
}
|
||||
after := time.Date(2026, 5, 1, 17, 0, 0, 0, time.UTC)
|
||||
got, _ := nextFireAfter(ev, after, loc)
|
||||
want := time.Date(2026, 5, 2, 16, 0, 0, 0, time.UTC) // PDT, UTC-7
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Daily at 09:00, start date 2026-05-10 → first fire 2026-05-10 09:00.
|
||||
got, err := firstFireOnOrAfter(model.RecurringFrequencyDaily, 1, nil, nil, nil, 9, 0, loc, time.Date(2026, 5, 10, 0, 0, 0, 0, loc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 5, 10, 9, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstFireOnOrAfter_WeeklyShiftsToTargetDayOfWeek(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Start 2026-05-04 (Mon), target weekday Friday (5) → first fire 2026-05-08.
|
||||
got, _ := firstFireOnOrAfter(model.RecurringFrequencyWeekly, 1, intPtr(5), nil, nil, 8, 0, loc, time.Date(2026, 5, 4, 0, 0, 0, 0, loc))
|
||||
want := time.Date(2026, 5, 8, 8, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMonths(t *testing.T) {
|
||||
tests := []struct {
|
||||
y int
|
||||
m time.Month
|
||||
n int
|
||||
wy int
|
||||
wm time.Month
|
||||
}{
|
||||
{2026, time.January, 1, 2026, time.February},
|
||||
{2026, time.December, 1, 2027, time.January},
|
||||
{2026, time.November, 3, 2027, time.February},
|
||||
{2026, time.January, 12, 2027, time.January},
|
||||
{2026, time.January, 25, 2028, time.February},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
gy, gm := addMonths(tt.y, tt.m, tt.n)
|
||||
if gy != tt.wy || gm != tt.wm {
|
||||
t.Errorf("addMonths(%d,%v,%d) = %d,%v; want %d,%v", tt.y, tt.m, tt.n, gy, gm, tt.wy, tt.wm)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue