feat: recurring expenses and reports
This commit is contained in:
parent
cda4f61939
commit
9e6ff67a87
23 changed files with 2943 additions and 56 deletions
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
24
internal/db/migrations/00012_create_budgets_table.sql
Normal file
24
internal/db/migrations/00012_create_budgets_table.sql
Normal 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;
|
||||
|
|
@ -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
42
internal/model/budget.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
41
internal/model/recurring_expense.go
Normal file
41
internal/model/recurring_expense.go
Normal 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
23
internal/model/report.go
Normal 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
|
||||
}
|
||||
77
internal/repository/budget.go
Normal file
77
internal/repository/budget.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
228
internal/repository/recurring_expense.go
Normal file
228
internal/repository/recurring_expense.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
47
internal/scheduler/scheduler.go
Normal file
47
internal/scheduler/scheduler.go
Normal 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
169
internal/service/budget.go
Normal 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
|
||||
}
|
||||
}
|
||||
265
internal/service/recurring_expense.go
Normal file
265
internal/service/recurring_expense.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
internal/service/report.go
Normal file
99
internal/service/report.go
Normal 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},
|
||||
}
|
||||
}
|
||||
409
internal/ui/components/recurring/recurring.templ
Normal file
409
internal/ui/components/recurring/recurring.templ
Normal 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>· { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
|
||||
} else {
|
||||
<span>· { 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>
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
382
internal/ui/pages/app_space_budgets.templ
Normal file
382
internal/ui/pages/app_space_budgets.templ
Normal 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>
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
47
internal/ui/pages/app_space_recurring.templ
Normal file
47
internal/ui/pages/app_space_recurring.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
200
internal/ui/pages/app_space_reports.templ
Normal file
200
internal/ui/pages/app_space_reports.templ
Normal 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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue