feat: loans

This commit is contained in:
juancwu 2026-03-14 11:34:21 -04:00
commit ac7296b06e
No known key found for this signature in database
20 changed files with 3191 additions and 4 deletions

View file

@ -36,9 +36,12 @@ type SpaceHandler struct {
recurringDepositService *service.RecurringDepositService
budgetService *service.BudgetService
reportService *service.ReportService
loanService *service.LoanService
receiptService *service.ReceiptService
recurringReceiptService *service.RecurringReceiptService
}
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, rds *service.RecurringDepositService, bs *service.BudgetService, rps *service.ReportService) *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, rds *service.RecurringDepositService, bs *service.BudgetService, rps *service.ReportService, ls *service.LoanService, rcs *service.ReceiptService, rrs *service.RecurringReceiptService) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
tagService: ts,
@ -51,6 +54,9 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
recurringDepositService: rds,
budgetService: bs,
reportService: rps,
loanService: ls,
receiptService: rcs,
recurringReceiptService: rrs,
}
}

View file

@ -0,0 +1,592 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/shopspring/decimal"
"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/pages"
)
func (h *SpaceHandler) LoansPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to get space", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
loans, totalPages, err := h.loanService.GetLoansWithSummaryForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get loans", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceLoansPage(space, loans, page, totalPages))
}
func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
interestStr := r.FormValue("interest_rate")
var interestBps int
if interestStr != "" {
interestRate, err := decimal.NewFromString(interestStr)
if err == nil {
interestBps = int(interestRate.Mul(decimal.NewFromInt(100)).IntPart())
}
}
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
dto := service.CreateLoanDTO{
SpaceID: spaceID,
UserID: user.ID,
Name: name,
Description: description,
OriginalAmount: amountCents,
InterestRateBps: interestBps,
StartDate: startDate,
EndDate: endDate,
}
_, err = h.loanService.CreateLoan(dto)
if err != nil {
slog.Error("failed to create loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Return updated loans list
loans, totalPages, err := h.loanService.GetLoansWithSummaryForSpacePaginated(spaceID, 1)
if err != nil {
slog.Error("failed to get loans after create", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.LoansListContent(spaceID, loans, 1, totalPages))
}
func (h *SpaceHandler) LoanDetailPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to get space", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
loan, err := h.loanService.GetLoanWithSummary(loanID)
if err != nil {
slog.Error("failed to get loan", "error", err)
http.Error(w, "Not Found", http.StatusNotFound)
return
}
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
receipts, totalPages, err := h.receiptService.GetReceiptsForLoanPaginated(loanID, page)
if err != nil {
slog.Error("failed to get receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
recurringReceipts, err := h.recurringReceiptService.GetRecurringReceiptsWithSourcesForLoan(loanID)
if err != nil {
slog.Error("failed to get recurring receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
accounts, err := h.accountService.GetAccountsForSpace(spaceID)
if err != nil {
slog.Error("failed to get accounts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err)
balance = 0
}
ui.Render(w, r, pages.SpaceLoanDetailPage(space, loan, receipts, page, totalPages, recurringReceipts, accounts, balance))
}
func (h *SpaceHandler) UpdateLoan(w http.ResponseWriter, r *http.Request) {
loanID := r.PathValue("loanID")
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
interestStr := r.FormValue("interest_rate")
var interestBps int
if interestStr != "" {
interestRate, err := decimal.NewFromString(interestStr)
if err == nil {
interestBps = int(interestRate.Mul(decimal.NewFromInt(100)).IntPart())
}
}
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
dto := service.UpdateLoanDTO{
ID: loanID,
Name: name,
Description: description,
OriginalAmount: amountCents,
InterestRateBps: interestBps,
StartDate: startDate,
EndDate: endDate,
}
_, err = h.loanService.UpdateLoan(dto)
if err != nil {
slog.Error("failed to update loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Redirect to loan detail
spaceID := r.PathValue("spaceID")
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteLoan(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
if err := h.loanService.DeleteLoan(loanID); err != nil {
slog.Error("failed to delete loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans", spaceID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
date = time.Now()
}
// Parse funding sources from parallel arrays
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.CreateReceiptDTO{
LoanID: loanID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: totalAmountCents,
Date: date,
FundingSources: fundingSources,
}
_, err = h.receiptService.CreateReceipt(dto)
if err != nil {
slog.Error("failed to create receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
// Return updated loan detail
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
receiptID := r.PathValue("receiptID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
date = time.Now()
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.UpdateReceiptDTO{
ID: receiptID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: totalAmountCents,
Date: date,
FundingSources: fundingSources,
}
_, err = h.receiptService.UpdateReceipt(dto)
if err != nil {
slog.Error("failed to update receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
receiptID := r.PathValue("receiptID")
if err := h.receiptService.DeleteReceipt(receiptID, spaceID); err != nil {
slog.Error("failed to delete receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) GetReceiptsList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
receipts, totalPages, err := h.receiptService.GetReceiptsForLoanPaginated(loanID, page)
if err != nil {
slog.Error("failed to get receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ReceiptsListContent(spaceID, loanID, receipts, page, totalPages))
}
func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.CreateRecurringReceiptDTO{
LoanID: loanID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: totalAmountCents,
Frequency: frequency,
StartDate: startDate,
EndDate: endDate,
FundingSources: fundingSources,
}
_, err = h.recurringReceiptService.CreateRecurringReceipt(dto)
if err != nil {
slog.Error("failed to create recurring receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.UpdateRecurringReceiptDTO{
ID: recurringReceiptID,
Description: description,
TotalAmount: totalAmountCents,
Frequency: frequency,
StartDate: startDate,
EndDate: endDate,
FundingSources: fundingSources,
}
_, err = h.recurringReceiptService.UpdateRecurringReceipt(dto)
if err != nil {
slog.Error("failed to update recurring receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
if err := h.recurringReceiptService.DeleteRecurringReceipt(recurringReceiptID); err != nil {
slog.Error("failed to delete recurring receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) ToggleRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
_, err := h.recurringReceiptService.ToggleRecurringReceipt(recurringReceiptID)
if err != nil {
slog.Error("failed to toggle recurring receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
// parseFundingSources parses funding sources from parallel form arrays:
// source_type[], source_amount[], source_account_id[]
func parseFundingSources(r *http.Request) ([]service.FundingSourceDTO, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
sourceTypes := r.Form["source_type"]
sourceAmounts := r.Form["source_amount"]
sourceAccountIDs := r.Form["source_account_id"]
if len(sourceTypes) == 0 {
return nil, fmt.Errorf("no funding sources provided")
}
if len(sourceTypes) != len(sourceAmounts) {
return nil, fmt.Errorf("mismatched funding source fields")
}
var sources []service.FundingSourceDTO
for i, srcType := range sourceTypes {
amount, err := decimal.NewFromString(sourceAmounts[i])
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("invalid funding source amount")
}
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
src := service.FundingSourceDTO{
SourceType: model.FundingSourceType(srcType),
Amount: amountCents,
}
if srcType == string(model.FundingSourceAccount) {
if i < len(sourceAccountIDs) && sourceAccountIDs[i] != "" {
src.AccountID = sourceAccountIDs[i]
} else {
return nil, fmt.Errorf("account source requires account_id")
}
}
sources = append(sources, src)
}
return sources, nil
}

View file

@ -27,8 +27,14 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
recurringDepositRepo := repository.NewRecurringDepositRepository(dbi.DB)
budgetRepo := repository.NewBudgetRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB)
loanRepo := repository.NewLoanRepository(dbi.DB)
receiptRepo := repository.NewReceiptRepository(dbi.DB)
recurringReceiptRepo := repository.NewRecurringReceiptRepository(dbi.DB)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
expenseSvc := service.NewExpenseService(expenseRepo)
loanSvc := service.NewLoanService(loanRepo, receiptRepo)
receiptSvc := service.NewReceiptService(receiptRepo, loanRepo, accountRepo)
recurringReceiptSvc := service.NewRecurringReceiptService(recurringReceiptRepo, receiptSvc, loanRepo, profileRepo, spaceRepo)
return NewSpaceHandler(
service.NewSpaceService(spaceRepo),
service.NewTagService(tagRepo),
@ -41,6 +47,9 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
service.NewRecurringDepositService(recurringDepositRepo, accountRepo, expenseSvc, profileRepo, spaceRepo),
service.NewBudgetService(budgetRepo),
service.NewReportService(expenseRepo),
loanSvc,
receiptSvc,
recurringReceiptSvc,
)
}