add expenses management

This commit is contained in:
juancwu 2026-01-14 20:40:36 +00:00
commit f8ddf152e4
16 changed files with 611 additions and 29 deletions

View file

@ -11,15 +11,16 @@ import (
)
type App struct {
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
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
}
func New(cfg *config.Config) (*App, error) {
@ -43,6 +44,7 @@ func New(cfg *config.Config) (*App, error) {
tagRepository := repository.NewTagRepository(database)
shoppingListRepository := repository.NewShoppingListRepository(database)
listItemRepository := repository.NewListItemRepository(database)
expenseRepository := repository.NewExpenseRepository(database)
// Services
userService := service.NewUserService(userRepository)
@ -68,6 +70,7 @@ func New(cfg *config.Config) (*App, error) {
profileService := service.NewProfileService(profileRepository)
tagService := service.NewTagService(tagRepository)
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
expenseService := service.NewExpenseService(expenseRepository)
return &App{
Cfg: cfg,
@ -79,9 +82,9 @@ func New(cfg *config.Config) (*App, error) {
SpaceService: spaceService,
TagService: tagService,
ShoppingListService: shoppingListService,
ExpenseService: expenseService,
}, nil
}
func (a *App) Close() error {
if a.DB != nil {
return a.DB.Close()

View file

@ -0,0 +1,42 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS 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')),
date TIMESTAMP NOT NULL,
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
);
CREATE TABLE IF NOT EXISTS expense_tags (
expense_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (expense_id, tag_id),
FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS expense_items (
expense_id TEXT NOT NULL,
item_id TEXT NOT NULL,
PRIMARY KEY (expense_id, item_id),
FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES list_items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_expenses_space_id ON expenses(space_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_expenses_space_id;
DROP TABLE IF EXISTS expense_items;
DROP TABLE IF EXISTS expense_tags;
DROP TABLE IF EXISTS expenses;
-- +goose StatementEnd

View file

@ -3,8 +3,11 @@ package handler
import (
"log/slog"
"net/http"
"strconv"
"time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
@ -13,16 +16,18 @@ import (
)
type SpaceHandler struct {
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
spaceService *service.SpaceService
tagService *service.TagService
listService *service.ShoppingListService
expenseService *service.ExpenseService
}
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService) *SpaceHandler {
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
tagService: ts,
listService: sls,
spaceService: ss,
tagService: ts,
listService: sls,
expenseService: es,
}
}
@ -240,3 +245,111 @@ func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) ExpensesPage(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
}
expenses, err := h.expenseService.GetExpensesForSpace(spaceID)
if err != nil {
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance for space", "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 for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
slog.Error("failed to get lists for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, lists))
}
func (h *SpaceHandler) CreateExpense(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
}
// --- Form Parsing ---
description := r.FormValue("description")
amountStr := r.FormValue("amount")
typeStr := r.FormValue("type")
dateStr := r.FormValue("date")
tagIDs := r.Form["tags"] // For multi-select
// --- Validation & Conversion ---
if description == "" || amountStr == "" || typeStr == "" || dateStr == "" {
http.Error(w, "All fields are required.", 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)
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
http.Error(w, "Invalid date format.", http.StatusBadRequest)
return
}
expenseType := model.ExpenseType(typeStr)
if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup {
http.Error(w, "Invalid transaction type.", http.StatusBadRequest)
return
}
// --- DTO Creation & Service Call ---
dto := service.CreateExpenseDTO{
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amountCents,
Type: expenseType,
Date: date,
TagIDs: tagIDs,
ItemIDs: []string{}, // TODO: Add item IDs from form
}
newExpense, err := h.expenseService.CreateExpense(dto)
if err != nil {
slog.Error("failed to create expense", "error", err)
http.Error(w, "Failed to create expense.", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
// Fallback: return just the item if balance fails, but ideally we want both.
// For now we will just log and continue, potentially showing stale balance.
}
ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance))
}

39
internal/model/expense.go Normal file
View file

@ -0,0 +1,39 @@
package model
import "time"
type ExpenseType string
const (
ExpenseTypeExpense ExpenseType = "expense"
ExpenseTypeTopup ExpenseType = "topup"
)
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"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type ExpenseTag struct {
ExpenseID string `db:"expense_id"`
TagID string `db:"tag_id"`
}
type ExpenseItem struct {
ExpenseID string `db:"expense_id"`
ItemID string `db:"item_id"`
}
type TagExpenseSummary struct {
TagID string `db:"tag_id"`
TagName string `db:"tag_name"`
TagColor string `db:"tag_color"`
TotalAmount int `db:"total_amount"`
}

View file

@ -9,8 +9,8 @@ const (
)
type File struct {
ID string `db:"id"`
UserID string `db:"user_id"` // Who owns/created this file
ID string `db:"id"`
UserID string `db:"user_id"` // Who owns/created this file
OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file
OwnerID string `db:"owner_id"` // Polymorphic FK
Type string `db:"type"`

View file

@ -3,8 +3,8 @@ package model
import "time"
type Profile struct {
ID string `db:"id"`
UserID string `db:"user_id"`
ID string `db:"id"`
UserID string `db:"user_id"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`

View file

@ -11,11 +11,11 @@ type ShoppingList struct {
}
type ListItem struct {
ID string `db:"id"`
ListID string `db:"list_id"`
Name string `db:"name"`
IsChecked bool `db:"is_checked"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID string `db:"id"`
ListID string `db:"list_id"`
Name string `db:"name"`
IsChecked bool `db:"is_checked"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}

View file

@ -5,8 +5,8 @@ import (
)
type Token struct {
ID string `db:"id"`
UserID string `db:"user_id"`
ID string `db:"id"`
UserID string `db:"user_id"`
Type string `db:"type"` // "email_verify" or "password_reset"
Token string `db:"token"`
ExpiresAt time.Time `db:"expires_at"`

View file

@ -3,7 +3,7 @@ package model
import "time"
type User struct {
ID string `db:"id"`
ID string `db:"id"`
Email string `db:"email"`
// Allow null for passwordless users
PasswordHash *string `db:"password_hash"`

View file

@ -0,0 +1,111 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrExpenseNotFound = errors.New("expense not found")
)
type ExpenseRepository interface {
Create(expense *model.Expense, tagIDs []string, itemIDs []string) error
GetByID(id string) (*model.Expense, error)
GetBySpaceID(spaceID string) ([]*model.Expense, error)
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
}
type expenseRepository struct {
db *sqlx.DB
}
func NewExpenseRepository(db *sqlx.DB) ExpenseRepository {
return &expenseRepository{db: db}
}
func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Insert Expense
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);`
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.CreatedAt, expense.UpdatedAt)
if err != nil {
return err
}
// Insert Tags
if len(tagIDs) > 0 {
queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
_, err := tx.Exec(queryTags, expense.ID, tagID)
if err != nil {
return err
}
}
}
// Insert Items
if len(itemIDs) > 0 {
queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);`
for _, itemID := range itemIDs {
_, err := tx.Exec(queryItems, expense.ID, itemID)
if err != nil {
return err
}
}
}
return tx.Commit()
}
func (r *expenseRepository) GetByID(id string) (*model.Expense, error) {
expense := &model.Expense{}
query := `SELECT * FROM expenses WHERE id = $1;`
err := r.db.Get(expense, query, id)
if err == sql.ErrNoRows {
return nil, ErrExpenseNotFound
}
return expense, err
}
func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, error) {
var expenses []*model.Expense
query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC;`
err := r.db.Select(&expenses, query, spaceID)
if err != nil {
return nil, err
}
return expenses, nil
}
func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
var summaries []*model.TagExpenseSummary
query := `
SELECT
t.id as tag_id,
t.name as tag_name,
t.color as tag_color,
SUM(e.amount_cents) as total_amount
FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id
JOIN tags t ON et.tag_id = t.id
WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3
GROUP BY t.id, t.name, t.color
ORDER BY total_amount DESC;
`
err := r.db.Select(&summaries, query, spaceID, fromDate, toDate)
if err != nil {
return nil, err
}
return summaries, nil
}

View file

@ -11,8 +11,8 @@ import (
)
var (
ErrTagNotFound = errors.New("tag not found")
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
ErrTagNotFound = errors.New("tag not found")
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
)
type TagRepository interface {

View file

@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService)
home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler()
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService)
mux := http.NewServeMux()
@ -94,6 +94,15 @@ func SetupRoutes(a *app.App) http.Handler {
deleteTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTagHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/tags/{tagID}", deleteTagWithAccess)
// Expense routes
expensesPageHandler := middleware.RequireAuth(space.ExpensesPage)
expensesPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(expensesPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/expenses", expensesPageWithAccess)
createExpenseHandler := middleware.RequireAuth(space.CreateExpense)
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
// 404
mux.HandleFunc("/{path...}", home.NotFoundPage)

View file

@ -0,0 +1,84 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount int
Type model.ExpenseType
Date time.Time
TagIDs []string
ItemIDs []string
}
type ExpenseService struct {
expenseRepo repository.ExpenseRepository
}
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
return &ExpenseService{expenseRepo: expenseRepo}
}
func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty")
}
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
AmountCents: dto.Amount,
Type: dto.Type,
Date: dto.Date,
CreatedAt: now,
UpdatedAt: now,
}
err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs)
if err != nil {
return nil, err
}
return expense, nil
}
func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense, error) {
return s.expenseRepo.GetBySpaceID(spaceID)
}
func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) {
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil {
return 0, err
}
var balance int
for _, expense := range expenses {
if expense.Type == model.ExpenseTypeExpense {
balance -= expense.AmountCents
} else if expense.Type == model.ExpenseTypeTopup {
balance += expense.AmountCents
}
}
return balance, nil
}
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
}

View file

@ -0,0 +1,94 @@
package expense
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/input"
"time"
)
templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.ShoppingList) {
<form
hx-post={ "/app/spaces/" + space.ID + "/expenses" }
hx-target="#expenses-list"
hx-swap="afterbegin"
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<label>
<input type="radio" name="type" value="expense" checked class="radio"/>
<span class="label-text ml-2">Expense</span>
</label>
<label>
<input type="radio" name="type" value="topup" class="radio"/>
<span class="label-text ml-2">Top-up</span>
</label>
</div>
// Description
<div>
<label for="description" class="label">Description</label>
@input.Input(input.Props{
Name: "description",
ID: "description",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
<label for="amount" class="label">Amount</label>
@input.Input(input.Props{
Name: "amount",
ID: "amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Date
<div>
<label for="date" class="label">Date</label>
@input.Input(input.Props{
Name: "date",
ID: "date",
Type: "date",
Value: time.Now().Format("2006-01-02"),
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Tags
if len(tags) > 0 {
<div>
<label class="label">Tags</label>
<select name="tags" multiple class="select select-bordered w-full h-32">
for _, tag := range tags {
<option value={ tag.ID }>{ tag.Name }</option>
}
</select>
</div>
}
// TODO: Shopping list items selector
<div class="flex justify-end">
@button.Button(button.Props{ Type: button.TypeSubmit }) {
Save Transaction
}
</div>
</form>
}
templ BalanceCard(balance int, oob bool) {
<div id="balance-card" class="border rounded-lg p-4 bg-card text-card-foreground" hx-swap-oob?={ oob }>
<h2 class="text-lg font-semibold">Current Balance</h2>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }
</p>
</div>
}

View file

@ -70,6 +70,16 @@ templ Space(title string, space *model.Space) {
<span>Tags</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/expenses",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/expenses",
Tooltip: "Expenses",
}) {
@icon.Landmark(icon.Props{Class: "size-4"})
<span>Expenses</span>
}
}
}
}
}

View file

@ -0,0 +1,77 @@
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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, lists []*model.ShoppingList) {
@layouts.Space("Expenses", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Expenses</h1>
@dialog.Dialog(dialog.Props{ ID: "add-expense-dialog" }) {
@dialog.Trigger() {
@button.Button() {
Add Expense
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() { Add Transaction }
@dialog.Description() {
Add a new expense or top-up to your space.
}
}
@expense.AddExpenseForm(space, tags, lists)
}
}
</div>
// Balance Card
@expense.BalanceCard(balance, false)
// List of expenses
<div id="expenses-list" class="border rounded-lg">
<h2 class="text-lg font-semibold p-4">History</h2>
<div class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, expense := range expenses {
@ExpenseListItem(expense)
}
</div>
</div>
</div>
}
}
templ ExpenseListItem(expense *model.Expense) {
<div class="p-4 flex justify-between items-center">
<div>
<p class="font-medium">{ expense.Description }</p>
<p class="text-sm text-muted-foreground">{ expense.Date.Format("Jan 02, 2006") }</p>
</div>
<div>
if expense.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
</p>
}
</div>
</div>
}
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
@ExpenseListItem(newExpense)
@expense.BalanceCard(balance, true)
}