add expenses management
This commit is contained in:
parent
b7905ddded
commit
f8ddf152e4
16 changed files with 611 additions and 29 deletions
|
|
@ -11,15 +11,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
UserService *service.UserService
|
UserService *service.UserService
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
ProfileService *service.ProfileService
|
ProfileService *service.ProfileService
|
||||||
SpaceService *service.SpaceService
|
SpaceService *service.SpaceService
|
||||||
TagService *service.TagService
|
TagService *service.TagService
|
||||||
ShoppingListService *service.ShoppingListService
|
ShoppingListService *service.ShoppingListService
|
||||||
|
ExpenseService *service.ExpenseService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -43,6 +44,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
tagRepository := repository.NewTagRepository(database)
|
tagRepository := repository.NewTagRepository(database)
|
||||||
shoppingListRepository := repository.NewShoppingListRepository(database)
|
shoppingListRepository := repository.NewShoppingListRepository(database)
|
||||||
listItemRepository := repository.NewListItemRepository(database)
|
listItemRepository := repository.NewListItemRepository(database)
|
||||||
|
expenseRepository := repository.NewExpenseRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
|
@ -68,6 +70,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
profileService := service.NewProfileService(profileRepository)
|
profileService := service.NewProfileService(profileRepository)
|
||||||
tagService := service.NewTagService(tagRepository)
|
tagService := service.NewTagService(tagRepository)
|
||||||
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
||||||
|
expenseService := service.NewExpenseService(expenseRepository)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -79,9 +82,9 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
SpaceService: spaceService,
|
SpaceService: spaceService,
|
||||||
TagService: tagService,
|
TagService: tagService,
|
||||||
ShoppingListService: shoppingListService,
|
ShoppingListService: shoppingListService,
|
||||||
|
ExpenseService: expenseService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
if a.DB != nil {
|
if a.DB != nil {
|
||||||
return a.DB.Close()
|
return a.DB.Close()
|
||||||
|
|
|
||||||
42
internal/db/migrations/00007_create_expenses_tables.sql
Normal file
42
internal/db/migrations/00007_create_expenses_tables.sql
Normal 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
|
||||||
|
|
@ -3,8 +3,11 @@ package handler
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"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/service"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
||||||
|
|
@ -13,16 +16,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpaceHandler struct {
|
type SpaceHandler struct {
|
||||||
spaceService *service.SpaceService
|
spaceService *service.SpaceService
|
||||||
tagService *service.TagService
|
tagService *service.TagService
|
||||||
listService *service.ShoppingListService
|
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{
|
return &SpaceHandler{
|
||||||
spaceService: ss,
|
spaceService: ss,
|
||||||
tagService: ts,
|
tagService: ts,
|
||||||
listService: sls,
|
listService: sls,
|
||||||
|
expenseService: es,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +78,7 @@ func (h *SpaceHandler) ListsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
|
@ -240,3 +245,111 @@ func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
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
39
internal/model/expense.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
UserID string `db:"user_id"` // Who owns/created this file
|
UserID string `db:"user_id"` // Who owns/created this file
|
||||||
OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file
|
OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file
|
||||||
OwnerID string `db:"owner_id"` // Polymorphic FK
|
OwnerID string `db:"owner_id"` // Polymorphic FK
|
||||||
Type string `db:"type"`
|
Type string `db:"type"`
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package model
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
UserID string `db:"user_id"`
|
UserID string `db:"user_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ type ShoppingList struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListItem struct {
|
type ListItem struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
ListID string `db:"list_id"`
|
ListID string `db:"list_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
IsChecked bool `db:"is_checked"`
|
IsChecked bool `db:"is_checked"`
|
||||||
CreatedBy string `db:"created_by"`
|
CreatedBy string `db:"created_by"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Token struct {
|
type Token struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
UserID string `db:"user_id"`
|
UserID string `db:"user_id"`
|
||||||
Type string `db:"type"` // "email_verify" or "password_reset"
|
Type string `db:"type"` // "email_verify" or "password_reset"
|
||||||
Token string `db:"token"`
|
Token string `db:"token"`
|
||||||
ExpiresAt time.Time `db:"expires_at"`
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package model
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
Email string `db:"email"`
|
Email string `db:"email"`
|
||||||
// Allow null for passwordless users
|
// Allow null for passwordless users
|
||||||
PasswordHash *string `db:"password_hash"`
|
PasswordHash *string `db:"password_hash"`
|
||||||
|
|
|
||||||
111
internal/repository/expense.go
Normal file
111
internal/repository/expense.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrTagNotFound = errors.New("tag not found")
|
ErrTagNotFound = errors.New("tag not found")
|
||||||
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
|
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
|
||||||
)
|
)
|
||||||
|
|
||||||
type TagRepository interface {
|
type TagRepository interface {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
auth := handler.NewAuthHandler(a.AuthService)
|
auth := handler.NewAuthHandler(a.AuthService)
|
||||||
home := handler.NewHomeHandler()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler()
|
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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -94,6 +94,15 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
deleteTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTagHandler)
|
deleteTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTagHandler)
|
||||||
mux.Handle("DELETE /app/spaces/{spaceID}/tags/{tagID}", deleteTagWithAccess)
|
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
|
// 404
|
||||||
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
mux.HandleFunc("/{path...}", home.NotFoundPage)
|
||||||
|
|
||||||
|
|
|
||||||
84
internal/service/expense.go
Normal file
84
internal/service/expense.go
Normal 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)
|
||||||
|
}
|
||||||
94
internal/ui/components/expense/expense.templ
Normal file
94
internal/ui/components/expense/expense.templ
Normal 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>
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,16 @@ templ Space(title string, space *model.Space) {
|
||||||
<span>Tags</span>
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
internal/ui/pages/app_space_expenses.templ
Normal file
77
internal/ui/pages/app_space_expenses.templ
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue