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

@ -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()
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -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()
}

View file

@ -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"`

View file

@ -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
}

View file

@ -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")

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

View file

@ -0,0 +1,5 @@
package forms
import "strconv"
func intToStr(n int) string { return strconv.Itoa(n) }

View file

@ -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) {
<form id="recurring-event-form" hx-post={ props.Action } hx-target="#recurring-event-form" hx-swap="outerHTML">
@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
}
<select id="kind" name="kind" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value={ string(model.RecurringEventKindBill) } selected?={ props.Kind == string(model.RecurringEventKindBill) }>Bill (withdrawal)</option>
<option value={ string(model.RecurringEventKindFund) } selected?={ props.Kind == string(model.RecurringEventKindFund) }>Fund (deposit)</option>
</select>
if props.KindErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.KindErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "source_account"}) {
Account
}
<select id="source_account" name="source_account" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value="" selected?={ props.SourceAccountID == "" }>Select an account…</option>
for _, a := range props.Accounts {
<option value={ a.ID } selected?={ props.SourceAccountID == a.ID }>{ a.Name }</option>
}
</select>
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 }
}
}
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "frequency"}) {
Frequency
}
<select id="frequency" name="frequency" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value={ string(model.RecurringFrequencyDaily) } selected?={ props.Frequency == string(model.RecurringFrequencyDaily) }>Daily</option>
<option value={ string(model.RecurringFrequencyWeekly) } selected?={ props.Frequency == string(model.RecurringFrequencyWeekly) }>Weekly</option>
<option value={ string(model.RecurringFrequencyMonthly) } selected?={ props.Frequency == string(model.RecurringFrequencyMonthly) }>Monthly</option>
<option value={ string(model.RecurringFrequencyYearly) } selected?={ props.Frequency == string(model.RecurringFrequencyYearly) }>Yearly</option>
</select>
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 }
}
}
}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "day_of_week"}) {
Day of week
}
<select id="day_of_week" name="day_of_week" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<option value="">—</option>
for i, name := range weekdayNames {
<option value={ intToStr(i) } selected?={ props.DayOfWeek == intToStr(i) }>{ name }</option>
}
</select>
@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
}
<select id="month_of_year" name="month_of_year" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<option value="">—</option>
for i, name := range monthNames {
<option value={ intToStr(i + 1) } selected?={ props.MonthOfYear == intToStr(i + 1) }>{ name }</option>
}
</select>
@form.Description() {
Used for yearly events.
}
if props.MonthOfYearErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.MonthOfYearErr }
}
}
}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@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
}
<select id="timezone" name="timezone" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
for _, tz := range props.Timezones {
<option value={ tz.Value } selected?={ props.Timezone == tz.Value }>{ tz.Label }</option>
}
</select>
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 }
}
}
}
</div>
@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 }
}
}
}
</form>
}

View file

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

View file

@ -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),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">New Recurring Event</h1>
<p class="text-muted-foreground mt-2">
Schedule a bill, fund, or transfer to repeat automatically.
</p>
</div>
@forms.RecurringEventForm(props.Form)
</div>
}
}

View file

@ -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),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">Edit Recurring Event</h1>
<p class="text-muted-foreground mt-2">
Changes apply going forward. Past transactions are not modified.
</p>
</div>
@forms.RecurringEventForm(props.Form)
</div>
}
}

View file

@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
<span>Members</span>
}
}
@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()
<span>Recurring</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),

View file

@ -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),
) {
<div class="container max-w-5xl px-6 py-8 mx-auto space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">Recurring</h1>
<p class="text-muted-foreground mt-2">
Bills, funds, and transfers that fire automatically on a schedule.
</p>
</div>
@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
}
</div>
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 {
<div class="space-y-3">
for _, ev := range props.Events {
@recurringEventRow(props.SpaceID, ev, props.AccountByID)
}
</div>
}
</div>
}
}
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"}) {
<div class="space-y-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold truncate">{ ev.Title }</span>
@kindBadge(ev.Kind)
if ev.Paused {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
<div class="text-sm text-muted-foreground">
{ accountLabel(ev, accountByID) } · ${ ev.Amount.StringFixedBank(2) } · { recurrenceSummary(ev) }
</div>
<div class="text-xs text-muted-foreground">
Next: { ev.NextRunAt.Format("2006-01-02 15:04 MST") } ({ ev.Timezone })
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
if ev.Paused {
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.resume", "spaceID", spaceID, "eventID", ev.ID) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantOutline,
Size: button.SizeSm,
}) {
Resume
}
</form>
} else {
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.pause", "spaceID", spaceID, "eventID", ev.ID) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantOutline,
Size: button.SizeSm,
}) {
Pause
}
</form>
}
@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
}
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.delete", "spaceID", spaceID, "eventID", ev.ID) } hx-confirm="Delete this recurring event? This does not delete previously generated transactions.">
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Size: button.SizeSm,
}) {
@icon.Trash2()
}
</form>
</div>
}
}
}
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
}
}
}