feat: recurring transactions
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s

This commit is contained in:
juancwu 2026-05-04 04:42:22 +00:00
commit 448b6f6262
16 changed files with 1956 additions and 4 deletions

View 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)
}

View 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)
}
}
}