From 448b6f62629eab1046fd05dd2c928be55787e1bc Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 4 May 2026 04:42:22 +0000 Subject: [PATCH] feat: recurring transactions --- cmd/server/main.go | 27 ++ internal/app/app.go | 12 +- .../00015_create_recurring_events_table.sql | 43 ++ internal/handler/recurring_event.go | 454 ++++++++++++++++++ internal/model/financial_management.go | 42 ++ internal/repository/recurring_event.go | 163 +++++++ internal/routes/routes.go | 10 + internal/service/recurring_event.go | 427 ++++++++++++++++ internal/service/recurring_event_test.go | 183 +++++++ internal/ui/forms/helpers.go | 5 + internal/ui/forms/recurring_event.templ | 336 +++++++++++++ internal/ui/pages/recurring_event_helpers.go | 57 +++ .../pages/space_create_recurring_event.templ | 29 ++ .../ui/pages/space_edit_recurring_event.templ | 30 ++ internal/ui/pages/space_overview.templ | 10 + .../ui/pages/space_recurring_events.templ | 132 +++++ 16 files changed, 1956 insertions(+), 4 deletions(-) create mode 100644 internal/db/migrations/00015_create_recurring_events_table.sql create mode 100644 internal/handler/recurring_event.go create mode 100644 internal/repository/recurring_event.go create mode 100644 internal/service/recurring_event.go create mode 100644 internal/service/recurring_event_test.go create mode 100644 internal/ui/forms/helpers.go create mode 100644 internal/ui/forms/recurring_event.templ create mode 100644 internal/ui/pages/recurring_event_helpers.go create mode 100644 internal/ui/pages/space_create_recurring_event.templ create mode 100644 internal/ui/pages/space_edit_recurring_event.templ create mode 100644 internal/ui/pages/space_recurring_events.templ diff --git a/cmd/server/main.go b/cmd/server/main.go index 0a59816..e45b01d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -55,11 +55,16 @@ func main() { Handler: finalHandler, } + workerCtx, stopWorker := context.WithCancel(context.Background()) + defer stopWorker() + go runRecurringWorker(workerCtx, a) + go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) <-sigCh slog.Info("shutting down gracefully") + stopWorker() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() srv.Shutdown(ctx) @@ -72,3 +77,25 @@ func main() { panic(err) } } + +// runRecurringWorker materializes due recurring events on a fixed cadence. It +// fires once at startup (catching up anything missed while the server was +// down), then ticks every minute until ctx is cancelled. +func runRecurringWorker(ctx context.Context, a *app.App) { + tick := func() { + if err := a.RecurringEventService.ProcessDue(time.Now().UTC()); err != nil { + slog.Error("recurring event processing failed", "error", err) + } + } + tick() + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tick() + } + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 8a71c38..58f32b5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,8 +19,9 @@ type App struct { SpaceService *service.SpaceService AccountService *service.AccountService AllocationService *service.AllocationService - TransactionService *service.TransactionService - InviteService *service.InviteService + TransactionService *service.TransactionService + RecurringEventService *service.RecurringEventService + InviteService *service.InviteService AuditLogService *service.SpaceAuditLogService TxAuditLogService *service.TransactionAuditLogService AccountActivitySvc *service.AccountActivityService @@ -50,6 +51,7 @@ func New(cfg *config.Config) (*App, error) { invitationRepository := repository.NewInvitationRepository(database) auditLogRepository := repository.NewSpaceAuditLogRepository(database) txAuditLogRepository := repository.NewTransactionAuditLogRepository(database) + recurringEventRepository := repository.NewRecurringEventRepository(database) // Services userService := service.NewUserService(userRepository) @@ -84,6 +86,7 @@ func New(cfg *config.Config) (*App, error) { cfg.IsProduction(), ) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService) + recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService) return &App{ Cfg: cfg, @@ -94,8 +97,9 @@ func New(cfg *config.Config) (*App, error) { SpaceService: spaceService, AccountService: accountService, AllocationService: allocationService, - TransactionService: transactionService, - InviteService: inviteService, + TransactionService: transactionService, + RecurringEventService: recurringEventService, + InviteService: inviteService, AuditLogService: auditLogService, TxAuditLogService: txAuditLogService, AccountActivitySvc: accountActivityService, diff --git a/internal/db/migrations/00015_create_recurring_events_table.sql b/internal/db/migrations/00015_create_recurring_events_table.sql new file mode 100644 index 0000000..a0665a5 --- /dev/null +++ b/internal/db/migrations/00015_create_recurring_events_table.sql @@ -0,0 +1,43 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE recurring_events ( + id TEXT PRIMARY KEY NOT NULL, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('bill', 'fund')), + source_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + title TEXT NOT NULL, + amount TEXT NOT NULL, + description TEXT, + + frequency TEXT NOT NULL CHECK (frequency IN ('daily', 'weekly', 'monthly', 'yearly')), + interval_count INTEGER NOT NULL DEFAULT 1 CHECK (interval_count >= 1), + day_of_week INTEGER CHECK (day_of_week IS NULL OR (day_of_week >= 0 AND day_of_week <= 6)), + day_of_month INTEGER CHECK (day_of_month IS NULL OR (day_of_month >= 1 AND day_of_month <= 31)), + month_of_year INTEGER CHECK (month_of_year IS NULL OR (month_of_year >= 1 AND month_of_year <= 12)), + fire_hour INTEGER NOT NULL CHECK (fire_hour >= 0 AND fire_hour <= 23), + fire_minute INTEGER NOT NULL CHECK (fire_minute >= 0 AND fire_minute <= 59), + timezone TEXT NOT NULL, + + next_run_at TIMESTAMP NOT NULL, + last_run_at TIMESTAMP, + paused BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_recurring_events_space_id + ON recurring_events (space_id, created_at DESC); + +CREATE INDEX idx_recurring_events_due + ON recurring_events (next_run_at) + WHERE paused = FALSE; + +CREATE INDEX idx_recurring_events_source_account + ON recurring_events (source_account_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE recurring_events; +-- +goose StatementEnd diff --git a/internal/handler/recurring_event.go b/internal/handler/recurring_event.go new file mode 100644 index 0000000..6d1d94b --- /dev/null +++ b/internal/handler/recurring_event.go @@ -0,0 +1,454 @@ +package handler + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/misc/timezone" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/service" + "git.juancwu.dev/juancwu/budgit/internal/ui" + "git.juancwu.dev/juancwu/budgit/internal/ui/forms" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" + "github.com/shopspring/decimal" +) + +type recurringEventHandler struct { + recurringService *service.RecurringEventService + accountService *service.AccountService + spaceService *service.SpaceService +} + +func NewRecurringEventHandler(rec *service.RecurringEventService, acc *service.AccountService, sp *service.SpaceService) *recurringEventHandler { + return &recurringEventHandler{recurringService: rec, accountService: acc, spaceService: sp} +} + +// ListPage shows every recurring event for a space. +func (h *recurringEventHandler) ListPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.Render(w, r, pages.NotFound()) + return + } + events, err := h.recurringService.ListBySpace(spaceID) + if err != nil { + slog.Error("failed to list recurring events", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load recurring events", http.StatusInternalServerError) + return + } + accounts, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to load accounts", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load recurring events", http.StatusInternalServerError) + return + } + accountByID := map[string]string{} + for _, a := range accounts { + accountByID[a.ID] = a.Name + } + ui.Render(w, r, pages.SpaceRecurringEventsPage(pages.SpaceRecurringEventsPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + Events: events, + AccountByID: accountByID, + })) +} + +// CreatePage shows the create form. +func (h *recurringEventHandler) CreatePage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.Render(w, r, pages.NotFound()) + return + } + accounts, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to load accounts", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load form", http.StatusInternalServerError) + return + } + + now := time.Now() + formProps := forms.RecurringEventFormProps{ + SpaceID: spaceID, + Action: routeurl.URL("action.app.spaces.space.recurring.create", "spaceID", spaceID), + CancelHref: routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID), + SubmitLabel: "Create", + Accounts: accounts, + Timezones: timezone.CommonTimezones(), + Kind: string(model.RecurringEventKindBill), + Frequency: string(model.RecurringFrequencyMonthly), + IntervalCount: "1", + FireTime: "09:00", + Timezone: "UTC", + StartDate: now.Format("2006-01-02"), + DayOfMonth: strconv.Itoa(now.Day()), + DayOfWeek: strconv.Itoa(int(now.Weekday())), + MonthOfYear: strconv.Itoa(int(now.Month())), + } + + ui.Render(w, r, pages.SpaceCreateRecurringEventPage(pages.SpaceCreateRecurringEventPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + Form: formProps, + })) +} + +// EditPage shows the edit form for an existing event. +func (h *recurringEventHandler) EditPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + eventID := r.PathValue("eventID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.Render(w, r, pages.NotFound()) + return + } + ev, err := h.recurringService.Get(eventID) + if err != nil || ev.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + accounts, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to load accounts", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load form", http.StatusInternalServerError) + return + } + + formProps := forms.RecurringEventFormProps{ + SpaceID: spaceID, + Action: routeurl.URL("action.app.spaces.space.recurring.event.edit", "spaceID", spaceID, "eventID", eventID), + CancelHref: routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID), + SubmitLabel: "Save", + Accounts: accounts, + Timezones: timezone.CommonTimezones(), + Title: ev.Title, + Kind: string(ev.Kind), + SourceAccountID: ev.SourceAccountID, + Amount: ev.Amount.StringFixedBank(2), + Frequency: string(ev.Frequency), + IntervalCount: strconv.Itoa(ev.IntervalCount), + FireTime: formatTimeOfDay(ev.FireHour, ev.FireMinute), + Timezone: ev.Timezone, + StartDate: ev.NextRunAt.In(mustLoc(ev.Timezone)).Format("2006-01-02"), + } + if ev.Description != nil { + formProps.Description = *ev.Description + } + if ev.DayOfWeek != nil { + formProps.DayOfWeek = strconv.Itoa(*ev.DayOfWeek) + } + if ev.DayOfMonth != nil { + formProps.DayOfMonth = strconv.Itoa(*ev.DayOfMonth) + } + if ev.MonthOfYear != nil { + formProps.MonthOfYear = strconv.Itoa(*ev.MonthOfYear) + } + + ui.Render(w, r, pages.SpaceEditRecurringEventPage(pages.SpaceEditRecurringEventPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + EventID: eventID, + Form: formProps, + })) +} + +// HandleCreate processes the create form submission. +func (h *recurringEventHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + if _, err := h.spaceService.GetSpace(spaceID); err != nil { + ui.RenderError(w, r, "Space not found", http.StatusNotFound) + return + } + + parsed, formProps := h.parseForm(r, spaceID) + formProps.Action = routeurl.URL("action.app.spaces.space.recurring.create", "spaceID", spaceID) + formProps.CancelHref = routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID) + formProps.SubmitLabel = "Create" + + if formProps.HasError() { + ui.Render(w, r, forms.RecurringEventForm(formProps)) + return + } + + if _, err := h.recurringService.Create(parsed); err != nil { + slog.Error("failed to create recurring event", "error", err, "space_id", spaceID) + formProps.GeneralErr = friendlyRecurringError(err) + ui.Render(w, r, forms.RecurringEventForm(formProps)) + return + } + + w.Header().Set("HX-Redirect", routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID)) + w.WriteHeader(http.StatusOK) +} + +// HandleEdit processes the edit form submission. +func (h *recurringEventHandler) HandleEdit(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + eventID := r.PathValue("eventID") + + existing, err := h.recurringService.Get(eventID) + if err != nil || existing.SpaceID != spaceID { + ui.RenderError(w, r, "Recurring event not found", http.StatusNotFound) + return + } + + parsed, formProps := h.parseForm(r, spaceID) + formProps.Action = routeurl.URL("action.app.spaces.space.recurring.event.edit", "spaceID", spaceID, "eventID", eventID) + formProps.CancelHref = routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID) + formProps.SubmitLabel = "Save" + + if formProps.HasError() { + ui.Render(w, r, forms.RecurringEventForm(formProps)) + return + } + + if _, err := h.recurringService.Update(service.UpdateRecurringEventInput{ + ID: eventID, + Kind: parsed.Kind, + SourceAccountID: parsed.SourceAccountID, + Title: parsed.Title, + Amount: parsed.Amount, + Description: parsed.Description, + Frequency: parsed.Frequency, + IntervalCount: parsed.IntervalCount, + DayOfWeek: parsed.DayOfWeek, + DayOfMonth: parsed.DayOfMonth, + MonthOfYear: parsed.MonthOfYear, + FireHour: parsed.FireHour, + FireMinute: parsed.FireMinute, + Timezone: parsed.Timezone, + StartDate: parsed.StartDate, + }); err != nil { + slog.Error("failed to update recurring event", "error", err, "event_id", eventID) + formProps.GeneralErr = friendlyRecurringError(err) + ui.Render(w, r, forms.RecurringEventForm(formProps)) + return + } + + w.Header().Set("HX-Redirect", routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID)) + w.WriteHeader(http.StatusOK) +} + +func (h *recurringEventHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + eventID := r.PathValue("eventID") + existing, err := h.recurringService.Get(eventID) + if err != nil || existing.SpaceID != spaceID { + ui.RenderError(w, r, "Recurring event not found", http.StatusNotFound) + return + } + if err := h.recurringService.Delete(eventID); err != nil { + slog.Error("failed to delete recurring event", "error", err, "event_id", eventID) + ui.RenderError(w, r, "Failed to delete", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID)) + w.WriteHeader(http.StatusOK) +} + +func (h *recurringEventHandler) HandlePause(w http.ResponseWriter, r *http.Request) { + h.setPaused(w, r, true) +} + +func (h *recurringEventHandler) HandleResume(w http.ResponseWriter, r *http.Request) { + h.setPaused(w, r, false) +} + +func (h *recurringEventHandler) setPaused(w http.ResponseWriter, r *http.Request, paused bool) { + spaceID := r.PathValue("spaceID") + eventID := r.PathValue("eventID") + existing, err := h.recurringService.Get(eventID) + if err != nil || existing.SpaceID != spaceID { + ui.RenderError(w, r, "Recurring event not found", http.StatusNotFound) + return + } + if err := h.recurringService.SetPaused(eventID, paused); err != nil { + slog.Error("failed to toggle pause", "error", err, "event_id", eventID) + ui.RenderError(w, r, "Failed to update", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID)) + w.WriteHeader(http.StatusOK) +} + +// parseForm reads the recurring-event form, returns a populated CreateRecurringEventInput +// alongside form props echoed back to the user with field-level errors. +func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (service.CreateRecurringEventInput, forms.RecurringEventFormProps) { + accounts, _ := h.accountService.GetAccountsForSpace(spaceID) + + title := strings.TrimSpace(r.FormValue("title")) + kind := strings.TrimSpace(r.FormValue("kind")) + sourceID := strings.TrimSpace(r.FormValue("source_account")) + amountStr := strings.TrimSpace(r.FormValue("amount")) + descriptionStr := strings.TrimSpace(r.FormValue("description")) + frequency := strings.TrimSpace(r.FormValue("frequency")) + intervalStr := strings.TrimSpace(r.FormValue("interval_count")) + dowStr := strings.TrimSpace(r.FormValue("day_of_week")) + domStr := strings.TrimSpace(r.FormValue("day_of_month")) + moyStr := strings.TrimSpace(r.FormValue("month_of_year")) + fireTime := strings.TrimSpace(r.FormValue("fire_time")) + tz := strings.TrimSpace(r.FormValue("timezone")) + startDateStr := strings.TrimSpace(r.FormValue("start_date")) + + props := forms.RecurringEventFormProps{ + SpaceID: spaceID, + Accounts: accounts, + Timezones: timezone.CommonTimezones(), + Title: title, + Kind: kind, + SourceAccountID: sourceID, + Amount: amountStr, + Description: descriptionStr, + Frequency: frequency, + IntervalCount: intervalStr, + DayOfWeek: dowStr, + DayOfMonth: domStr, + MonthOfYear: moyStr, + FireTime: fireTime, + Timezone: tz, + StartDate: startDateStr, + } + + input := service.CreateRecurringEventInput{ + SpaceID: spaceID, + Kind: model.RecurringEventKind(kind), + SourceAccountID: sourceID, + Title: title, + Description: descriptionStr, + Frequency: model.RecurringFrequency(frequency), + Timezone: tz, + } + + if title == "" { + props.TitleErr = "Title is required." + } + switch model.RecurringEventKind(kind) { + case model.RecurringEventKindBill, model.RecurringEventKindFund: + // ok + default: + props.KindErr = "Choose a kind." + } + if sourceID == "" { + props.SourceErr = "Source account is required." + } + if amount, err := decimal.NewFromString(amountStr); err != nil { + props.AmountErr = "Enter a valid amount (e.g. 12.34)." + } else if !amount.IsPositive() { + props.AmountErr = "Amount must be greater than zero." + } else if amount.Exponent() < -2 { + props.AmountErr = "Amount can have at most 2 decimal places." + } else { + input.Amount = amount + } + + if interval, err := strconv.Atoi(intervalStr); err != nil || interval < 1 { + props.IntervalErr = "Interval must be a positive whole number." + } else { + input.IntervalCount = interval + } + + switch model.RecurringFrequency(frequency) { + case model.RecurringFrequencyDaily: + // no anchor + case model.RecurringFrequencyWeekly: + if v, err := strconv.Atoi(dowStr); err != nil || v < 0 || v > 6 { + props.DayOfWeekErr = "Choose a day of the week." + } else { + input.DayOfWeek = &v + } + case model.RecurringFrequencyMonthly: + if v, err := strconv.Atoi(domStr); err != nil || v < 1 || v > 31 { + props.DayOfMonthErr = "Day of month must be between 1 and 31." + } else { + input.DayOfMonth = &v + } + case model.RecurringFrequencyYearly: + if v, err := strconv.Atoi(domStr); err != nil || v < 1 || v > 31 { + props.DayOfMonthErr = "Day of month must be between 1 and 31." + } else { + input.DayOfMonth = &v + } + if v, err := strconv.Atoi(moyStr); err != nil || v < 1 || v > 12 { + props.MonthOfYearErr = "Month must be between 1 and 12." + } else { + input.MonthOfYear = &v + } + default: + props.FrequencyErr = "Choose a frequency." + } + + if hh, mm, ok := parseTimeOfDay(fireTime); !ok { + props.FireTimeErr = "Enter a valid time (HH:MM)." + } else { + input.FireHour = hh + input.FireMinute = mm + } + + if tz == "" { + props.TimezoneErr = "Timezone is required." + } else if _, err := time.LoadLocation(tz); err != nil { + props.TimezoneErr = "Unknown timezone." + } + + if startDateStr == "" { + props.StartDateErr = "Start date is required." + } else if d, err := time.Parse("2006-01-02", startDateStr); err != nil { + props.StartDateErr = "Enter a valid date." + } else { + input.StartDate = d + } + + return input, props +} + +func parseTimeOfDay(s string) (int, int, bool) { + parts := strings.Split(s, ":") + if len(parts) != 2 { + return 0, 0, false + } + h, err1 := strconv.Atoi(parts[0]) + m, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} + +func formatTimeOfDay(h, m int) string { + return strconv.Itoa(h) + ":" + leadingZero(m) +} + +func leadingZero(n int) string { + if n < 10 { + return "0" + strconv.Itoa(n) + } + return strconv.Itoa(n) +} + +func mustLoc(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + return time.UTC + } + return loc +} + +func friendlyRecurringError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, repository.ErrRecurringEventNotFound) { + return "Recurring event not found." + } + return err.Error() +} diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index bb3a961..eb2f0a6 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -54,6 +54,48 @@ type Allocation struct { UpdatedAt time.Time `db:"updated_at"` } +type RecurringEventKind string + +const ( + RecurringEventKindBill RecurringEventKind = "bill" + RecurringEventKindFund RecurringEventKind = "fund" +) + +type RecurringFrequency string + +const ( + RecurringFrequencyDaily RecurringFrequency = "daily" + RecurringFrequencyWeekly RecurringFrequency = "weekly" + RecurringFrequencyMonthly RecurringFrequency = "monthly" + RecurringFrequencyYearly RecurringFrequency = "yearly" +) + +type RecurringEvent struct { + ID string `db:"id"` + SpaceID string `db:"space_id"` + Kind RecurringEventKind `db:"kind"` + SourceAccountID string `db:"source_account_id"` + Title string `db:"title"` + Amount decimal.Decimal `db:"amount"` + Description *string `db:"description"` + + Frequency RecurringFrequency `db:"frequency"` + IntervalCount int `db:"interval_count"` + DayOfWeek *int `db:"day_of_week"` + DayOfMonth *int `db:"day_of_month"` + MonthOfYear *int `db:"month_of_year"` + FireHour int `db:"fire_hour"` + FireMinute int `db:"fire_minute"` + Timezone string `db:"timezone"` + + NextRunAt time.Time `db:"next_run_at"` + LastRunAt *time.Time `db:"last_run_at"` + Paused bool `db:"paused"` + + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + type Category struct { ID string `db:"id"` Name string `db:"name"` diff --git a/internal/repository/recurring_event.go b/internal/repository/recurring_event.go new file mode 100644 index 0000000..a4eeef9 --- /dev/null +++ b/internal/repository/recurring_event.go @@ -0,0 +1,163 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrRecurringEventNotFound = errors.New("recurring event not found") + +type RecurringEventRepository interface { + Create(e *model.RecurringEvent) error + ByID(id string) (*model.RecurringEvent, error) + BySpaceID(spaceID string) ([]*model.RecurringEvent, error) + ByAccountID(accountID string) ([]*model.RecurringEvent, error) + DueBefore(now time.Time) ([]*model.RecurringEvent, error) + Update(e *model.RecurringEvent) error + UpdateCursor(id string, nextRunAt time.Time, lastRunAt time.Time) error + SetPaused(id string, paused bool) error + Delete(id string) error +} + +type recurringEventRepository struct { + db *sqlx.DB +} + +func NewRecurringEventRepository(db *sqlx.DB) RecurringEventRepository { + return &recurringEventRepository{db: db} +} + +func (r *recurringEventRepository) Create(e *model.RecurringEvent) error { + query := `INSERT INTO recurring_events ( + id, space_id, kind, source_account_id, title, amount, description, + frequency, interval_count, day_of_week, day_of_month, month_of_year, + fire_hour, fire_minute, timezone, + next_run_at, last_run_at, paused, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, + $16, $17, $18, $19, $20 + );` + _, err := r.db.Exec(query, + e.ID, e.SpaceID, e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description, + e.Frequency, e.IntervalCount, e.DayOfWeek, e.DayOfMonth, e.MonthOfYear, + e.FireHour, e.FireMinute, e.Timezone, + e.NextRunAt, e.LastRunAt, e.Paused, e.CreatedAt, e.UpdatedAt, + ) + return err +} + +func (r *recurringEventRepository) ByID(id string) (*model.RecurringEvent, error) { + out := &model.RecurringEvent{} + err := r.db.Get(out, `SELECT * FROM recurring_events WHERE id = $1;`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrRecurringEventNotFound + } + return out, err +} + +func (r *recurringEventRepository) BySpaceID(spaceID string) ([]*model.RecurringEvent, error) { + var out []*model.RecurringEvent + err := r.db.Select(&out, `SELECT * FROM recurring_events WHERE space_id = $1 ORDER BY created_at DESC;`, spaceID) + return out, err +} + +func (r *recurringEventRepository) ByAccountID(accountID string) ([]*model.RecurringEvent, error) { + var out []*model.RecurringEvent + query := `SELECT * FROM recurring_events + WHERE source_account_id = $1 + ORDER BY created_at DESC;` + err := r.db.Select(&out, query, accountID) + return out, err +} + +func (r *recurringEventRepository) DueBefore(now time.Time) ([]*model.RecurringEvent, error) { + var out []*model.RecurringEvent + query := `SELECT * FROM recurring_events + WHERE paused = FALSE AND next_run_at <= $1 + ORDER BY next_run_at ASC;` + err := r.db.Select(&out, query, now) + return out, err +} + +func (r *recurringEventRepository) Update(e *model.RecurringEvent) error { + query := `UPDATE recurring_events SET + kind = $1, source_account_id = $2, title = $3, amount = $4, description = $5, + frequency = $6, interval_count = $7, day_of_week = $8, day_of_month = $9, month_of_year = $10, + fire_hour = $11, fire_minute = $12, timezone = $13, + next_run_at = $14, paused = $15, updated_at = CURRENT_TIMESTAMP + WHERE id = $16;` + res, err := r.db.Exec(query, + e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description, + e.Frequency, e.IntervalCount, e.DayOfWeek, e.DayOfMonth, e.MonthOfYear, + e.FireHour, e.FireMinute, e.Timezone, + e.NextRunAt, e.Paused, e.ID, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrRecurringEventNotFound + } + return nil +} + +func (r *recurringEventRepository) UpdateCursor(id string, nextRunAt time.Time, lastRunAt time.Time) error { + query := `UPDATE recurring_events + SET next_run_at = $1, last_run_at = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3;` + res, err := r.db.Exec(query, nextRunAt, lastRunAt, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrRecurringEventNotFound + } + return nil +} + +func (r *recurringEventRepository) SetPaused(id string, paused bool) error { + res, err := r.db.Exec( + `UPDATE recurring_events SET paused = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, + paused, id, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrRecurringEventNotFound + } + return nil +} + +func (r *recurringEventRepository) Delete(id string) error { + res, err := r.db.Exec(`DELETE FROM recurring_events WHERE id = $1;`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrRecurringEventNotFound + } + return nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 1210b4c..bd3415e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -21,6 +21,7 @@ func SetupRoutes(a *app.App) http.Handler { settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc) allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService) + recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService) redirectH := handler.NewRedirectHandler() r := router.New() @@ -107,6 +108,15 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("/accounts/create", spaceH.SpaceCreateAccountPage).Name("page.app.spaces.space.accounts.create") g.Post("/accounts/create", spaceH.HandleCreateAccount).Name("action.app.spaces.space.accounts.create") + g.Get("/recurring", recurringH.ListPage).Name("page.app.spaces.space.recurring") + g.Get("/recurring/create", recurringH.CreatePage).Name("page.app.spaces.space.recurring.create") + g.Post("/recurring/create", recurringH.HandleCreate).Name("action.app.spaces.space.recurring.create") + g.Get("/recurring/{eventID}/edit", recurringH.EditPage).Name("page.app.spaces.space.recurring.event.edit") + g.Post("/recurring/{eventID}/edit", recurringH.HandleEdit).Name("action.app.spaces.space.recurring.event.edit") + g.Post("/recurring/{eventID}/delete", recurringH.HandleDelete).Name("action.app.spaces.space.recurring.event.delete") + g.Post("/recurring/{eventID}/pause", recurringH.HandlePause).Name("action.app.spaces.space.recurring.event.pause") + g.Post("/recurring/{eventID}/resume", recurringH.HandleResume).Name("action.app.spaces.space.recurring.event.resume") + g.SubGroup("/accounts/{accountID}", func(g *router.Group) { g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview") g.Get("/activity", spaceH.SpaceAccountActivityPage).Name("page.app.spaces.space.accounts.account.activity") diff --git a/internal/service/recurring_event.go b/internal/service/recurring_event.go new file mode 100644 index 0000000..06d49ef --- /dev/null +++ b/internal/service/recurring_event.go @@ -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) +} diff --git a/internal/service/recurring_event_test.go b/internal/service/recurring_event_test.go new file mode 100644 index 0000000..3ae5e77 --- /dev/null +++ b/internal/service/recurring_event_test.go @@ -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) + } + } +} diff --git a/internal/ui/forms/helpers.go b/internal/ui/forms/helpers.go new file mode 100644 index 0000000..28e9a6d --- /dev/null +++ b/internal/ui/forms/helpers.go @@ -0,0 +1,5 @@ +package forms + +import "strconv" + +func intToStr(n int) string { return strconv.Itoa(n) } diff --git a/internal/ui/forms/recurring_event.templ b/internal/ui/forms/recurring_event.templ new file mode 100644 index 0000000..35191d9 --- /dev/null +++ b/internal/ui/forms/recurring_event.templ @@ -0,0 +1,336 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/misc/timezone" +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" + +type RecurringEventFormProps struct { + SpaceID string + Action string + CancelHref string + SubmitLabel string + + Accounts []*model.Account + Timezones []timezone.TimezoneOption + + Title string + Kind string + SourceAccountID string + Amount string + Description string + Frequency string + IntervalCount string + DayOfWeek string + DayOfMonth string + MonthOfYear string + FireTime string + Timezone string + StartDate string + + TitleErr string + KindErr string + SourceErr string + AmountErr string + FrequencyErr string + IntervalErr string + DayOfWeekErr string + DayOfMonthErr string + MonthOfYearErr string + FireTimeErr string + TimezoneErr string + StartDateErr string + GeneralErr string +} + +func (p RecurringEventFormProps) HasError() bool { + return p.TitleErr != "" || p.KindErr != "" || p.SourceErr != "" || + p.AmountErr != "" || p.FrequencyErr != "" || p.IntervalErr != "" || + p.DayOfWeekErr != "" || p.DayOfMonthErr != "" || p.MonthOfYearErr != "" || + p.FireTimeErr != "" || p.TimezoneErr != "" || p.StartDateErr != "" +} + +var weekdayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} +var monthNames = []string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} + +templ RecurringEventForm(props RecurringEventFormProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "title"}) { + Title + } + @input.Input(input.Props{ + ID: "title", + Name: "title", + Type: input.TypeText, + Placeholder: "e.g. Rent", + Class: "rounded-sm", + Value: props.Title, + HasError: props.TitleErr != "", + Required: true, + }) + if props.TitleErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.TitleErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "kind"}) { + Kind + } + + if props.KindErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.KindErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "source_account"}) { + Account + } + + if props.SourceErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.SourceErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "amount"}) { + Amount + } + @input.Input(input.Props{ + ID: "amount", + Name: "amount", + Type: input.TypeNumber, + Placeholder: "0.00", + Class: "rounded-sm", + Value: props.Amount, + HasError: props.AmountErr != "", + Required: true, + Attributes: templ.Attributes{ + "step": "0.01", + "min": "0", + "inputmode": "decimal", + }, + }) + if props.AmountErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.AmountErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "frequency"}) { + Frequency + } + + if props.FrequencyErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.FrequencyErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "interval_count"}) { + Repeat every + } + @input.Input(input.Props{ + ID: "interval_count", + Name: "interval_count", + Type: input.TypeNumber, + Class: "rounded-sm", + Value: props.IntervalCount, + HasError: props.IntervalErr != "", + Required: true, + Attributes: templ.Attributes{ + "min": "1", + "step": "1", + }, + }) + @form.Description() { + e.g. "every 2" + Weekly = every other week. + } + if props.IntervalErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.IntervalErr } + } + } + } +
+
+ @form.Item() { + @form.Label(form.LabelProps{For: "day_of_week"}) { + Day of week + } + + @form.Description() { + Used for weekly events. + } + if props.DayOfWeekErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.DayOfWeekErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "day_of_month"}) { + Day of month + } + @input.Input(input.Props{ + ID: "day_of_month", + Name: "day_of_month", + Type: input.TypeNumber, + Class: "rounded-sm", + Value: props.DayOfMonth, + HasError: props.DayOfMonthErr != "", + Attributes: templ.Attributes{ + "min": "1", + "max": "31", + }, + }) + @form.Description() { + Used for monthly/yearly. Clamps to last day of short months. + } + if props.DayOfMonthErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.DayOfMonthErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "month_of_year"}) { + Month + } + + @form.Description() { + Used for yearly events. + } + if props.MonthOfYearErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.MonthOfYearErr } + } + } + } +
+
+ @form.Item() { + @form.Label(form.LabelProps{For: "fire_time"}) { + Time of day + } + @input.Input(input.Props{ + ID: "fire_time", + Name: "fire_time", + Type: input.TypeTime, + Class: "rounded-sm", + Value: props.FireTime, + HasError: props.FireTimeErr != "", + Required: true, + }) + if props.FireTimeErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.FireTimeErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "timezone"}) { + Timezone + } + + if props.TimezoneErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.TimezoneErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "start_date"}) { + Start date + } + @input.Input(input.Props{ + ID: "start_date", + Name: "start_date", + Type: input.TypeDate, + Class: "rounded-sm", + Value: props.StartDate, + HasError: props.StartDateErr != "", + Required: true, + }) + @form.Description() { + First firing on or after this local date. + } + if props.StartDateErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.StartDateErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "description"}) { + Description + } + @textarea.Textarea(textarea.Props{ + ID: "description", + Name: "description", + Placeholder: "Optional", + Rows: 3, + Value: props.Description, + }) + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: props.CancelHref, + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + { props.SubmitLabel } + } + } + } +
+} diff --git a/internal/ui/pages/recurring_event_helpers.go b/internal/ui/pages/recurring_event_helpers.go new file mode 100644 index 0000000..b5266cc --- /dev/null +++ b/internal/ui/pages/recurring_event_helpers.go @@ -0,0 +1,57 @@ +package pages + +import ( + "fmt" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" +) + +func accountLabel(ev *model.RecurringEvent, accountByID map[string]string) string { + src := accountByID[ev.SourceAccountID] + if src == "" { + src = ev.SourceAccountID + } + return src +} + +var weekdayLabels = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + +func recurrenceSummary(ev *model.RecurringEvent) string { + timePart := fmt.Sprintf(" at %02d:%02d", ev.FireHour, ev.FireMinute) + switch ev.Frequency { + case model.RecurringFrequencyDaily: + if ev.IntervalCount == 1 { + return "Daily" + timePart + } + return fmt.Sprintf("Every %d days%s", ev.IntervalCount, timePart) + case model.RecurringFrequencyWeekly: + dow := "" + if ev.DayOfWeek != nil && *ev.DayOfWeek >= 0 && *ev.DayOfWeek < len(weekdayLabels) { + dow = " on " + weekdayLabels[*ev.DayOfWeek] + } + if ev.IntervalCount == 1 { + return "Weekly" + dow + timePart + } + return fmt.Sprintf("Every %d weeks%s%s", ev.IntervalCount, dow, timePart) + case model.RecurringFrequencyMonthly: + dom := "" + if ev.DayOfMonth != nil { + dom = fmt.Sprintf(" on day %d", *ev.DayOfMonth) + } + if ev.IntervalCount == 1 { + return "Monthly" + dom + timePart + } + return fmt.Sprintf("Every %d months%s%s", ev.IntervalCount, dom, timePart) + case model.RecurringFrequencyYearly: + date := "" + if ev.MonthOfYear != nil && ev.DayOfMonth != nil { + date = fmt.Sprintf(" on %s %d", time.Month(*ev.MonthOfYear).String(), *ev.DayOfMonth) + } + if ev.IntervalCount == 1 { + return "Yearly" + date + timePart + } + return fmt.Sprintf("Every %d years%s%s", ev.IntervalCount, date, timePart) + } + return string(ev.Frequency) +} diff --git a/internal/ui/pages/space_create_recurring_event.templ b/internal/ui/pages/space_create_recurring_event.templ new file mode 100644 index 0000000..0c7bc65 --- /dev/null +++ b/internal/ui/pages/space_create_recurring_event.templ @@ -0,0 +1,29 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceCreateRecurringEventPageProps struct { + SpaceID string + SpaceName string + Form forms.RecurringEventFormProps +} + +templ SpaceCreateRecurringEventPage(props SpaceCreateRecurringEventPageProps) { + @layouts.AppWithBreadcrumb( + "New Recurring", + spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "New Recurring"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + ) { +
+
+

New Recurring Event

+

+ Schedule a bill, fund, or transfer to repeat automatically. +

+
+ @forms.RecurringEventForm(props.Form) +
+ } +} diff --git a/internal/ui/pages/space_edit_recurring_event.templ b/internal/ui/pages/space_edit_recurring_event.templ new file mode 100644 index 0000000..0b2b6f7 --- /dev/null +++ b/internal/ui/pages/space_edit_recurring_event.templ @@ -0,0 +1,30 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceEditRecurringEventPageProps struct { + SpaceID string + SpaceName string + EventID string + Form forms.RecurringEventFormProps +} + +templ SpaceEditRecurringEventPage(props SpaceEditRecurringEventPageProps) { + @layouts.AppWithBreadcrumb( + "Edit Recurring", + spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Edit Recurring"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + ) { +
+
+

Edit Recurring Event

+

+ Changes apply going forward. Past transactions are not modified. +

+
+ @forms.RecurringEventForm(props.Form) +
+ } +} diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ index a2e7334..65986a0 100644 --- a/internal/ui/pages/space_overview.templ +++ b/internal/ui/pages/space_overview.templ @@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) { Members } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID), + IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID), + Tooltip: "Recurring", + }) { + @icon.CalendarSync() + Recurring + } + } @sidebar.MenuItem() { @sidebar.MenuButton(sidebar.MenuButtonProps{ Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID), diff --git a/internal/ui/pages/space_recurring_events.templ b/internal/ui/pages/space_recurring_events.templ new file mode 100644 index 0000000..eb6ee56 --- /dev/null +++ b/internal/ui/pages/space_recurring_events.templ @@ -0,0 +1,132 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceRecurringEventsPageProps struct { + SpaceID string + SpaceName string + Events []*model.RecurringEvent + AccountByID map[string]string +} + +templ SpaceRecurringEventsPage(props SpaceRecurringEventsPageProps) { + @layouts.AppWithBreadcrumb( + "Recurring", + spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Recurring"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + ) { +
+
+
+

Recurring

+

+ Bills, funds, and transfers that fire automatically on a schedule. +

+
+ @button.Button(button.Props{ + Href: routeurl.URL("page.app.spaces.space.recurring.create", "spaceID", props.SpaceID), + Class: "flex gap-2 items-center", + }) { + @icon.Plus() + New Recurring + } +
+ if len(props.Events) == 0 { + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-8 text-center text-muted-foreground"}) { + No recurring events yet. + } + } + } else { +
+ for _, ev := range props.Events { + @recurringEventRow(props.SpaceID, ev, props.AccountByID) + } +
+ } +
+ } +} + +templ recurringEventRow(spaceID string, ev *model.RecurringEvent, accountByID map[string]string) { + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3"}) { +
+
+ { ev.Title } + @kindBadge(ev.Kind) + if ev.Paused { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + Paused + } + } +
+
+ { accountLabel(ev, accountByID) } · ${ ev.Amount.StringFixedBank(2) } · { recurrenceSummary(ev) } +
+
+ Next: { ev.NextRunAt.Format("2006-01-02 15:04 MST") } ({ ev.Timezone }) +
+
+
+ if ev.Paused { +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantOutline, + Size: button.SizeSm, + }) { + Resume + } +
+ } else { +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantOutline, + Size: button.SizeSm, + }) { + Pause + } +
+ } + @button.Button(button.Props{ + Variant: button.VariantOutline, + Size: button.SizeSm, + Href: routeurl.URL("page.app.spaces.space.recurring.event.edit", "spaceID", spaceID, "eventID", ev.ID), + }) { + Edit + } +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + Size: button.SizeSm, + }) { + @icon.Trash2() + } +
+
+ } + } +} + +templ kindBadge(kind model.RecurringEventKind) { + switch kind { + case model.RecurringEventKindBill: + @badge.Badge(badge.Props{Variant: badge.VariantDestructive}) { + Bill + } + case model.RecurringEventKindFund: + @badge.Badge(badge.Props{Variant: badge.VariantDefault}) { + Fund + } + } +}