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