feat: recurring transactions
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s
This commit is contained in:
parent
f0a309ea20
commit
448b6f6262
16 changed files with 1956 additions and 4 deletions
|
|
@ -55,11 +55,16 @@ func main() {
|
||||||
Handler: finalHandler,
|
Handler: finalHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workerCtx, stopWorker := context.WithCancel(context.Background())
|
||||||
|
defer stopWorker()
|
||||||
|
go runRecurringWorker(workerCtx, a)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
<-sigCh
|
<-sigCh
|
||||||
slog.Info("shutting down gracefully")
|
slog.Info("shutting down gracefully")
|
||||||
|
stopWorker()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
srv.Shutdown(ctx)
|
srv.Shutdown(ctx)
|
||||||
|
|
@ -72,3 +77,25 @@ func main() {
|
||||||
panic(err)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type App struct {
|
||||||
AccountService *service.AccountService
|
AccountService *service.AccountService
|
||||||
AllocationService *service.AllocationService
|
AllocationService *service.AllocationService
|
||||||
TransactionService *service.TransactionService
|
TransactionService *service.TransactionService
|
||||||
|
RecurringEventService *service.RecurringEventService
|
||||||
InviteService *service.InviteService
|
InviteService *service.InviteService
|
||||||
AuditLogService *service.SpaceAuditLogService
|
AuditLogService *service.SpaceAuditLogService
|
||||||
TxAuditLogService *service.TransactionAuditLogService
|
TxAuditLogService *service.TransactionAuditLogService
|
||||||
|
|
@ -50,6 +51,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
invitationRepository := repository.NewInvitationRepository(database)
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
auditLogRepository := repository.NewSpaceAuditLogRepository(database)
|
auditLogRepository := repository.NewSpaceAuditLogRepository(database)
|
||||||
txAuditLogRepository := repository.NewTransactionAuditLogRepository(database)
|
txAuditLogRepository := repository.NewTransactionAuditLogRepository(database)
|
||||||
|
recurringEventRepository := repository.NewRecurringEventRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
|
@ -84,6 +86,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
cfg.IsProduction(),
|
cfg.IsProduction(),
|
||||||
)
|
)
|
||||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
||||||
|
recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -95,6 +98,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
AccountService: accountService,
|
AccountService: accountService,
|
||||||
AllocationService: allocationService,
|
AllocationService: allocationService,
|
||||||
TransactionService: transactionService,
|
TransactionService: transactionService,
|
||||||
|
RecurringEventService: recurringEventService,
|
||||||
InviteService: inviteService,
|
InviteService: inviteService,
|
||||||
AuditLogService: auditLogService,
|
AuditLogService: auditLogService,
|
||||||
TxAuditLogService: txAuditLogService,
|
TxAuditLogService: txAuditLogService,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
454
internal/handler/recurring_event.go
Normal file
454
internal/handler/recurring_event.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,48 @@ type Allocation struct {
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
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 {
|
type Category struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
|
|
|
||||||
163
internal/repository/recurring_event.go
Normal file
163
internal/repository/recurring_event.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
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)
|
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)
|
allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService)
|
||||||
|
recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService)
|
||||||
redirectH := handler.NewRedirectHandler()
|
redirectH := handler.NewRedirectHandler()
|
||||||
|
|
||||||
r := router.New()
|
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.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.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.SubGroup("/accounts/{accountID}", func(g *router.Group) {
|
||||||
g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview")
|
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")
|
g.Get("/activity", spaceH.SpaceAccountActivityPage).Name("page.app.spaces.space.accounts.account.activity")
|
||||||
|
|
|
||||||
427
internal/service/recurring_event.go
Normal file
427
internal/service/recurring_event.go
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecurringEventService manages recurring bills, funds, and transfers and
|
||||||
|
// materializes due events into actual transactions via TransactionService.
|
||||||
|
type RecurringEventService struct {
|
||||||
|
repo repository.RecurringEventRepository
|
||||||
|
txService *TransactionService
|
||||||
|
accountService *AccountService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecurringEventService(
|
||||||
|
repo repository.RecurringEventRepository,
|
||||||
|
txService *TransactionService,
|
||||||
|
accountService *AccountService,
|
||||||
|
) *RecurringEventService {
|
||||||
|
return &RecurringEventService{
|
||||||
|
repo: repo,
|
||||||
|
txService: txService,
|
||||||
|
accountService: accountService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRecurringEventInput struct {
|
||||||
|
SpaceID string
|
||||||
|
Kind model.RecurringEventKind
|
||||||
|
SourceAccountID string
|
||||||
|
Title string
|
||||||
|
Amount decimal.Decimal
|
||||||
|
Description string
|
||||||
|
|
||||||
|
Frequency model.RecurringFrequency
|
||||||
|
IntervalCount int
|
||||||
|
DayOfWeek *int
|
||||||
|
DayOfMonth *int
|
||||||
|
MonthOfYear *int
|
||||||
|
FireHour int
|
||||||
|
FireMinute int
|
||||||
|
Timezone string
|
||||||
|
|
||||||
|
// StartDate is the local calendar date (Y-M-D) of the first intended firing
|
||||||
|
// in the event's timezone. The first NextRunAt is computed from StartDate,
|
||||||
|
// FireHour, FireMinute, and Timezone — clamped to the recurrence anchors.
|
||||||
|
StartDate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.RecurringEvent, error) {
|
||||||
|
if err := validateRule(input.Kind, input.SourceAccountID, input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, input.Timezone); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if !input.Amount.IsPositive() {
|
||||||
|
return nil, fmt.Errorf("amount must be greater than zero")
|
||||||
|
}
|
||||||
|
if input.SpaceID == "" {
|
||||||
|
return nil, fmt.Errorf("space id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(input.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid timezone: %w", err)
|
||||||
|
}
|
||||||
|
if input.StartDate.IsZero() {
|
||||||
|
return nil, fmt.Errorf("start date is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var description *string
|
||||||
|
if d := strings.TrimSpace(input.Description); d != "" {
|
||||||
|
description = &d
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
SpaceID: input.SpaceID,
|
||||||
|
Kind: input.Kind,
|
||||||
|
SourceAccountID: input.SourceAccountID,
|
||||||
|
Title: title,
|
||||||
|
Amount: input.Amount,
|
||||||
|
Description: description,
|
||||||
|
Frequency: input.Frequency,
|
||||||
|
IntervalCount: input.IntervalCount,
|
||||||
|
DayOfWeek: input.DayOfWeek,
|
||||||
|
DayOfMonth: input.DayOfMonth,
|
||||||
|
MonthOfYear: input.MonthOfYear,
|
||||||
|
FireHour: input.FireHour,
|
||||||
|
FireMinute: input.FireMinute,
|
||||||
|
Timezone: input.Timezone,
|
||||||
|
NextRunAt: firstFire.UTC(),
|
||||||
|
Paused: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(ev); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create recurring event: %w", err)
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRecurringEventInput struct {
|
||||||
|
ID string
|
||||||
|
Kind model.RecurringEventKind
|
||||||
|
SourceAccountID string
|
||||||
|
Title string
|
||||||
|
Amount decimal.Decimal
|
||||||
|
Description string
|
||||||
|
|
||||||
|
Frequency model.RecurringFrequency
|
||||||
|
IntervalCount int
|
||||||
|
DayOfWeek *int
|
||||||
|
DayOfMonth *int
|
||||||
|
MonthOfYear *int
|
||||||
|
FireHour int
|
||||||
|
FireMinute int
|
||||||
|
Timezone string
|
||||||
|
|
||||||
|
// StartDate, if non-zero, recomputes the next firing. If zero, the current
|
||||||
|
// cursor is kept (useful for purely cosmetic edits like renaming).
|
||||||
|
StartDate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.RecurringEvent, error) {
|
||||||
|
if err := validateRule(input.Kind, input.SourceAccountID, input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, input.Timezone); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
if !input.Amount.IsPositive() {
|
||||||
|
return nil, fmt.Errorf("amount must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.repo.ByID(input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(input.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid timezone: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRun := existing.NextRunAt
|
||||||
|
if !input.StartDate.IsZero() {
|
||||||
|
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nextRun = firstFire.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
var description *string
|
||||||
|
if d := strings.TrimSpace(input.Description); d != "" {
|
||||||
|
description = &d
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Kind = input.Kind
|
||||||
|
existing.SourceAccountID = input.SourceAccountID
|
||||||
|
existing.Title = title
|
||||||
|
existing.Amount = input.Amount
|
||||||
|
existing.Description = description
|
||||||
|
existing.Frequency = input.Frequency
|
||||||
|
existing.IntervalCount = input.IntervalCount
|
||||||
|
existing.DayOfWeek = input.DayOfWeek
|
||||||
|
existing.DayOfMonth = input.DayOfMonth
|
||||||
|
existing.MonthOfYear = input.MonthOfYear
|
||||||
|
existing.FireHour = input.FireHour
|
||||||
|
existing.FireMinute = input.FireMinute
|
||||||
|
existing.Timezone = input.Timezone
|
||||||
|
existing.NextRunAt = nextRun
|
||||||
|
|
||||||
|
if err := s.repo.Update(existing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) Delete(id string) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) SetPaused(id string, paused bool) error {
|
||||||
|
return s.repo.SetPaused(id, paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) Get(id string) (*model.RecurringEvent, error) {
|
||||||
|
return s.repo.ByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) ListBySpace(spaceID string) ([]*model.RecurringEvent, error) {
|
||||||
|
return s.repo.BySpaceID(spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) ListByAccount(accountID string) ([]*model.RecurringEvent, error) {
|
||||||
|
return s.repo.ByAccountID(accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessDue materializes every recurring event whose next_run_at is at or
|
||||||
|
// before `now`, advancing each cursor and backfilling missed occurrences. One
|
||||||
|
// event's failure is logged but does not stop processing of others.
|
||||||
|
func (s *RecurringEventService) ProcessDue(now time.Time) error {
|
||||||
|
events, err := s.repo.DueBefore(now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list due events: %w", err)
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
if err := s.fireUntilCaughtUp(ev, now); err != nil {
|
||||||
|
slog.Error("recurring event materialization failed",
|
||||||
|
"error", err, "event_id", ev.ID, "kind", ev.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) fireUntilCaughtUp(ev *model.RecurringEvent, now time.Time) error {
|
||||||
|
loc, err := time.LoadLocation(ev.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone %q: %w", ev.Timezone, err)
|
||||||
|
}
|
||||||
|
for !ev.NextRunAt.After(now) {
|
||||||
|
if err := s.materialize(ev); err != nil {
|
||||||
|
return fmt.Errorf("materialize: %w", err)
|
||||||
|
}
|
||||||
|
next, err := nextFireAfter(ev, ev.NextRunAt, loc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compute next: %w", err)
|
||||||
|
}
|
||||||
|
last := ev.NextRunAt
|
||||||
|
if err := s.repo.UpdateCursor(ev.ID, next, last); err != nil {
|
||||||
|
return fmt.Errorf("persist cursor: %w", err)
|
||||||
|
}
|
||||||
|
ev.LastRunAt = &last
|
||||||
|
ev.NextRunAt = next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RecurringEventService) materialize(ev *model.RecurringEvent) error {
|
||||||
|
desc := ""
|
||||||
|
if ev.Description != nil {
|
||||||
|
desc = *ev.Description
|
||||||
|
}
|
||||||
|
switch ev.Kind {
|
||||||
|
case model.RecurringEventKindBill:
|
||||||
|
_, err := s.txService.PayBill(PayBillInput{
|
||||||
|
AccountID: ev.SourceAccountID,
|
||||||
|
Title: ev.Title,
|
||||||
|
Amount: ev.Amount,
|
||||||
|
OccurredAt: ev.NextRunAt,
|
||||||
|
Description: desc,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
case model.RecurringEventKindFund:
|
||||||
|
_, err := s.txService.Deposit(DepositInput{
|
||||||
|
AccountID: ev.SourceAccountID,
|
||||||
|
Title: ev.Title,
|
||||||
|
Amount: ev.Amount,
|
||||||
|
OccurredAt: ev.NextRunAt,
|
||||||
|
Description: desc,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown recurring event kind: %s", ev.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Recurrence math -----
|
||||||
|
|
||||||
|
func validateRule(kind model.RecurringEventKind, src string, freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, tz string) error {
|
||||||
|
switch kind {
|
||||||
|
case model.RecurringEventKindBill, model.RecurringEventKindFund:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid kind: %s", kind)
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
return fmt.Errorf("source account is required")
|
||||||
|
}
|
||||||
|
if interval < 1 {
|
||||||
|
return fmt.Errorf("interval must be at least 1")
|
||||||
|
}
|
||||||
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||||
|
return fmt.Errorf("invalid time of day")
|
||||||
|
}
|
||||||
|
if tz == "" {
|
||||||
|
return fmt.Errorf("timezone is required")
|
||||||
|
}
|
||||||
|
if _, err := time.LoadLocation(tz); err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone: %w", err)
|
||||||
|
}
|
||||||
|
switch freq {
|
||||||
|
case model.RecurringFrequencyDaily:
|
||||||
|
// no anchors required
|
||||||
|
case model.RecurringFrequencyWeekly:
|
||||||
|
if dow == nil || *dow < 0 || *dow > 6 {
|
||||||
|
return fmt.Errorf("weekly events require day of week")
|
||||||
|
}
|
||||||
|
case model.RecurringFrequencyMonthly:
|
||||||
|
if dom == nil || *dom < 1 || *dom > 31 {
|
||||||
|
return fmt.Errorf("monthly events require day of month")
|
||||||
|
}
|
||||||
|
case model.RecurringFrequencyYearly:
|
||||||
|
if dom == nil || *dom < 1 || *dom > 31 {
|
||||||
|
return fmt.Errorf("yearly events require day of month")
|
||||||
|
}
|
||||||
|
if moy == nil || *moy < 1 || *moy > 12 {
|
||||||
|
return fmt.Errorf("yearly events require month of year")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid frequency: %s", freq)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFireOnOrAfter computes the first firing in `loc` at or after the local
|
||||||
|
// midnight of startDate, snapped to the recurrence anchors and time-of-day.
|
||||||
|
func firstFireOnOrAfter(freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, loc *time.Location, startDate time.Time) (time.Time, error) {
|
||||||
|
startLocal := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, loc)
|
||||||
|
threshold := startLocal.Add(-time.Nanosecond) // nextFireAfter computes strictly-after; -1ns lets the first candidate land on startDate
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: freq,
|
||||||
|
IntervalCount: interval,
|
||||||
|
DayOfWeek: dow,
|
||||||
|
DayOfMonth: dom,
|
||||||
|
MonthOfYear: moy,
|
||||||
|
FireHour: hour,
|
||||||
|
FireMinute: minute,
|
||||||
|
}
|
||||||
|
return nextFireAfter(ev, threshold, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextFireAfter computes the next firing strictly after `after`, in UTC.
|
||||||
|
func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location) (time.Time, error) {
|
||||||
|
afterLocal := after.In(loc)
|
||||||
|
switch ev.Frequency {
|
||||||
|
case model.RecurringFrequencyDaily:
|
||||||
|
c := time.Date(afterLocal.Year(), afterLocal.Month(), afterLocal.Day(), ev.FireHour, ev.FireMinute, 0, 0, loc)
|
||||||
|
for !c.After(after) {
|
||||||
|
c = c.AddDate(0, 0, ev.IntervalCount)
|
||||||
|
}
|
||||||
|
return c.UTC(), nil
|
||||||
|
|
||||||
|
case model.RecurringFrequencyWeekly:
|
||||||
|
if ev.DayOfWeek == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("weekly event missing day of week")
|
||||||
|
}
|
||||||
|
c := time.Date(afterLocal.Year(), afterLocal.Month(), afterLocal.Day(), ev.FireHour, ev.FireMinute, 0, 0, loc)
|
||||||
|
shift := (int(time.Weekday(*ev.DayOfWeek)) - int(c.Weekday()) + 7) % 7
|
||||||
|
c = c.AddDate(0, 0, shift)
|
||||||
|
for !c.After(after) {
|
||||||
|
c = c.AddDate(0, 0, 7*ev.IntervalCount)
|
||||||
|
}
|
||||||
|
return c.UTC(), nil
|
||||||
|
|
||||||
|
case model.RecurringFrequencyMonthly:
|
||||||
|
if ev.DayOfMonth == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("monthly event missing day of month")
|
||||||
|
}
|
||||||
|
y, m := afterLocal.Year(), afterLocal.Month()
|
||||||
|
c := monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
|
for !c.After(after) {
|
||||||
|
y, m = addMonths(y, m, ev.IntervalCount)
|
||||||
|
c = monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
|
}
|
||||||
|
return c.UTC(), nil
|
||||||
|
|
||||||
|
case model.RecurringFrequencyYearly:
|
||||||
|
if ev.DayOfMonth == nil || ev.MonthOfYear == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("yearly event missing anchors")
|
||||||
|
}
|
||||||
|
moy := time.Month(*ev.MonthOfYear)
|
||||||
|
y := afterLocal.Year()
|
||||||
|
c := monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
|
for !c.After(after) {
|
||||||
|
y += ev.IntervalCount
|
||||||
|
c = monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
|
}
|
||||||
|
return c.UTC(), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unknown frequency: %s", ev.Frequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// monthlyCandidate constructs a fire time at year/month, clamping the day to
|
||||||
|
// that month's actual length (e.g. day=31 in February becomes the 28th/29th).
|
||||||
|
func monthlyCandidate(year int, month time.Month, dom, hour, minute int, loc *time.Location) time.Time {
|
||||||
|
last := lastDayOfMonth(year, month, loc)
|
||||||
|
if dom > last {
|
||||||
|
dom = last
|
||||||
|
}
|
||||||
|
return time.Date(year, month, dom, hour, minute, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastDayOfMonth(year int, month time.Month, loc *time.Location) int {
|
||||||
|
firstOfNext := time.Date(year, month+1, 1, 0, 0, 0, 0, loc)
|
||||||
|
return firstOfNext.AddDate(0, 0, -1).Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMonths(y int, m time.Month, n int) (int, time.Month) {
|
||||||
|
total := (int(m) - 1) + n
|
||||||
|
years := total / 12
|
||||||
|
rem := total % 12
|
||||||
|
if rem < 0 {
|
||||||
|
rem += 12
|
||||||
|
years--
|
||||||
|
}
|
||||||
|
return y + years, time.Month(rem + 1)
|
||||||
|
}
|
||||||
183
internal/service/recurring_event_test.go
Normal file
183
internal/service/recurring_event_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustLoad(t *testing.T, name string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
|
func TestNextFireAfter_Daily(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "America/New_York")
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyDaily,
|
||||||
|
IntervalCount: 1,
|
||||||
|
FireHour: 9,
|
||||||
|
FireMinute: 30,
|
||||||
|
}
|
||||||
|
// "after" = 2026-05-01 14:00 NY → next fire same day's 09:30 already passed,
|
||||||
|
// so should be 2026-05-02 09:30 NY.
|
||||||
|
after := time.Date(2026, 5, 1, 14, 0, 0, 0, loc)
|
||||||
|
got, err := nextFireAfter(ev, after, loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 5, 2, 9, 30, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("daily next: got %v want %v", got.In(loc), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextFireAfter_DailyEvery3Days(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyDaily,
|
||||||
|
IntervalCount: 3,
|
||||||
|
FireHour: 8,
|
||||||
|
FireMinute: 0,
|
||||||
|
}
|
||||||
|
after := time.Date(2026, 5, 1, 8, 0, 1, 0, loc) // 1 second past today's fire
|
||||||
|
got, _ := nextFireAfter(ev, after, loc)
|
||||||
|
want := time.Date(2026, 5, 4, 8, 0, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextFireAfter_Weekly(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
// Every Tuesday (DayOfWeek=2) at 10:00, after Wed 2026-05-06.
|
||||||
|
dow := 2
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyWeekly,
|
||||||
|
IntervalCount: 1,
|
||||||
|
DayOfWeek: &dow,
|
||||||
|
FireHour: 10,
|
||||||
|
FireMinute: 0,
|
||||||
|
}
|
||||||
|
after := time.Date(2026, 5, 6, 12, 0, 0, 0, loc) // Wed 12:00
|
||||||
|
got, _ := nextFireAfter(ev, after, loc)
|
||||||
|
want := time.Date(2026, 5, 12, 10, 0, 0, 0, loc) // following Tue
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextFireAfter_MonthlyDayClamp(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
// Every month on day 31. Jan 31 → Feb 28 (2026 not leap).
|
||||||
|
dom := 31
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyMonthly,
|
||||||
|
IntervalCount: 1,
|
||||||
|
DayOfMonth: &dom,
|
||||||
|
FireHour: 0,
|
||||||
|
FireMinute: 0,
|
||||||
|
}
|
||||||
|
after := time.Date(2026, 1, 31, 0, 0, 1, 0, loc)
|
||||||
|
got, _ := nextFireAfter(ev, after, loc)
|
||||||
|
want := time.Date(2026, 2, 28, 0, 0, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
// Then Feb 28 → Mar 31 (anchor preserved).
|
||||||
|
got2, _ := nextFireAfter(ev, want, loc)
|
||||||
|
want2 := time.Date(2026, 3, 31, 0, 0, 0, 0, loc)
|
||||||
|
if !got2.Equal(want2) {
|
||||||
|
t.Errorf("got2 %v want2 %v", got2, want2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextFireAfter_Yearly(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
dom := 15
|
||||||
|
moy := 6
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyYearly,
|
||||||
|
IntervalCount: 1,
|
||||||
|
DayOfMonth: &dom,
|
||||||
|
MonthOfYear: &moy,
|
||||||
|
FireHour: 12,
|
||||||
|
FireMinute: 0,
|
||||||
|
}
|
||||||
|
after := time.Date(2026, 6, 15, 12, 0, 1, 0, loc)
|
||||||
|
got, _ := nextFireAfter(ev, after, loc)
|
||||||
|
want := time.Date(2027, 6, 15, 12, 0, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextFireAfter_TimezoneCrossesUTCBoundary(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "America/Los_Angeles")
|
||||||
|
// 09:00 LA daily. From 2026-05-01 17:00 UTC (10:00 LA), already past, so
|
||||||
|
// next is 2026-05-02 09:00 LA = 16:00 UTC.
|
||||||
|
ev := &model.RecurringEvent{
|
||||||
|
Frequency: model.RecurringFrequencyDaily,
|
||||||
|
IntervalCount: 1,
|
||||||
|
FireHour: 9,
|
||||||
|
FireMinute: 0,
|
||||||
|
}
|
||||||
|
after := time.Date(2026, 5, 1, 17, 0, 0, 0, time.UTC)
|
||||||
|
got, _ := nextFireAfter(ev, after, loc)
|
||||||
|
want := time.Date(2026, 5, 2, 16, 0, 0, 0, time.UTC) // PDT, UTC-7
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
// Daily at 09:00, start date 2026-05-10 → first fire 2026-05-10 09:00.
|
||||||
|
got, err := firstFireOnOrAfter(model.RecurringFrequencyDaily, 1, nil, nil, nil, 9, 0, loc, time.Date(2026, 5, 10, 0, 0, 0, 0, loc))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 5, 10, 9, 0, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstFireOnOrAfter_WeeklyShiftsToTargetDayOfWeek(t *testing.T) {
|
||||||
|
loc := mustLoad(t, "UTC")
|
||||||
|
// Start 2026-05-04 (Mon), target weekday Friday (5) → first fire 2026-05-08.
|
||||||
|
got, _ := firstFireOnOrAfter(model.RecurringFrequencyWeekly, 1, intPtr(5), nil, nil, 8, 0, loc, time.Date(2026, 5, 4, 0, 0, 0, 0, loc))
|
||||||
|
want := time.Date(2026, 5, 8, 8, 0, 0, 0, loc)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddMonths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
y int
|
||||||
|
m time.Month
|
||||||
|
n int
|
||||||
|
wy int
|
||||||
|
wm time.Month
|
||||||
|
}{
|
||||||
|
{2026, time.January, 1, 2026, time.February},
|
||||||
|
{2026, time.December, 1, 2027, time.January},
|
||||||
|
{2026, time.November, 3, 2027, time.February},
|
||||||
|
{2026, time.January, 12, 2027, time.January},
|
||||||
|
{2026, time.January, 25, 2028, time.February},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
gy, gm := addMonths(tt.y, tt.m, tt.n)
|
||||||
|
if gy != tt.wy || gm != tt.wm {
|
||||||
|
t.Errorf("addMonths(%d,%v,%d) = %d,%v; want %d,%v", tt.y, tt.m, tt.n, gy, gm, tt.wy, tt.wm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/ui/forms/helpers.go
Normal file
5
internal/ui/forms/helpers.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package forms
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
func intToStr(n int) string { return strconv.Itoa(n) }
|
||||||
336
internal/ui/forms/recurring_event.templ
Normal file
336
internal/ui/forms/recurring_event.templ
Normal 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>
|
||||||
|
}
|
||||||
57
internal/ui/pages/recurring_event_helpers.go
Normal file
57
internal/ui/pages/recurring_event_helpers.go
Normal 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)
|
||||||
|
}
|
||||||
29
internal/ui/pages/space_create_recurring_event.templ
Normal file
29
internal/ui/pages/space_create_recurring_event.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/ui/pages/space_edit_recurring_event.templ
Normal file
30
internal/ui/pages/space_edit_recurring_event.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
|
||||||
<span>Members</span>
|
<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.MenuItem() {
|
||||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),
|
Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),
|
||||||
|
|
|
||||||
132
internal/ui/pages/space_recurring_events.templ
Normal file
132
internal/ui/pages/space_recurring_events.templ
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue