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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue