feat: recurring expenses and reports

This commit is contained in:
juancwu 2026-02-14 17:00:15 +00:00
commit 9e6ff67a87
23 changed files with 2943 additions and 56 deletions

View file

@ -13,6 +13,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/app"
"git.juancwu.dev/juancwu/budgit/internal/config"
"git.juancwu.dev/juancwu/budgit/internal/routes"
"git.juancwu.dev/juancwu/budgit/internal/scheduler"
)
// version is set at build time via -ldflags.
@ -35,6 +36,12 @@ func main() {
handler := routes.SetupRoutes(a)
// Start recurring expense scheduler
schedulerCtx, schedulerCancel := context.WithCancel(context.Background())
defer schedulerCancel()
sched := scheduler.New(a.RecurringExpenseService)
go sched.Start(schedulerCtx)
// Health check bypasses all middleware
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/healthz" {

View file

@ -11,19 +11,22 @@ import (
)
type App struct {
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
SpaceService *service.SpaceService
TagService *service.TagService
ShoppingListService *service.ShoppingListService
ExpenseService *service.ExpenseService
InviteService *service.InviteService
MoneyAccountService *service.MoneyAccountService
PaymentMethodService *service.PaymentMethodService
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
SpaceService *service.SpaceService
TagService *service.TagService
ShoppingListService *service.ShoppingListService
ExpenseService *service.ExpenseService
InviteService *service.InviteService
MoneyAccountService *service.MoneyAccountService
PaymentMethodService *service.PaymentMethodService
RecurringExpenseService *service.RecurringExpenseService
BudgetService *service.BudgetService
ReportService *service.ReportService
}
func New(cfg *config.Config) (*App, error) {
@ -51,6 +54,8 @@ func New(cfg *config.Config) (*App, error) {
invitationRepository := repository.NewInvitationRepository(database)
moneyAccountRepository := repository.NewMoneyAccountRepository(database)
paymentMethodRepository := repository.NewPaymentMethodRepository(database)
recurringExpenseRepository := repository.NewRecurringExpenseRepository(database)
budgetRepository := repository.NewBudgetRepository(database)
// Services
userService := service.NewUserService(userRepository)
@ -80,21 +85,27 @@ func New(cfg *config.Config) (*App, error) {
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository)
budgetService := service.NewBudgetService(budgetRepository)
reportService := service.NewReportService(expenseRepository)
return &App{
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
SpaceService: spaceService,
TagService: tagService,
ShoppingListService: shoppingListService,
ExpenseService: expenseService,
InviteService: inviteService,
MoneyAccountService: moneyAccountService,
PaymentMethodService: paymentMethodService,
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
SpaceService: spaceService,
TagService: tagService,
ShoppingListService: shoppingListService,
ExpenseService: expenseService,
InviteService: inviteService,
MoneyAccountService: moneyAccountService,
PaymentMethodService: paymentMethodService,
RecurringExpenseService: recurringExpenseService,
BudgetService: budgetService,
ReportService: reportService,
}, nil
}
func (a *App) Close() error {

View file

@ -0,0 +1,44 @@
-- +goose Up
CREATE TABLE recurring_expenses (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
created_by TEXT NOT NULL,
description TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('expense', 'topup')),
payment_method_id TEXT,
frequency TEXT NOT NULL CHECK (frequency IN ('daily', 'weekly', 'biweekly', 'monthly', 'yearly')),
start_date DATE NOT NULL,
end_date DATE,
next_occurrence DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id) ON DELETE SET NULL
);
CREATE TABLE recurring_expense_tags (
recurring_expense_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (recurring_expense_id, tag_id),
FOREIGN KEY (recurring_expense_id) REFERENCES recurring_expenses(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
ALTER TABLE expenses ADD COLUMN recurring_expense_id TEXT REFERENCES recurring_expenses(id) ON DELETE SET NULL;
CREATE INDEX idx_recurring_expenses_space_id ON recurring_expenses(space_id);
CREATE INDEX idx_recurring_expenses_next_occurrence ON recurring_expenses(next_occurrence);
CREATE INDEX idx_recurring_expenses_active ON recurring_expenses(is_active);
CREATE INDEX idx_expenses_recurring_expense_id ON expenses(recurring_expense_id);
-- +goose Down
DROP INDEX IF EXISTS idx_expenses_recurring_expense_id;
DROP INDEX IF EXISTS idx_recurring_expenses_active;
DROP INDEX IF EXISTS idx_recurring_expenses_next_occurrence;
DROP INDEX IF EXISTS idx_recurring_expenses_space_id;
ALTER TABLE expenses DROP COLUMN IF EXISTS recurring_expense_id;
DROP TABLE IF EXISTS recurring_expense_tags;
DROP TABLE IF EXISTS recurring_expenses;

View file

@ -0,0 +1,24 @@
-- +goose Up
CREATE TABLE budgets (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
period TEXT NOT NULL CHECK (period IN ('weekly', 'monthly', 'yearly')),
start_date DATE NOT NULL,
end_date DATE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(space_id, tag_id, period),
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_budgets_space_id ON budgets(space_id);
-- +goose Down
DROP INDEX IF EXISTS idx_budgets_space_id;
DROP TABLE IF EXISTS budgets;

View file

@ -14,6 +14,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
@ -21,24 +22,30 @@ import (
)
type SpaceHandler struct {
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
expenseService *service.ExpenseService
inviteService *service.InviteService
accountService *service.MoneyAccountService
methodService *service.PaymentMethodService
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
expenseService *service.ExpenseService
inviteService *service.InviteService
accountService *service.MoneyAccountService
methodService *service.PaymentMethodService
recurringService *service.RecurringExpenseService
budgetService *service.BudgetService
reportService *service.ReportService
}
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService, pms *service.PaymentMethodService) *SpaceHandler {
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService, pms *service.PaymentMethodService, rs *service.RecurringExpenseService, bs *service.BudgetService, rps *service.ReportService) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
tagService: ts,
listService: sls,
expenseService: es,
inviteService: is,
accountService: mas,
methodService: pms,
spaceService: ss,
tagService: ts,
listService: sls,
expenseService: es,
inviteService: is,
accountService: mas,
methodService: pms,
recurringService: rs,
budgetService: bs,
reportService: rps,
}
}
@ -1479,6 +1486,613 @@ func (h *SpaceHandler) DeletePaymentMethod(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusOK)
}
// --- Recurring Expenses ---
func (h *SpaceHandler) getRecurringForSpace(w http.ResponseWriter, spaceID, recurringID string) *model.RecurringExpense {
re, err := h.recurringService.GetRecurringExpense(recurringID)
if err != nil {
http.Error(w, "Recurring expense not found", http.StatusNotFound)
return nil
}
if re.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return re
}
func (h *SpaceHandler) RecurringExpensesPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
// Lazy check: process any due recurrences for this space
h.recurringService.ProcessDueRecurrencesForSpace(spaceID, time.Now())
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
if err != nil {
slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
methods, err := h.methodService.GetMethodsForSpace(spaceID)
if err != nil {
slog.Error("failed to get payment methods", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceRecurringPage(space, recs, tags, methods))
}
func (h *SpaceHandler) CreateRecurringExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
description := r.FormValue("description")
amountStr := r.FormValue("amount")
typeStr := r.FormValue("type")
frequencyStr := r.FormValue("frequency")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
tagNames := r.Form["tags"]
if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" {
http.Error(w, "All required fields must be provided.", http.StatusBadRequest)
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
http.Error(w, "Invalid amount format.", http.StatusBadRequest)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
http.Error(w, "Invalid start date format.", http.StatusBadRequest)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
http.Error(w, "Invalid end date format.", http.StatusBadRequest)
return
}
endDate = &ed
}
expenseType := model.ExpenseType(typeStr)
if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup {
http.Error(w, "Invalid transaction type.", http.StatusBadRequest)
return
}
frequency := model.Frequency(frequencyStr)
// Tag processing
existingTags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
existingTagsMap := make(map[string]string)
for _, t := range existingTags {
existingTagsMap[t.Name] = t.ID
}
var finalTagIDs []string
processedTags := make(map[string]bool)
for _, rawTagName := range tagNames {
tagName := service.NormalizeTagName(rawTagName)
if tagName == "" || processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
newTag, err := h.tagService.CreateTag(spaceID, tagName, nil)
if err != nil {
slog.Error("failed to create tag", "error", err, "tag_name", tagName)
continue
}
finalTagIDs = append(finalTagIDs, newTag.ID)
existingTagsMap[tagName] = newTag.ID
}
processedTags[tagName] = true
}
var paymentMethodID *string
if pmid := r.FormValue("payment_method_id"); pmid != "" {
paymentMethodID = &pmid
}
re, err := h.recurringService.CreateRecurringExpense(service.CreateRecurringExpenseDTO{
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amountCents,
Type: expenseType,
PaymentMethodID: paymentMethodID,
Frequency: frequency,
StartDate: startDate,
EndDate: endDate,
TagIDs: finalTagIDs,
})
if err != nil {
slog.Error("failed to create recurring expense", "error", err)
http.Error(w, "Failed to create recurring expense.", http.StatusInternalServerError)
return
}
// Fetch tags/method for the response
tagsMap, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMap {
if item.ID == re.ID {
ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil))
return
}
}
// Fallback: render without tags
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil))
}
func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringID := r.PathValue("recurringID")
if h.getRecurringForSpace(w, spaceID, recurringID) == nil {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
description := r.FormValue("description")
amountStr := r.FormValue("amount")
typeStr := r.FormValue("type")
frequencyStr := r.FormValue("frequency")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
tagNames := r.Form["tags"]
if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" {
http.Error(w, "All required fields must be provided.", http.StatusBadRequest)
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
http.Error(w, "Invalid amount.", http.StatusBadRequest)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
http.Error(w, "Invalid start date.", http.StatusBadRequest)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
http.Error(w, "Invalid end date.", http.StatusBadRequest)
return
}
endDate = &ed
}
// Tag processing
existingTags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
existingTagsMap := make(map[string]string)
for _, t := range existingTags {
existingTagsMap[t.Name] = t.ID
}
var finalTagIDs []string
processedTags := make(map[string]bool)
for _, rawTagName := range tagNames {
tagName := service.NormalizeTagName(rawTagName)
if tagName == "" || processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
newTag, err := h.tagService.CreateTag(spaceID, tagName, nil)
if err != nil {
continue
}
finalTagIDs = append(finalTagIDs, newTag.ID)
}
processedTags[tagName] = true
}
var paymentMethodID *string
if pmid := r.FormValue("payment_method_id"); pmid != "" {
paymentMethodID = &pmid
}
updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{
ID: recurringID,
Description: description,
Amount: amountCents,
Type: model.ExpenseType(typeStr),
PaymentMethodID: paymentMethodID,
Frequency: model.Frequency(frequencyStr),
StartDate: startDate,
EndDate: endDate,
TagIDs: finalTagIDs,
})
if err != nil {
slog.Error("failed to update recurring expense", "error", err)
http.Error(w, "Failed to update.", http.StatusInternalServerError)
return
}
// Build response with tags/method
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMapResult {
if item.ID == updated.ID {
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
return
}
}
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
}
func (h *SpaceHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringID := r.PathValue("recurringID")
if h.getRecurringForSpace(w, spaceID, recurringID) == nil {
return
}
if err := h.recurringService.DeleteRecurringExpense(recurringID); err != nil {
slog.Error("failed to delete recurring expense", "error", err, "recurring_id", recurringID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringID := r.PathValue("recurringID")
if h.getRecurringForSpace(w, spaceID, recurringID) == nil {
return
}
updated, err := h.recurringService.ToggleRecurringExpense(recurringID)
if err != nil {
slog.Error("failed to toggle recurring expense", "error", err, "recurring_id", recurringID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMapResult {
if item.ID == updated.ID {
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
return
}
}
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
}
// --- Budgets ---
func (h *SpaceHandler) getBudgetForSpace(w http.ResponseWriter, spaceID, budgetID string) *model.Budget {
budget, err := h.budgetService.GetBudget(budgetID)
if err != nil {
http.Error(w, "Budget not found", http.StatusNotFound)
return nil
}
if budget.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return budget
}
func (h *SpaceHandler) BudgetsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
if err != nil {
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceBudgetsPage(space, budgets, tags))
}
func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
tagID := r.FormValue("tag_id")
amountStr := r.FormValue("amount")
periodStr := r.FormValue("period")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
if tagID == "" || amountStr == "" || periodStr == "" || startDateStr == "" {
http.Error(w, "All required fields must be provided.", http.StatusBadRequest)
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
http.Error(w, "Invalid amount.", http.StatusBadRequest)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
http.Error(w, "Invalid start date.", http.StatusBadRequest)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
http.Error(w, "Invalid end date.", http.StatusBadRequest)
return
}
endDate = &ed
}
_, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{
SpaceID: spaceID,
TagID: tagID,
Amount: amountCents,
Period: model.BudgetPeriod(periodStr),
StartDate: startDate,
EndDate: endDate,
CreatedBy: user.ID,
})
if err != nil {
slog.Error("failed to create budget", "error", err)
http.Error(w, "Failed to create budget.", http.StatusInternalServerError)
return
}
// Refresh the full budgets list
tags, _ := h.tagService.GetTagsForSpace(spaceID)
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
}
func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
budgetID := r.PathValue("budgetID")
if h.getBudgetForSpace(w, spaceID, budgetID) == nil {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
tagID := r.FormValue("tag_id")
amountStr := r.FormValue("amount")
periodStr := r.FormValue("period")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
if tagID == "" || amountStr == "" || periodStr == "" || startDateStr == "" {
http.Error(w, "All required fields must be provided.", http.StatusBadRequest)
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
http.Error(w, "Invalid amount.", http.StatusBadRequest)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
http.Error(w, "Invalid start date.", http.StatusBadRequest)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
http.Error(w, "Invalid end date.", http.StatusBadRequest)
return
}
endDate = &ed
}
_, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{
ID: budgetID,
TagID: tagID,
Amount: amountCents,
Period: model.BudgetPeriod(periodStr),
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
slog.Error("failed to update budget", "error", err)
http.Error(w, "Failed to update budget.", http.StatusInternalServerError)
return
}
// Refresh the full budgets list
tags, _ := h.tagService.GetTagsForSpace(spaceID)
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
}
func (h *SpaceHandler) DeleteBudget(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
budgetID := r.PathValue("budgetID")
if h.getBudgetForSpace(w, spaceID, budgetID) == nil {
return
}
if err := h.budgetService.DeleteBudget(budgetID); err != nil {
slog.Error("failed to delete budget", "error", err, "budget_id", budgetID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
tags, _ := h.tagService.GetTagsForSpace(spaceID)
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
if err != nil {
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
}
// --- Reports ---
func (h *SpaceHandler) ReportsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
// Default to this month
now := time.Now()
presets := service.GetPresetDateRanges(now)
from := presets[0].From
to := presets[0].To
report, err := h.reportService.GetSpendingReport(spaceID, from, to)
if err != nil {
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceReportsPage(space, report, presets, "this_month"))
}
func (h *SpaceHandler) GetReportCharts(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
rangeKey := r.URL.Query().Get("range")
now := time.Now()
presets := service.GetPresetDateRanges(now)
var from, to time.Time
activeRange := "this_month"
if rangeKey == "custom" {
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
var err error
from, err = time.Parse("2006-01-02", fromStr)
if err != nil {
from = presets[0].From
}
to, err = time.Parse("2006-01-02", toStr)
if err != nil {
to = presets[0].To
}
activeRange = "custom"
} else {
for _, p := range presets {
if p.Key == rangeKey {
from = p.From
to = p.To
activeRange = p.Key
break
}
}
if from.IsZero() {
from = presets[0].From
to = presets[0].To
}
}
report, err := h.reportService.GetSpendingReport(spaceID, from, to)
if err != nil {
slog.Error("failed to get report charts", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
_ = activeRange
ui.Render(w, r, pages.ReportCharts(spaceID, report, from, to))
}
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {

42
internal/model/budget.go Normal file
View file

@ -0,0 +1,42 @@
package model
import "time"
type BudgetPeriod string
const (
BudgetPeriodWeekly BudgetPeriod = "weekly"
BudgetPeriodMonthly BudgetPeriod = "monthly"
BudgetPeriodYearly BudgetPeriod = "yearly"
)
type BudgetStatus string
const (
BudgetStatusOnTrack BudgetStatus = "on_track"
BudgetStatusWarning BudgetStatus = "warning"
BudgetStatusOver BudgetStatus = "over"
)
type Budget struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
TagID string `db:"tag_id"`
AmountCents int `db:"amount_cents"`
Period BudgetPeriod `db:"period"`
StartDate time.Time `db:"start_date"`
EndDate *time.Time `db:"end_date"`
IsActive bool `db:"is_active"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type BudgetWithSpent struct {
Budget
TagName string `db:"tag_name"`
TagColor *string `db:"tag_color"`
SpentCents int
Percentage float64
Status BudgetStatus
}

View file

@ -10,16 +10,17 @@ const (
)
type Expense struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
CreatedBy string `db:"created_by"`
Description string `db:"description"`
AmountCents int `db:"amount_cents"`
Type ExpenseType `db:"type"`
Date time.Time `db:"date"`
PaymentMethodID *string `db:"payment_method_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
CreatedBy string `db:"created_by"`
Description string `db:"description"`
AmountCents int `db:"amount_cents"`
Type ExpenseType `db:"type"`
Date time.Time `db:"date"`
PaymentMethodID *string `db:"payment_method_id"`
RecurringExpenseID *string `db:"recurring_expense_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type ExpenseWithTags struct {

View file

@ -0,0 +1,41 @@
package model
import "time"
type Frequency string
const (
FrequencyDaily Frequency = "daily"
FrequencyWeekly Frequency = "weekly"
FrequencyBiweekly Frequency = "biweekly"
FrequencyMonthly Frequency = "monthly"
FrequencyYearly Frequency = "yearly"
)
type RecurringExpense struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
CreatedBy string `db:"created_by"`
Description string `db:"description"`
AmountCents int `db:"amount_cents"`
Type ExpenseType `db:"type"`
PaymentMethodID *string `db:"payment_method_id"`
Frequency Frequency `db:"frequency"`
StartDate time.Time `db:"start_date"`
EndDate *time.Time `db:"end_date"`
NextOccurrence time.Time `db:"next_occurrence"`
IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type RecurringExpenseWithTags struct {
RecurringExpense
Tags []*Tag
}
type RecurringExpenseWithTagsAndMethod struct {
RecurringExpense
Tags []*Tag
PaymentMethod *PaymentMethod
}

23
internal/model/report.go Normal file
View file

@ -0,0 +1,23 @@
package model
import "time"
type DailySpending struct {
Date time.Time `db:"date"`
TotalCents int `db:"total_cents"`
}
type MonthlySpending struct {
Month string `db:"month"`
TotalCents int `db:"total_cents"`
}
type SpendingReport struct {
ByTag []*TagExpenseSummary
DailySpending []*DailySpending
MonthlySpending []*MonthlySpending
TopExpenses []*ExpenseWithTagsAndMethod
TotalIncome int
TotalExpenses int
NetBalance int
}

View file

@ -0,0 +1,77 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrBudgetNotFound = errors.New("budget not found")
)
type BudgetRepository interface {
Create(budget *model.Budget) error
GetByID(id string) (*model.Budget, error)
GetBySpaceID(spaceID string) ([]*model.Budget, error)
GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error)
Update(budget *model.Budget) error
Delete(id string) error
}
type budgetRepository struct {
db *sqlx.DB
}
func NewBudgetRepository(db *sqlx.DB) BudgetRepository {
return &budgetRepository{db: db}
}
func (r *budgetRepository) Create(budget *model.Budget) error {
query := `INSERT INTO budgets (id, space_id, tag_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
_, err := r.db.Exec(query, budget.ID, budget.SpaceID, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
return err
}
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
budget := &model.Budget{}
err := r.db.Get(budget, `SELECT * FROM budgets WHERE id = $1;`, id)
if err == sql.ErrNoRows {
return nil, ErrBudgetNotFound
}
return budget, err
}
func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error) {
var budgets []*model.Budget
err := r.db.Select(&budgets, `SELECT * FROM budgets WHERE space_id = $1 ORDER BY created_at DESC;`, spaceID)
return budgets, err
}
func (r *budgetRepository) GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error) {
var spent int
query := `
SELECT COALESCE(SUM(e.amount_cents), 0)
FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id
WHERE e.space_id = $1 AND et.tag_id = $2 AND e.type = 'expense'
AND e.date >= $3 AND e.date <= $4;
`
err := r.db.Get(&spent, query, spaceID, tagID, periodStart, periodEnd)
return spent, err
}
func (r *budgetRepository) Update(budget *model.Budget) error {
query := `UPDATE budgets SET tag_id = $1, amount_cents = $2, period = $3, start_date = $4, end_date = $5, is_active = $6, updated_at = $7 WHERE id = $8;`
_, err := r.db.Exec(query, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
return err
}
func (r *budgetRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id)
return err
}

View file

@ -24,6 +24,11 @@ type ExpenseRepository interface {
GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error)
Update(expense *model.Expense, tagIDs []string) error
Delete(id string) error
// Report queries
GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error)
GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error)
GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error)
GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error)
}
type expenseRepository struct {
@ -42,9 +47,9 @@ func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, item
defer tx.Rollback()
// Insert Expense
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt)
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt)
if err != nil {
return err
}
@ -252,3 +257,69 @@ func (r *expenseRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id)
return err
}
func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
var results []*model.DailySpending
query := `
SELECT date, SUM(amount_cents) as total_cents
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY date
ORDER BY date ASC;
`
err := r.db.Select(&results, query, spaceID, from, to)
return results, err
}
func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error) {
var results []*model.MonthlySpending
query := `
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(amount_cents) as total_cents
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY TO_CHAR(date, 'YYYY-MM')
ORDER BY month ASC;
`
err := r.db.Select(&results, query, spaceID, from, to)
return results, err
}
func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error) {
var results []*model.Expense
query := `
SELECT * FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
ORDER BY amount_cents DESC
LIMIT $4;
`
err := r.db.Select(&results, query, spaceID, from, to, limit)
return results, err
}
func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error) {
type summary struct {
Type string `db:"type"`
Total int `db:"total"`
}
var results []summary
query := `
SELECT type, COALESCE(SUM(amount_cents), 0) as total
FROM expenses
WHERE space_id = $1 AND date >= $2 AND date <= $3
GROUP BY type;
`
err := r.db.Select(&results, query, spaceID, from, to)
if err != nil {
return 0, 0, err
}
var income, expenses int
for _, r := range results {
if r.Type == "topup" {
income = r.Total
} else if r.Type == "expense" {
expenses = r.Total
}
}
return income, expenses, nil
}

View file

@ -0,0 +1,228 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringExpenseNotFound = errors.New("recurring expense not found")
)
type RecurringExpenseRepository interface {
Create(re *model.RecurringExpense, tagIDs []string) error
GetByID(id string) (*model.RecurringExpense, error)
GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error)
GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error)
GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error)
Update(re *model.RecurringExpense, tagIDs []string) error
Delete(id string) error
SetActive(id string, active bool) error
GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error)
UpdateNextOccurrence(id string, next time.Time) error
Deactivate(id string) error
}
type recurringExpenseRepository struct {
db *sqlx.DB
}
func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository {
return &recurringExpenseRepository{db: db}
}
func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);`
_, err = tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt)
if err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (r *recurringExpenseRepository) GetByID(id string) (*model.RecurringExpense, error) {
re := &model.RecurringExpense{}
query := `SELECT * FROM recurring_expenses WHERE id = $1;`
err := r.db.Get(re, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringExpenseNotFound
}
return re, err
}
func (r *recurringExpenseRepository) GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringExpenseRepository) GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error) {
if len(ids) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
RecurringExpenseID string `db:"recurring_expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
}
query, args, err := sqlx.In(`
SELECT ret.recurring_expense_id, t.id, t.space_id, t.name, t.color
FROM recurring_expense_tags ret
JOIN tags t ON ret.tag_id = t.id
WHERE ret.recurring_expense_id IN (?)
ORDER BY t.name;
`, ids)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]*model.Tag)
for _, rw := range rows {
result[rw.RecurringExpenseID] = append(result[rw.RecurringExpenseID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error) {
if len(ids) == 0 {
return make(map[string]*model.PaymentMethod), nil
}
type row struct {
RecurringExpenseID string `db:"recurring_expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Type model.PaymentMethodType `db:"type"`
LastFour *string `db:"last_four"`
}
query, args, err := sqlx.In(`
SELECT re.id AS recurring_expense_id, pm.id, pm.space_id, pm.name, pm.type, pm.last_four
FROM recurring_expenses re
JOIN payment_methods pm ON re.payment_method_id = pm.id
WHERE re.id IN (?) AND re.payment_method_id IS NOT NULL;
`, ids)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string]*model.PaymentMethod)
for _, rw := range rows {
result[rw.RecurringExpenseID] = &model.PaymentMethod{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Type: rw.Type,
LastFour: rw.LastFour,
}
}
return result, nil
}
func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
_, err = tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID)
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID)
if err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (r *recurringExpenseRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM recurring_expenses WHERE id = $1;`, id)
return err
}
func (r *recurringExpenseRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_expenses SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringExpenseRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringExpenseRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses WHERE is_active = true AND space_id = $1 AND next_occurrence <= $2;`
err := r.db.Select(&results, query, spaceID, now)
return results, err
}
func (r *recurringExpenseRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_expenses SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}
func (r *recurringExpenseRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}

View file

@ -15,7 +15,7 @@ func SetupRoutes(a *app.App) http.Handler {
home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.BudgetService, a.ReportService)
mux := http.NewServeMux()
@ -168,6 +168,57 @@ func SetupRoutes(a *app.App) http.Handler {
deleteMethodWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteMethodHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/payment-methods/{methodID}", deleteMethodWithAccess)
// Recurring expense routes
recurringPageHandler := middleware.RequireAuth(space.RecurringExpensesPage)
recurringPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(recurringPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/recurring", recurringPageWithAccess)
createRecurringHandler := middleware.RequireAuth(space.CreateRecurringExpense)
createRecurringWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createRecurringHandler)
mux.Handle("POST /app/spaces/{spaceID}/recurring", createRecurringWithAccess)
updateRecurringHandler := middleware.RequireAuth(space.UpdateRecurringExpense)
updateRecurringWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateRecurringHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/recurring/{recurringID}", updateRecurringWithAccess)
deleteRecurringHandler := middleware.RequireAuth(space.DeleteRecurringExpense)
deleteRecurringWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteRecurringHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/recurring/{recurringID}", deleteRecurringWithAccess)
toggleRecurringHandler := middleware.RequireAuth(space.ToggleRecurringExpense)
toggleRecurringWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(toggleRecurringHandler)
mux.Handle("POST /app/spaces/{spaceID}/recurring/{recurringID}/toggle", toggleRecurringWithAccess)
// Budget routes
budgetsPageHandler := middleware.RequireAuth(space.BudgetsPage)
budgetsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(budgetsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/budgets", budgetsPageWithAccess)
createBudgetHandler := middleware.RequireAuth(space.CreateBudget)
createBudgetWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createBudgetHandler)
mux.Handle("POST /app/spaces/{spaceID}/budgets", createBudgetWithAccess)
updateBudgetHandler := middleware.RequireAuth(space.UpdateBudget)
updateBudgetWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateBudgetHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/budgets/{budgetID}", updateBudgetWithAccess)
deleteBudgetHandler := middleware.RequireAuth(space.DeleteBudget)
deleteBudgetWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteBudgetHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/budgets/{budgetID}", deleteBudgetWithAccess)
budgetsListHandler := middleware.RequireAuth(space.GetBudgetsList)
budgetsListWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(budgetsListHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/budgets", budgetsListWithAccess)
// Report routes
reportsPageHandler := middleware.RequireAuth(space.ReportsPage)
reportsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(reportsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/reports", reportsPageWithAccess)
reportChartsHandler := middleware.RequireAuth(space.GetReportCharts)
reportChartsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(reportChartsHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/report-charts", reportChartsWithAccess)
// Component routes (HTMX updates)
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)

View file

@ -0,0 +1,47 @@
package scheduler
import (
"context"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/service"
)
type Scheduler struct {
recurringService *service.RecurringExpenseService
interval time.Duration
}
func New(recurringService *service.RecurringExpenseService) *Scheduler {
return &Scheduler{
recurringService: recurringService,
interval: 1 * time.Hour,
}
}
func (s *Scheduler) Start(ctx context.Context) {
// Run immediately on startup to catch up missed recurrences
s.run()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("scheduler stopping")
return
case <-ticker.C:
s.run()
}
}
}
func (s *Scheduler) run() {
slog.Info("scheduler: processing due recurring expenses")
now := time.Now()
if err := s.recurringService.ProcessDueRecurrences(now); err != nil {
slog.Error("scheduler: failed to process recurring expenses", "error", err)
}
}

169
internal/service/budget.go Normal file
View file

@ -0,0 +1,169 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateBudgetDTO struct {
SpaceID string
TagID string
Amount int
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
CreatedBy string
}
type UpdateBudgetDTO struct {
ID string
TagID string
Amount int
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
}
type BudgetService struct {
budgetRepo repository.BudgetRepository
}
func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService {
return &BudgetService{budgetRepo: budgetRepo}
}
func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("budget amount must be positive")
}
now := time.Now()
budget := &model.Budget{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
TagID: dto.TagID,
AmountCents: dto.Amount,
Period: dto.Period,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsActive: true,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.budgetRepo.Create(budget); err != nil {
return nil, err
}
return budget, nil
}
func (s *BudgetService) GetBudget(id string) (*model.Budget, error) {
return s.budgetRepo.GetByID(id)
}
func (s *BudgetService) GetBudgetsWithSpent(spaceID string, tags []*model.Tag) ([]*model.BudgetWithSpent, error) {
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
tagMap := make(map[string]*model.Tag)
for _, t := range tags {
tagMap[t.ID] = t
}
result := make([]*model.BudgetWithSpent, 0, len(budgets))
for _, b := range budgets {
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, b.TagID, start, end)
if err != nil {
spent = 0
}
var percentage float64
if b.AmountCents > 0 {
percentage = float64(spent) / float64(b.AmountCents) * 100
}
var status model.BudgetStatus
switch {
case percentage > 100:
status = model.BudgetStatusOver
case percentage >= 75:
status = model.BudgetStatusWarning
default:
status = model.BudgetStatusOnTrack
}
bws := &model.BudgetWithSpent{
Budget: *b,
SpentCents: spent,
Percentage: percentage,
Status: status,
}
if tag, ok := tagMap[b.TagID]; ok {
bws.TagName = tag.Name
bws.TagColor = tag.Color
}
result = append(result, bws)
}
return result, nil
}
func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("budget amount must be positive")
}
existing, err := s.budgetRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.TagID = dto.TagID
existing.AmountCents = dto.Amount
existing.Period = dto.Period
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.budgetRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *BudgetService) DeleteBudget(id string) error {
return s.budgetRepo.Delete(id)
}
func GetCurrentPeriodBounds(period model.BudgetPeriod, now time.Time) (time.Time, time.Time) {
switch period {
case model.BudgetPeriodWeekly:
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start := now.AddDate(0, 0, -(weekday - 1))
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 6)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
case model.BudgetPeriodYearly:
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
end := time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location())
return start, end
default: // monthly
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 1, -1)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
}
}

View file

@ -0,0 +1,265 @@
package service
import (
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateRecurringExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount int
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type UpdateRecurringExpenseDTO struct {
ID string
Description string
Amount int
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type RecurringExpenseService struct {
recurringRepo repository.RecurringExpenseRepository
expenseRepo repository.ExpenseRepository
}
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository) *RecurringExpenseService {
return &RecurringExpenseService{
recurringRepo: recurringRepo,
expenseRepo: expenseRepo,
}
}
func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
re := &model.RecurringExpense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
AmountCents: dto.Amount,
Type: dto.Type,
PaymentMethodID: dto.PaymentMethodID,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.recurringRepo.Create(re, dto.TagIDs); err != nil {
return nil, err
}
return re, nil
}
func (s *RecurringExpenseService) GetRecurringExpense(id string) (*model.RecurringExpense, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringExpenseService) GetRecurringExpensesForSpace(spaceID string) ([]*model.RecurringExpense, error) {
return s.recurringRepo.GetBySpaceID(spaceID)
}
func (s *RecurringExpenseService) GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID string) ([]*model.RecurringExpenseWithTagsAndMethod, error) {
recs, err := s.recurringRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
ids := make([]string, len(recs))
for i, re := range recs {
ids[i] = re.ID
}
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
methodsMap, err := s.recurringRepo.GetPaymentMethodsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
result := make([]*model.RecurringExpenseWithTagsAndMethod, len(recs))
for i, re := range recs {
result[i] = &model.RecurringExpenseWithTagsAndMethod{
RecurringExpense: *re,
Tags: tagsMap[re.ID],
PaymentMethod: methodsMap[re.ID],
}
}
return result, nil
}
func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.AmountCents = dto.Amount
existing.Type = dto.Type
existing.PaymentMethodID = dto.PaymentMethodID
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
// Recalculate next occurrence if frequency or start changed
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
if err := s.recurringRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringExpenseService) DeleteRecurringExpense(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringExpenseService) ToggleRecurringExpense(id string) (*model.RecurringExpense, error) {
re, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !re.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
re.IsActive = newActive
return re, nil
}
func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurrences: %w", err)
}
for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
if err != nil {
return fmt.Errorf("failed to get due recurrences for space: %w", err)
}
for _, re := range dues {
if err := s.processRecurrence(re, now); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, now time.Time) error {
// Get tag IDs for this recurring expense
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs([]string{re.ID})
if err != nil {
return err
}
var tagIDs []string
for _, t := range tagsMap[re.ID] {
tagIDs = append(tagIDs, t.ID)
}
// Generate expenses for each missed occurrence up to now
for !re.NextOccurrence.After(now) {
// Check if end_date has been passed
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
return s.recurringRepo.Deactivate(re.ID)
}
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: re.SpaceID,
CreatedBy: re.CreatedBy,
Description: re.Description,
AmountCents: re.AmountCents,
Type: re.Type,
Date: re.NextOccurrence,
PaymentMethodID: re.PaymentMethodID,
RecurringExpenseID: &re.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.expenseRepo.Create(expense, tagIDs, nil); err != nil {
return fmt.Errorf("failed to create expense from recurring: %w", err)
}
re.NextOccurrence = AdvanceDate(re.NextOccurrence, re.Frequency)
}
// Check if the new next occurrence exceeds end date
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
if err := s.recurringRepo.Deactivate(re.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
}
func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
switch freq {
case model.FrequencyDaily:
return date.AddDate(0, 0, 1)
case model.FrequencyWeekly:
return date.AddDate(0, 0, 7)
case model.FrequencyBiweekly:
return date.AddDate(0, 0, 14)
case model.FrequencyMonthly:
return date.AddDate(0, 1, 0)
case model.FrequencyYearly:
return date.AddDate(1, 0, 0)
default:
return date.AddDate(0, 1, 0)
}
}

View file

@ -0,0 +1,99 @@
package service
import (
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
type ReportService struct {
expenseRepo repository.ExpenseRepository
}
func NewReportService(expenseRepo repository.ExpenseRepository) *ReportService {
return &ReportService{expenseRepo: expenseRepo}
}
type DateRange struct {
Label string
Key string
From time.Time
To time.Time
}
func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (*model.SpendingReport, error) {
byTag, err := s.expenseRepo.GetExpensesByTag(spaceID, from, to)
if err != nil {
return nil, err
}
daily, err := s.expenseRepo.GetDailySpending(spaceID, from, to)
if err != nil {
return nil, err
}
monthly, err := s.expenseRepo.GetMonthlySpending(spaceID, from, to)
if err != nil {
return nil, err
}
topExpenses, err := s.expenseRepo.GetTopExpenses(spaceID, from, to, 10)
if err != nil {
return nil, err
}
// Get tags and payment methods for top expenses
ids := make([]string, len(topExpenses))
for i, e := range topExpenses {
ids[i] = e.ID
}
tagsMap, _ := s.expenseRepo.GetTagsByExpenseIDs(ids)
methodsMap, _ := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
topWithTags := make([]*model.ExpenseWithTagsAndMethod, len(topExpenses))
for i, e := range topExpenses {
topWithTags[i] = &model.ExpenseWithTagsAndMethod{
Expense: *e,
Tags: tagsMap[e.ID],
PaymentMethod: methodsMap[e.ID],
}
}
totalIncome, totalExpenses, err := s.expenseRepo.GetIncomeVsExpenseSummary(spaceID, from, to)
if err != nil {
return nil, err
}
return &model.SpendingReport{
ByTag: byTag,
DailySpending: daily,
MonthlySpending: monthly,
TopExpenses: topWithTags,
TotalIncome: totalIncome,
TotalExpenses: totalExpenses,
NetBalance: totalIncome - totalExpenses,
}, nil
}
func GetPresetDateRanges(now time.Time) []DateRange {
thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
thisMonthEnd := thisMonthStart.AddDate(0, 1, -1)
thisMonthEnd = time.Date(thisMonthEnd.Year(), thisMonthEnd.Month(), thisMonthEnd.Day(), 23, 59, 59, 0, now.Location())
lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
lastMonthEnd := thisMonthStart.AddDate(0, 0, -1)
lastMonthEnd = time.Date(lastMonthEnd.Year(), lastMonthEnd.Month(), lastMonthEnd.Day(), 23, 59, 59, 0, now.Location())
last3MonthsStart := thisMonthStart.AddDate(0, -2, 0)
yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
return []DateRange{
{Label: "This Month", Key: "this_month", From: thisMonthStart, To: thisMonthEnd},
{Label: "Last Month", Key: "last_month", From: lastMonthStart, To: lastMonthEnd},
{Label: "Last 3 Months", Key: "last_3_months", From: last3MonthsStart, To: thisMonthEnd},
{Label: "This Year", Key: "this_year", From: yearStart, To: thisMonthEnd},
}
}

View file

@ -0,0 +1,409 @@
package recurring
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
)
func frequencyLabel(f model.Frequency) string {
switch f {
case model.FrequencyDaily:
return "Daily"
case model.FrequencyWeekly:
return "Weekly"
case model.FrequencyBiweekly:
return "Biweekly"
case model.FrequencyMonthly:
return "Monthly"
case model.FrequencyYearly:
return "Yearly"
default:
return string(f)
}
}
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ delDialogID := "del-recurring-" + re.ID }}
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="font-medium">{ re.Description }</p>
if !re.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
{ frequencyLabel(re.Frequency) }
}
<span>Next: { re.NextOccurrence.Format("Jan 02, 2006") }</span>
if re.PaymentMethod != nil {
if re.PaymentMethod.LastFour != nil {
<span>&middot; { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
} else {
<span>&middot; { re.PaymentMethod.Name }</span>
}
}
</div>
if len(re.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range re.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<div class="flex items-center gap-1 shrink-0">
if re.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
</p>
}
// Toggle pause/resume
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7",
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/recurring/%s/toggle", spaceID, re.ID),
"hx-target": "#recurring-" + re.ID,
"hx-swap": "outerHTML",
},
}) {
if re.IsActive {
@icon.Pause(icon.Props{Size: 14})
} else {
@icon.Play(icon.Props{Size: 14})
}
}
// Edit button
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Pencil(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Edit Recurring Transaction
}
@dialog.Description() {
Update the details of this recurring transaction.
}
}
@EditRecurringForm(spaceID, re, methods)
}
}
// Delete button
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Recurring Transaction
}
@dialog.Description() {
Are you sure you want to delete "{ re.Description }"? This will not remove previously generated expenses.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID),
"hx-target": "#recurring-" + re.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.PaymentMethod, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/recurring" }
hx-target="#recurring-list"
hx-swap="beforeend"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "recurring-type-expense",
Name: "type",
Value: "expense",
Checked: true,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "recurring-type-expense"}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "recurring-type-topup",
Name: "type",
Value: "topup",
})
<div class="grid gap-2">
@label.Label(label.Props{For: "recurring-type-topup"}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "recurring-description"}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "recurring-description",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "recurring-amount"}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "recurring-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{For: "recurring-frequency"}) {
Frequency
}
<select name="frequency" id="recurring-frequency" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly" selected>Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "recurring-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "recurring-start-date",
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "recurring-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "recurring-end-date",
Name: "end_date",
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "recurring-tags"}) {
Tags
}
<datalist id="recurring-available-tags">
for _, t := range tags {
<option value={ t.Name }></option>
}
</datalist>
@tagsinput.TagsInput(tagsinput.Props{
ID: "recurring-tags",
Name: "tags",
Placeholder: "Add tags (press enter)",
Attributes: templ.Attributes{"list": "recurring-available-tags"},
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, nil)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ tagValues := make([]string, len(re.Tags)) }}
for i, t := range re.Tags {
{{ tagValues[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID) }
hx-target={ "#recurring-" + re.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-recurring-type-expense-" + re.ID,
Name: "type",
Value: "expense",
Checked: re.Type == model.ExpenseTypeExpense,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-recurring-type-expense-" + re.ID}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-recurring-type-topup-" + re.ID,
Name: "type",
Value: "topup",
Checked: re.Type == model.ExpenseTypeTopup,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-recurring-type-topup-" + re.ID}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "edit-recurring-desc-" + re.ID}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "edit-recurring-desc-" + re.ID,
Value: re.Description,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-recurring-amount-" + re.ID}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-recurring-amount-" + re.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(re.AmountCents)/100.0),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{For: "edit-recurring-freq-" + re.ID}) {
Frequency
}
<select name="frequency" id={ "edit-recurring-freq-" + re.ID } class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="daily" selected?={ re.Frequency == model.FrequencyDaily }>Daily</option>
<option value="weekly" selected?={ re.Frequency == model.FrequencyWeekly }>Weekly</option>
<option value="biweekly" selected?={ re.Frequency == model.FrequencyBiweekly }>Biweekly</option>
<option value="monthly" selected?={ re.Frequency == model.FrequencyMonthly }>Monthly</option>
<option value="yearly" selected?={ re.Frequency == model.FrequencyYearly }>Yearly</option>
</select>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-recurring-start-" + re.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-start-" + re.ID,
Name: "start_date",
Value: re.StartDate,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "edit-recurring-end-" + re.ID}) {
End Date (optional)
}
if re.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-end-" + re.ID,
Name: "end_date",
Value: *re.EndDate,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-end-" + re.ID,
Name: "end_date",
})
}
</div>
// Tags
<div>
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
Tags
}
@tagsinput.TagsInput(tagsinput.Props{
ID: "edit-recurring-tags-" + re.ID,
Name: "tags",
Value: tagValues,
Placeholder: "Add tags (press enter)",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}

View file

@ -60,6 +60,36 @@ templ Space(title string, space *model.Space) {
<span>Expenses</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/recurring",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/recurring",
Tooltip: "Recurring Transactions",
}) {
@icon.Repeat(icon.Props{Class: "size-4"})
<span>Recurring</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/budgets",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/budgets",
Tooltip: "Budgets",
}) {
@icon.Target(icon.Props{Class: "size-4"})
<span>Budgets</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/reports",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/reports",
Tooltip: "Reports",
}) {
@icon.ChartPie(icon.Props{Class: "size-4"})
<span>Reports</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/accounts",

View file

@ -0,0 +1,382 @@
package pages
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
func periodLabel(p model.BudgetPeriod) string {
switch p {
case model.BudgetPeriodWeekly:
return "Weekly"
case model.BudgetPeriodYearly:
return "Yearly"
default:
return "Monthly"
}
}
func progressBarColor(status model.BudgetStatus) string {
switch status {
case model.BudgetStatusOver:
return "bg-destructive"
case model.BudgetStatusWarning:
return "bg-yellow-500"
default:
return "bg-green-500"
}
}
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
@layouts.Space("Budgets", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Budgets</h1>
@dialog.Dialog(dialog.Props{ID: "add-budget-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Budget
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Budget
}
@dialog.Description() {
Set a spending limit for a tag category.
}
}
@AddBudgetForm(space.ID, tags)
}
}
</div>
<div id="budgets-list-wrapper">
@BudgetsList(space.ID, budgets, tags)
</div>
</div>
}
}
templ BudgetsList(spaceID string, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
<div id="budgets-list" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
if len(budgets) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No budgets set up yet.</p>
}
for _, b := range budgets {
@BudgetCard(spaceID, b, tags)
}
</div>
}
templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ delDialogID := "del-budget-" + b.ID }}
{{ pct := b.Percentage }}
if pct > 100 {
{{ pct = 100 }}
}
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2">
if b.TagColor != nil {
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *b.TagColor }></span>
}
<h3 class="font-semibold">{ b.TagName }</h3>
</div>
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
</div>
<div class="flex gap-1">
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Pencil(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Edit Budget
}
@dialog.Description() {
Update this budget's settings.
}
}
@EditBudgetForm(spaceID, b, tags)
}
}
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Budget
}
@dialog.Description() {
Are you sure you want to delete the budget for "{ b.TagName }"?
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID),
"hx-target": "#budget-" + b.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
// Progress bar
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</span>
</div>
<div class="w-full bg-muted rounded-full h-2.5">
<div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div>
if b.Status == model.BudgetStatusOver {
<p class="text-xs text-destructive font-medium">Over budget by { fmt.Sprintf("$%.2f", float64(b.SpentCents-b.AmountCents)/100.0) }</p>
}
</div>
</div>
}
templ AddBudgetForm(spaceID string, tags []*model.Tag) {
<form
hx-post={ "/app/spaces/" + spaceID + "/budgets" }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('add-budget-dialog') then reset() me end"
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "budget-tag"}) {
Tag
}
<select name="tag_id" id="budget-tag" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="" disabled selected>Select a tag...</option>
for _, t := range tags {
<option value={ t.ID }>{ t.Name }</option>
}
</select>
</div>
// Amount
<div>
@label.Label(label.Props{For: "budget-amount"}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "budget-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-monthly",
Name: "period",
Value: "monthly",
Checked: true,
})
@label.Label(label.Props{For: "budget-period-monthly"}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-weekly",
Name: "period",
Value: "weekly",
})
@label.Label(label.Props{For: "budget-period-weekly"}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-yearly",
Name: "period",
Value: "yearly",
})
@label.Label(label.Props{For: "budget-period-yearly"}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "budget-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-start-date",
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "budget-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-end-date",
Name: "end_date",
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "edit-budget-tag-" + b.ID}) {
Tag
}
<select name="tag_id" id={ "edit-budget-tag-" + b.ID } class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
for _, t := range tags {
<option value={ t.ID } selected?={ t.ID == b.TagID }>{ t.Name }</option>
}
</select>
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-budget-amount-" + b.ID}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-budget-amount-" + b.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(b.AmountCents)/100.0),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-monthly-" + b.ID,
Name: "period",
Value: "monthly",
Checked: b.Period == model.BudgetPeriodMonthly,
})
@label.Label(label.Props{For: "edit-budget-period-monthly-" + b.ID}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-weekly-" + b.ID,
Name: "period",
Value: "weekly",
Checked: b.Period == model.BudgetPeriodWeekly,
})
@label.Label(label.Props{For: "edit-budget-period-weekly-" + b.ID}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-yearly-" + b.ID,
Name: "period",
Value: "yearly",
Checked: b.Period == model.BudgetPeriodYearly,
})
@label.Label(label.Props{For: "edit-budget-period-yearly-" + b.ID}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-budget-start-" + b.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-start-" + b.ID,
Name: "start_date",
Value: b.StartDate,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date
<div>
@label.Label(label.Props{For: "edit-budget-end-" + b.ID}) {
End Date (optional)
}
if b.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
Value: *b.EndDate,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
})
}
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}

View file

@ -112,7 +112,12 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMe
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium">{ exp.Description }</p>
<div class="flex items-center gap-1.5">
<p class="font-medium">{ exp.Description }</p>
if exp.RecurringExpenseID != nil {
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
}
</div>
<p class="text-sm text-muted-foreground">
{ exp.Date.Format("Jan 02, 2006") }
if exp.PaymentMethod != nil {

View file

@ -0,0 +1,47 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithTagsAndMethod, tags []*model.Tag, methods []*model.PaymentMethod) {
@layouts.Space("Recurring", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Recurring Transactions</h1>
@dialog.Dialog(dialog.Props{ID: "add-recurring-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Recurring
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Recurring Transaction
}
@dialog.Description() {
Set up a recurring expense or top-up that will auto-generate on schedule.
}
}
@recurring.AddRecurringForm(space.ID, tags, methods, "add-recurring-dialog")
}
}
</div>
<div class="border rounded-lg">
<div id="recurring-list" class="divide-y">
if len(recs) == 0 {
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
}
for _, re := range recs {
@recurring.RecurringItem(space.ID, re, methods)
}
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,200 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
var defaultChartColors = []string{
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
}
func chartColor(i int, tagColor *string) string {
if tagColor != nil && *tagColor != "" {
return *tagColor
}
return defaultChartColors[i%len(defaultChartColors)]
}
templ SpaceReportsPage(space *model.Space, report *model.SpendingReport, presets []service.DateRange, activeRange string) {
@layouts.Space("Reports", space) {
@chart.Script()
<div class="space-y-4">
<h1 class="text-2xl font-bold">Reports</h1>
// Date range selector
<div class="flex flex-wrap gap-2 items-center">
for _, p := range presets {
if p.Key == activeRange {
@button.Button(button.Props{
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", space.ID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
} else {
@button.Button(button.Props{
Variant: button.VariantOutline,
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", space.ID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
}
}
</div>
<div id="report-content">
@ReportCharts(space.ID, report, presets[0].From, presets[0].To)
</div>
</div>
}
}
templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.Time) {
<div class="grid gap-4 md:grid-cols-2 overflow-hidden">
// Income vs Expenses Summary
<div class="border rounded-lg p-4 bg-card text-card-foreground space-y-2 min-w-0">
<h3 class="font-semibold">Income vs Expenses</h3>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-green-500 font-medium">Income</span>
<span class="font-bold text-green-500">{ fmt.Sprintf("$%.2f", float64(report.TotalIncome)/100.0) }</span>
</div>
<div class="flex justify-between">
<span class="text-destructive font-medium">Expenses</span>
<span class="font-bold text-destructive">{ fmt.Sprintf("$%.2f", float64(report.TotalExpenses)/100.0) }</span>
</div>
<hr class="border-border"/>
<div class="flex justify-between">
<span class="font-medium">Net</span>
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance >= 0), templ.KV("text-destructive", report.NetBalance < 0) }>
{ fmt.Sprintf("$%.2f", float64(report.NetBalance)/100.0) }
</span>
</div>
</div>
</div>
// Spending by Tag (Doughnut chart)
if len(report.ByTag) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Category</h3>
{{
tagLabels := make([]string, len(report.ByTag))
tagData := make([]float64, len(report.ByTag))
tagColors := make([]string, len(report.ByTag))
for i, t := range report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = float64(t.TotalAmount) / 100.0
tagColors[i] = chartColor(i, &t.TagColor)
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: tagLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: tagData,
BackgroundColor: tagColors,
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending by Category</h3>
<p class="text-sm text-muted-foreground">No tagged expenses in this period.</p>
</div>
}
// Spending Over Time (Bar chart)
if len(report.DailySpending) > 0 || len(report.MonthlySpending) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
{{
days := to.Sub(from).Hours() / 24
var timeLabels []string
var timeData []float64
if days <= 31 {
for _, d := range report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, float64(d.TotalCents)/100.0)
}
} else {
for _, m := range report.MonthlySpending {
timeLabels = append(timeLabels, m.Month)
timeData = append(timeData, float64(m.TotalCents)/100.0)
}
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantBar,
ShowYAxis: true,
ShowXAxis: true,
ShowXLabels: true,
ShowYLabels: true,
Data: chart.Data{
Labels: timeLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: timeData,
BackgroundColor: "#3b82f6",
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
</div>
}
// Top 10 Expenses
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Top Expenses</h3>
if len(report.TopExpenses) == 0 {
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
} else {
<div class="divide-y">
for _, exp := range report.TopExpenses {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium text-sm truncate">{ exp.Description }</p>
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-0.5">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<p class="font-bold text-destructive text-sm shrink-0">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p>
</div>
}
</div>
}
</div>
</div>
}