Merge branch 'feat/receipts'

This commit is contained in:
juancwu 2026-03-14 11:44:44 -04:00
commit e1ad197624
No known key found for this signature in database
20 changed files with 3195 additions and 8 deletions

View file

@ -39,7 +39,7 @@ func main() {
// Start recurring expense scheduler
schedulerCtx, schedulerCancel := context.WithCancel(context.Background())
defer schedulerCancel()
sched := scheduler.New(a.RecurringExpenseService)
sched := scheduler.New(a.RecurringExpenseService, a.RecurringReceiptService)
go sched.Start(schedulerCtx)
// Health check bypasses all middleware

View file

@ -28,6 +28,9 @@ type App struct {
RecurringDepositService *service.RecurringDepositService
BudgetService *service.BudgetService
ReportService *service.ReportService
LoanService *service.LoanService
ReceiptService *service.ReceiptService
RecurringReceiptService *service.RecurringReceiptService
}
func New(cfg *config.Config) (*App, error) {
@ -58,6 +61,9 @@ func New(cfg *config.Config) (*App, error) {
recurringExpenseRepository := repository.NewRecurringExpenseRepository(database)
recurringDepositRepository := repository.NewRecurringDepositRepository(database)
budgetRepository := repository.NewBudgetRepository(database)
loanRepository := repository.NewLoanRepository(database)
receiptRepository := repository.NewReceiptRepository(database)
recurringReceiptRepository := repository.NewRecurringReceiptRepository(database)
// Services
userService := service.NewUserService(userRepository)
@ -91,6 +97,9 @@ func New(cfg *config.Config) (*App, error) {
recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository, spaceRepository)
budgetService := service.NewBudgetService(budgetRepository)
reportService := service.NewReportService(expenseRepository)
loanService := service.NewLoanService(loanRepository, receiptRepository)
receiptService := service.NewReceiptService(receiptRepository, loanRepository, moneyAccountRepository)
recurringReceiptService := service.NewRecurringReceiptService(recurringReceiptRepository, receiptService, loanRepository, profileRepository, spaceRepository)
return &App{
Cfg: cfg,
@ -110,6 +119,9 @@ func New(cfg *config.Config) (*App, error) {
RecurringDepositService: recurringDepositService,
BudgetService: budgetService,
ReportService: reportService,
LoanService: loanService,
ReceiptService: receiptService,
RecurringReceiptService: recurringReceiptService,
}, nil
}
func (a *App) Close() error {

View file

@ -0,0 +1,110 @@
-- +goose Up
CREATE TABLE loans (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
original_amount_cents INTEGER NOT NULL,
interest_rate_bps INTEGER NOT NULL DEFAULT 0,
start_date DATE NOT NULL,
end_date DATE,
is_paid_off BOOLEAN NOT NULL DEFAULT FALSE,
created_by TEXT 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 INDEX idx_loans_space_id ON loans(space_id);
CREATE TABLE recurring_receipts (
id TEXT PRIMARY KEY NOT NULL,
loan_id TEXT NOT NULL,
space_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
total_amount_cents INTEGER NOT NULL,
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_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (loan_id) REFERENCES loans(id) ON DELETE CASCADE,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_recurring_receipts_space_id ON recurring_receipts(space_id);
CREATE INDEX idx_recurring_receipts_loan_id ON recurring_receipts(loan_id);
CREATE INDEX idx_recurring_receipts_next_occurrence ON recurring_receipts(next_occurrence);
CREATE INDEX idx_recurring_receipts_active ON recurring_receipts(is_active);
CREATE TABLE recurring_receipt_sources (
id TEXT PRIMARY KEY NOT NULL,
recurring_receipt_id TEXT NOT NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('balance', 'account')),
account_id TEXT,
amount_cents INTEGER NOT NULL,
FOREIGN KEY (recurring_receipt_id) REFERENCES recurring_receipts(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES money_accounts(id) ON DELETE SET NULL
);
CREATE INDEX idx_recurring_receipt_sources_recurring_receipt_id ON recurring_receipt_sources(recurring_receipt_id);
CREATE TABLE receipts (
id TEXT PRIMARY KEY NOT NULL,
loan_id TEXT NOT NULL,
space_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
total_amount_cents INTEGER NOT NULL,
date DATE NOT NULL,
recurring_receipt_id TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (loan_id) REFERENCES loans(id) ON DELETE CASCADE,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY (recurring_receipt_id) REFERENCES recurring_receipts(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_receipts_loan_id ON receipts(loan_id);
CREATE INDEX idx_receipts_space_id ON receipts(space_id);
CREATE INDEX idx_receipts_recurring_receipt_id ON receipts(recurring_receipt_id);
CREATE TABLE receipt_funding_sources (
id TEXT PRIMARY KEY NOT NULL,
receipt_id TEXT NOT NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('balance', 'account')),
account_id TEXT,
amount_cents INTEGER NOT NULL,
linked_expense_id TEXT,
linked_transfer_id TEXT,
FOREIGN KEY (receipt_id) REFERENCES receipts(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES money_accounts(id) ON DELETE SET NULL,
FOREIGN KEY (linked_expense_id) REFERENCES expenses(id) ON DELETE SET NULL,
FOREIGN KEY (linked_transfer_id) REFERENCES account_transfers(id) ON DELETE SET NULL
);
CREATE INDEX idx_receipt_funding_sources_receipt_id ON receipt_funding_sources(receipt_id);
-- +goose Down
DROP INDEX IF EXISTS idx_receipt_funding_sources_receipt_id;
DROP INDEX IF EXISTS idx_receipts_recurring_receipt_id;
DROP INDEX IF EXISTS idx_receipts_space_id;
DROP INDEX IF EXISTS idx_receipts_loan_id;
DROP INDEX IF EXISTS idx_recurring_receipt_sources_recurring_receipt_id;
DROP INDEX IF EXISTS idx_recurring_receipts_active;
DROP INDEX IF EXISTS idx_recurring_receipts_next_occurrence;
DROP INDEX IF EXISTS idx_recurring_receipts_loan_id;
DROP INDEX IF EXISTS idx_recurring_receipts_space_id;
DROP INDEX IF EXISTS idx_loans_space_id;
DROP TABLE IF EXISTS receipt_funding_sources;
DROP TABLE IF EXISTS receipts;
DROP TABLE IF EXISTS recurring_receipt_sources;
DROP TABLE IF EXISTS recurring_receipts;
DROP TABLE IF EXISTS loans;

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,
)
}

25
internal/model/loan.go Normal file
View file

@ -0,0 +1,25 @@
package model
import "time"
type Loan struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Description string `db:"description"`
OriginalAmountCents int `db:"original_amount_cents"`
InterestRateBps int `db:"interest_rate_bps"`
StartDate time.Time `db:"start_date"`
EndDate *time.Time `db:"end_date"`
IsPaidOff bool `db:"is_paid_off"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type LoanWithPaymentSummary struct {
Loan
TotalPaidCents int
RemainingCents int
ReceiptCount int
}

48
internal/model/receipt.go Normal file
View file

@ -0,0 +1,48 @@
package model
import "time"
type FundingSourceType string
const (
FundingSourceBalance FundingSourceType = "balance"
FundingSourceAccount FundingSourceType = "account"
)
type Receipt struct {
ID string `db:"id"`
LoanID string `db:"loan_id"`
SpaceID string `db:"space_id"`
Description string `db:"description"`
TotalAmountCents int `db:"total_amount_cents"`
Date time.Time `db:"date"`
RecurringReceiptID *string `db:"recurring_receipt_id"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type ReceiptFundingSource struct {
ID string `db:"id"`
ReceiptID string `db:"receipt_id"`
SourceType FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"`
AmountCents int `db:"amount_cents"`
LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"`
}
type ReceiptWithSources struct {
Receipt
Sources []ReceiptFundingSource
}
type ReceiptFundingSourceWithAccount struct {
ReceiptFundingSource
AccountName string
}
type ReceiptWithSourcesAndAccounts struct {
Receipt
Sources []ReceiptFundingSourceWithAccount
}

View file

@ -0,0 +1,38 @@
package model
import "time"
type RecurringReceipt struct {
ID string `db:"id"`
LoanID string `db:"loan_id"`
SpaceID string `db:"space_id"`
Description string `db:"description"`
TotalAmountCents int `db:"total_amount_cents"`
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"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type RecurringReceiptSource struct {
ID string `db:"id"`
RecurringReceiptID string `db:"recurring_receipt_id"`
SourceType FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"`
AmountCents int `db:"amount_cents"`
}
type RecurringReceiptWithSources struct {
RecurringReceipt
Sources []RecurringReceiptSource
}
type RecurringReceiptWithLoan struct {
RecurringReceipt
LoanName string
Sources []RecurringReceiptSource
}

107
internal/repository/loan.go Normal file
View file

@ -0,0 +1,107 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrLoanNotFound = errors.New("loan not found")
)
type LoanRepository interface {
Create(loan *model.Loan) error
GetByID(id string) (*model.Loan, error)
GetBySpaceID(spaceID string) ([]*model.Loan, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error)
CountBySpaceID(spaceID string) (int, error)
Update(loan *model.Loan) error
Delete(id string) error
SetPaidOff(id string, paidOff bool) error
GetTotalPaidForLoan(loanID string) (int, error)
GetReceiptCountForLoan(loanID string) (int, error)
}
type loanRepository struct {
db *sqlx.DB
}
func NewLoanRepository(db *sqlx.DB) LoanRepository {
return &loanRepository{db: db}
}
func (r *loanRepository) Create(loan *model.Loan) error {
query := `INSERT INTO loans (id, space_id, name, description, original_amount_cents, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);`
_, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt)
return err
}
func (r *loanRepository) GetByID(id string) (*model.Loan, error) {
loan := &model.Loan{}
query := `SELECT * FROM loans WHERE id = $1;`
err := r.db.Get(loan, query, id)
if err == sql.ErrNoRows {
return nil, ErrLoanNotFound
}
return loan, err
}
func (r *loanRepository) GetBySpaceID(spaceID string) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC;`
err := r.db.Select(&loans, query, spaceID)
return loans, err
}
func (r *loanRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&loans, query, spaceID, limit, offset)
return loans, err
}
func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM loans WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *loanRepository) Update(loan *model.Loan) error {
query := `UPDATE loans SET name = $1, description = $2, original_amount_cents = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;`
result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrLoanNotFound
}
return err
}
func (r *loanRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM loans WHERE id = $1;`, id)
return err
}
func (r *loanRepository) SetPaidOff(id string, paidOff bool) error {
_, err := r.db.Exec(`UPDATE loans SET is_paid_off = $1, updated_at = $2 WHERE id = $3;`, paidOff, time.Now(), id)
return err
}
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (int, error) {
var total int
err := r.db.Get(&total, `SELECT COALESCE(SUM(total_amount_cents), 0) FROM receipts WHERE loan_id = $1;`, loanID)
return total, err
}
func (r *loanRepository) GetReceiptCountForLoan(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}

View file

@ -0,0 +1,326 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrReceiptNotFound = errors.New("receipt not found")
)
type ReceiptRepository interface {
CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
GetByID(id string) (*model.Receipt, error)
GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error)
CountByLoanID(loanID string) (int, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error)
CountBySpaceID(spaceID string) (int, error)
GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error)
GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error)
DeleteWithReversal(receiptID string) error
UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
}
type receiptRepository struct {
db *sqlx.DB
}
func NewReceiptRepository(db *sqlx.DB) ReceiptRepository {
return &receiptRepository{db: db}
}
func (r *receiptRepository) CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Insert receipt
_, err = tx.Exec(
`INSERT INTO receipts (id, loan_id, space_id, description, total_amount_cents, date, recurring_receipt_id, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`,
receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt,
)
if err != nil {
return err
}
// Insert balance expense if present
if balanceExpense != nil {
_, err = tx.Exec(
`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);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id)
VALUES ($1, $2, $3, $4, $5, $6, $7);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *receiptRepository) GetByID(id string) (*model.Receipt, error) {
receipt := &model.Receipt{}
query := `SELECT * FROM receipts WHERE id = $1;`
err := r.db.Get(receipt, query, id)
if err == sql.ErrNoRows {
return nil, ErrReceiptNotFound
}
return receipt, err
}
func (r *receiptRepository) GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE loan_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, loanID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountByLoanID(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}
func (r *receiptRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, spaceID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *receiptRepository) GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error) {
var sources []model.ReceiptFundingSource
query := `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`
err := r.db.Select(&sources, query, receiptID)
return sources, err
}
func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error) {
if len(receiptIDs) == 0 {
return make(map[string][]model.ReceiptFundingSourceWithAccount), nil
}
type row struct {
ID string `db:"id"`
ReceiptID string `db:"receipt_id"`
SourceType model.FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"`
AmountCents int `db:"amount_cents"`
LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"`
AccountName *string `db:"account_name"`
}
query, args, err := sqlx.In(`
SELECT rfs.id, rfs.receipt_id, rfs.source_type, rfs.account_id, rfs.amount_cents,
rfs.linked_expense_id, rfs.linked_transfer_id,
ma.name AS account_name
FROM receipt_funding_sources rfs
LEFT JOIN money_accounts ma ON rfs.account_id = ma.id
WHERE rfs.receipt_id IN (?)
ORDER BY rfs.source_type ASC;
`, receiptIDs)
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.ReceiptFundingSourceWithAccount)
for _, rw := range rows {
accountName := ""
if rw.AccountName != nil {
accountName = *rw.AccountName
}
result[rw.ReceiptID] = append(result[rw.ReceiptID], model.ReceiptFundingSourceWithAccount{
ReceiptFundingSource: model.ReceiptFundingSource{
ID: rw.ID,
ReceiptID: rw.ReceiptID,
SourceType: rw.SourceType,
AccountID: rw.AccountID,
AmountCents: rw.AmountCents,
LinkedExpenseID: rw.LinkedExpenseID,
LinkedTransferID: rw.LinkedTransferID,
},
AccountName: accountName,
})
}
return result, nil
}
func (r *receiptRepository) DeleteWithReversal(receiptID string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Get all funding sources for this receipt
var sources []model.ReceiptFundingSource
if err := tx.Select(&sources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete linked expenses and transfers
for _, src := range sources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
// Delete funding sources (cascade would handle this, but be explicit)
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete the receipt
if _, err := tx.Exec(`DELETE FROM receipts WHERE id = $1;`, receiptID); err != nil {
return err
}
return tx.Commit()
}
func (r *receiptRepository) UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Delete old linked records
var oldSources []model.ReceiptFundingSource
if err := tx.Select(&oldSources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
for _, src := range oldSources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
// Update receipt
_, err = tx.Exec(
`UPDATE receipts SET description = $1, total_amount_cents = $2, date = $3, updated_at = $4 WHERE id = $5;`,
receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.UpdatedAt, receipt.ID,
)
if err != nil {
return err
}
// Insert new balance expense
if balanceExpense != nil {
_, err = tx.Exec(
`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);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert new account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert new funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id)
VALUES ($1, $2, $3, $4, $5, $6, $7);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}

View file

@ -0,0 +1,165 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringReceiptNotFound = errors.New("recurring receipt not found")
)
type RecurringReceiptRepository interface {
Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
GetByID(id string) (*model.RecurringReceipt, error)
GetByLoanID(loanID string) ([]*model.RecurringReceipt, error)
GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error)
GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error)
Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
Delete(id string) error
SetActive(id string, active bool) error
Deactivate(id string) error
GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error)
UpdateNextOccurrence(id string, next time.Time) error
}
type recurringReceiptRepository struct {
db *sqlx.DB
}
func NewRecurringReceiptRepository(db *sqlx.DB) RecurringReceiptRepository {
return &recurringReceiptRepository{db: db}
}
func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount_cents, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);`,
rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt,
)
if err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents)
VALUES ($1, $2, $3, $4, $5);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) GetByID(id string) (*model.RecurringReceipt, error) {
rr := &model.RecurringReceipt{}
query := `SELECT * FROM recurring_receipts WHERE id = $1;`
err := r.db.Get(rr, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringReceiptNotFound
}
return rr, err
}
func (r *recurringReceiptRepository) GetByLoanID(loanID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE loan_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, loanID)
return results, err
}
func (r *recurringReceiptRepository) GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringReceiptRepository) GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error) {
var sources []model.RecurringReceiptSource
query := `SELECT * FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`
err := r.db.Select(&sources, query, id)
return sources, err
}
func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`UPDATE recurring_receipts SET description = $1, total_amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`,
rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
)
if err != nil {
return err
}
// Replace sources
if _, err := tx.Exec(`DELETE FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`, rr.ID); err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents)
VALUES ($1, $2, $3, $4, $5);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM recurring_receipts WHERE id = $1;`, id)
return err
}
func (r *recurringReceiptRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringReceiptRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}
func (r *recurringReceiptRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringReceiptRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts 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 *recurringReceiptRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}

View file

@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
home := handler.NewHomeHandler()
settings := handler.NewSettingsHandler(a.AuthService, a.UserService, a.ProfileService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService, a.LoanService, a.ReceiptService, a.RecurringReceiptService)
mux := http.NewServeMux()
@ -217,6 +217,61 @@ func SetupRoutes(a *app.App) http.Handler {
budgetsListWithAuth := middleware.RequireAuth(budgetsListHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/components/budgets", budgetsListWithAuth)
// Loan routes
loansPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.LoansPage)
loansPageWithAuth := middleware.RequireAuth(loansPageHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/loans", loansPageWithAuth)
createLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateLoan)
createLoanWithAuth := middleware.RequireAuth(createLoanHandler)
mux.Handle("POST /app/spaces/{spaceID}/loans", crudLimiter(createLoanWithAuth))
loanDetailHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.LoanDetailPage)
loanDetailWithAuth := middleware.RequireAuth(loanDetailHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/loans/{loanID}", loanDetailWithAuth)
updateLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateLoan)
updateLoanWithAuth := middleware.RequireAuth(updateLoanHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}", crudLimiter(updateLoanWithAuth))
deleteLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteLoan)
deleteLoanWithAuth := middleware.RequireAuth(deleteLoanHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}", crudLimiter(deleteLoanWithAuth))
// Receipt routes
createReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateReceipt)
createReceiptWithAuth := middleware.RequireAuth(createReceiptHandler)
mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/receipts", crudLimiter(createReceiptWithAuth))
updateReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateReceipt)
updateReceiptWithAuth := middleware.RequireAuth(updateReceiptHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", crudLimiter(updateReceiptWithAuth))
deleteReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteReceipt)
deleteReceiptWithAuth := middleware.RequireAuth(deleteReceiptHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", crudLimiter(deleteReceiptWithAuth))
receiptsListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetReceiptsList)
receiptsListWithAuth := middleware.RequireAuth(receiptsListHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/loans/{loanID}/components/receipts", receiptsListWithAuth)
// Recurring receipt routes
createRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateRecurringReceipt)
createRecurringReceiptWithAuth := middleware.RequireAuth(createRecurringReceiptHandler)
mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/recurring", crudLimiter(createRecurringReceiptWithAuth))
updateRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateRecurringReceipt)
updateRecurringReceiptWithAuth := middleware.RequireAuth(updateRecurringReceiptHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", crudLimiter(updateRecurringReceiptWithAuth))
deleteRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteRecurringReceipt)
deleteRecurringReceiptWithAuth := middleware.RequireAuth(deleteRecurringReceiptHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", crudLimiter(deleteRecurringReceiptWithAuth))
toggleRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ToggleRecurringReceipt)
toggleRecurringReceiptWithAuth := middleware.RequireAuth(toggleRecurringReceiptHandler)
mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}/toggle", crudLimiter(toggleRecurringReceiptWithAuth))
// Report routes
reportChartsHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetReportCharts)
reportChartsWithAuth := middleware.RequireAuth(reportChartsHandler)

View file

@ -9,14 +9,16 @@ import (
)
type Scheduler struct {
recurringService *service.RecurringExpenseService
interval time.Duration
recurringService *service.RecurringExpenseService
recurringReceiptService *service.RecurringReceiptService
interval time.Duration
}
func New(recurringService *service.RecurringExpenseService) *Scheduler {
func New(recurringService *service.RecurringExpenseService, recurringReceiptService *service.RecurringReceiptService) *Scheduler {
return &Scheduler{
recurringService: recurringService,
interval: 1 * time.Hour,
recurringService: recurringService,
recurringReceiptService: recurringReceiptService,
interval: 1 * time.Hour,
}
}
@ -45,4 +47,9 @@ func (s *Scheduler) run() {
if err := s.recurringService.ProcessDueRecurrences(now); err != nil {
slog.Error("scheduler: failed to process recurring expenses", "error", err)
}
slog.Info("scheduler: processing due recurring receipts")
if err := s.recurringReceiptService.ProcessDueRecurrences(now); err != nil {
slog.Error("scheduler: failed to process recurring receipts", "error", err)
}
}

195
internal/service/loan.go Normal file
View file

@ -0,0 +1,195 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateLoanDTO struct {
SpaceID string
UserID string
Name string
Description string
OriginalAmount int
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
type UpdateLoanDTO struct {
ID string
Name string
Description string
OriginalAmount int
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
const LoansPerPage = 25
type LoanService struct {
loanRepo repository.LoanRepository
receiptRepo repository.ReceiptRepository
}
func NewLoanService(loanRepo repository.LoanRepository, receiptRepo repository.ReceiptRepository) *LoanService {
return &LoanService{
loanRepo: loanRepo,
receiptRepo: receiptRepo,
}
}
func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
loan := &model.Loan{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: dto.Name,
Description: dto.Description,
OriginalAmountCents: dto.OriginalAmount,
InterestRateBps: dto.InterestRateBps,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsPaidOff: false,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.loanRepo.Create(loan); err != nil {
return nil, err
}
return loan, nil
}
func (s *LoanService) GetLoan(id string) (*model.Loan, error) {
return s.loanRepo.GetByID(id)
}
func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSummary, error) {
loan, err := s.loanRepo.GetByID(id)
if err != nil {
return nil, err
}
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(id)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(id)
if err != nil {
return nil, err
}
return &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaidCents: totalPaid,
RemainingCents: loan.OriginalAmountCents - totalPaid,
ReceiptCount: receiptCount,
}, nil
}
func (s *LoanService) GetLoansWithSummaryForSpace(spaceID string) ([]*model.LoanWithPaymentSummary, error) {
loans, err := s.loanRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
return s.attachSummaries(loans)
}
func (s *LoanService) GetLoansWithSummaryForSpacePaginated(spaceID string, page int) ([]*model.LoanWithPaymentSummary, int, error) {
total, err := s.loanRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
totalPages := (total + LoansPerPage - 1) / LoansPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * LoansPerPage
loans, err := s.loanRepo.GetBySpaceIDPaginated(spaceID, LoansPerPage, offset)
if err != nil {
return nil, 0, err
}
result, err := s.attachSummaries(loans)
if err != nil {
return nil, 0, err
}
return result, totalPages, nil
}
func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPaymentSummary, error) {
result := make([]*model.LoanWithPaymentSummary, len(loans))
for i, loan := range loans {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(loan.ID)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(loan.ID)
if err != nil {
return nil, err
}
result[i] = &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaidCents: totalPaid,
RemainingCents: loan.OriginalAmountCents - totalPaid,
ReceiptCount: receiptCount,
}
}
return result, nil
}
func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.loanRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Name = dto.Name
existing.Description = dto.Description
existing.OriginalAmountCents = dto.OriginalAmount
existing.InterestRateBps = dto.InterestRateBps
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.loanRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *LoanService) DeleteLoan(id string) error {
return s.loanRepo.Delete(id)
}

317
internal/service/receipt.go Normal file
View file

@ -0,0 +1,317 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type FundingSourceDTO struct {
SourceType model.FundingSourceType
AccountID string
Amount int
}
type CreateReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount int
Date time.Time
FundingSources []FundingSourceDTO
RecurringReceiptID *string
}
type UpdateReceiptDTO struct {
ID string
SpaceID string
UserID string
Description string
TotalAmount int
Date time.Time
FundingSources []FundingSourceDTO
}
const ReceiptsPerPage = 25
type ReceiptService struct {
receiptRepo repository.ReceiptRepository
loanRepo repository.LoanRepository
accountRepo repository.MoneyAccountRepository
}
func NewReceiptService(
receiptRepo repository.ReceiptRepository,
loanRepo repository.LoanRepository,
accountRepo repository.MoneyAccountRepository,
) *ReceiptService {
return &ReceiptService{
receiptRepo: receiptRepo,
loanRepo: loanRepo,
accountRepo: accountRepo,
}
}
func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
// Validate funding sources sum to total
var sum int
for _, src := range dto.FundingSources {
if src.Amount <= 0 {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum += src.Amount
}
if sum != dto.TotalAmount {
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount)
}
// Validate loan exists and is not paid off
loan, err := s.loanRepo.GetByID(dto.LoanID)
if err != nil {
return nil, fmt.Errorf("loan not found: %w", err)
}
if loan.IsPaidOff {
return nil, fmt.Errorf("loan is already paid off")
}
now := time.Now()
receipt := &model.Receipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmountCents: dto.TotalAmount,
Date: dto.Date,
RecurringReceiptID: dto.RecurringReceiptID,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(receipt, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.CreateWithSources(receipt, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Check if loan is now fully paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID)
if err == nil && totalPaid >= loan.OriginalAmountCents {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
}
return &model.ReceiptWithSources{Receipt: *receipt, Sources: sources}, nil
}
func (s *ReceiptService) buildLinkedRecords(
receipt *model.Receipt,
fundingSources []FundingSourceDTO,
spaceID, userID, description string,
date time.Time,
) ([]model.ReceiptFundingSource, *model.Expense, []*model.AccountTransfer) {
now := time.Now()
var sources []model.ReceiptFundingSource
var balanceExpense *model.Expense
var accountTransfers []*model.AccountTransfer
for _, src := range fundingSources {
fs := model.ReceiptFundingSource{
ID: uuid.NewString(),
ReceiptID: receipt.ID,
SourceType: src.SourceType,
AmountCents: src.Amount,
}
if src.SourceType == model.FundingSourceBalance {
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: spaceID,
CreatedBy: userID,
Description: fmt.Sprintf("Loan payment: %s", description),
AmountCents: src.Amount,
Type: model.ExpenseTypeExpense,
Date: date,
CreatedAt: now,
UpdatedAt: now,
}
balanceExpense = expense
fs.LinkedExpenseID = &expense.ID
} else {
acctID := src.AccountID
fs.AccountID = &acctID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: src.AccountID,
AmountCents: src.Amount,
Direction: model.TransferDirectionWithdrawal,
Note: fmt.Sprintf("Loan payment: %s", description),
CreatedBy: userID,
CreatedAt: now,
}
accountTransfers = append(accountTransfers, transfer)
fs.LinkedTransferID = &transfer.ID
}
sources = append(sources, fs)
}
return sources, balanceExpense, accountTransfers
}
func (s *ReceiptService) GetReceipt(id string) (*model.ReceiptWithSourcesAndAccounts, error) {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return nil, err
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs([]string{id})
if err != nil {
return nil, err
}
return &model.ReceiptWithSourcesAndAccounts{
Receipt: *receipt,
Sources: sourcesMap[id],
}, nil
}
func (s *ReceiptService) GetReceiptsForLoanPaginated(loanID string, page int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
total, err := s.receiptRepo.CountByLoanID(loanID)
if err != nil {
return nil, 0, err
}
totalPages := (total + ReceiptsPerPage - 1) / ReceiptsPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ReceiptsPerPage
receipts, err := s.receiptRepo.GetByLoanIDPaginated(loanID, ReceiptsPerPage, offset)
if err != nil {
return nil, 0, err
}
return s.attachSources(receipts, totalPages)
}
func (s *ReceiptService) attachSources(receipts []*model.Receipt, totalPages int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
ids := make([]string, len(receipts))
for i, r := range receipts {
ids[i] = r.ID
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ReceiptWithSourcesAndAccounts, len(receipts))
for i, r := range receipts {
result[i] = &model.ReceiptWithSourcesAndAccounts{
Receipt: *r,
Sources: sourcesMap[r.ID],
}
}
return result, totalPages, nil
}
func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return err
}
if receipt.SpaceID != spaceID {
return fmt.Errorf("receipt not found")
}
if err := s.receiptRepo.DeleteWithReversal(id); err != nil {
return err
}
// Check if loan should be un-marked as paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(receipt.LoanID)
if err != nil {
return nil // receipt deleted successfully, paid-off check is best-effort
}
loan, err := s.loanRepo.GetByID(receipt.LoanID)
if err != nil {
return nil
}
if loan.IsPaidOff && totalPaid < loan.OriginalAmountCents {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
return nil
}
func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
var sum int
for _, src := range dto.FundingSources {
if src.Amount <= 0 {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum += src.Amount
}
if sum != dto.TotalAmount {
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount)
}
existing, err := s.receiptRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
if existing.SpaceID != dto.SpaceID {
return nil, fmt.Errorf("receipt not found")
}
existing.Description = dto.Description
existing.TotalAmountCents = dto.TotalAmount
existing.Date = dto.Date
existing.UpdatedAt = time.Now()
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(existing, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.UpdateWithSources(existing, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Re-check paid-off status
loan, err := s.loanRepo.GetByID(existing.LoanID)
if err == nil {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID)
if err == nil {
if totalPaid >= loan.OriginalAmountCents && !loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
} else if totalPaid < loan.OriginalAmountCents && loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
}
}
return &model.ReceiptWithSources{Receipt: *existing, Sources: sources}, nil
}

View file

@ -0,0 +1,323 @@
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 CreateRecurringReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type UpdateRecurringReceiptDTO struct {
ID string
Description string
TotalAmount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type RecurringReceiptService struct {
recurringRepo repository.RecurringReceiptRepository
receiptService *ReceiptService
loanRepo repository.LoanRepository
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringReceiptService(
recurringRepo repository.RecurringReceiptRepository,
receiptService *ReceiptService,
loanRepo repository.LoanRepository,
profileRepo repository.ProfileRepository,
spaceRepo repository.SpaceRepository,
) *RecurringReceiptService {
return &RecurringReceiptService{
recurringRepo: recurringRepo,
receiptService: receiptService,
loanRepo: loanRepo,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) {
if dto.TotalAmount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
var sum int
for _, src := range dto.FundingSources {
sum += src.Amount
}
if sum != dto.TotalAmount {
return nil, fmt.Errorf("funding source amounts must equal total amount")
}
now := time.Now()
rr := &model.RecurringReceipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmountCents: dto.TotalAmount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: rr.ID,
SourceType: src.SourceType,
AmountCents: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Create(rr, sources); err != nil {
return nil, err
}
return &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}, nil
}
func (s *RecurringReceiptService) GetRecurringReceipt(id string) (*model.RecurringReceipt, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringReceiptService) GetRecurringReceiptsForLoan(loanID string) ([]*model.RecurringReceipt, error) {
return s.recurringRepo.GetByLoanID(loanID)
}
func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID string) ([]*model.RecurringReceiptWithSources, error) {
rrs, err := s.recurringRepo.GetByLoanID(loanID)
if err != nil {
return nil, err
}
result := make([]*model.RecurringReceiptWithSources, len(rrs))
for i, rr := range rrs {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return nil, err
}
result[i] = &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}
}
return result, nil
}
func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) {
if dto.TotalAmount <= 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.TotalAmountCents = dto.TotalAmount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: existing.ID,
SourceType: src.SourceType,
AmountCents: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Update(existing, sources); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringReceiptService) DeleteRecurringReceipt(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringReceiptService) ToggleRecurringReceipt(id string) (*model.RecurringReceipt, error) {
rr, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !rr.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
rr.IsActive = newActive
return rr, nil
}
func (s *RecurringReceiptService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurring receipts: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
if err != nil {
return fmt.Errorf("failed to get due recurring receipts for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, now time.Time) error {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return err
}
for !rr.NextOccurrence.After(now) {
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
return s.recurringRepo.Deactivate(rr.ID)
}
// Check if loan is already paid off
loan, err := s.loanRepo.GetByID(rr.LoanID)
if err != nil {
return fmt.Errorf("failed to get loan: %w", err)
}
if loan.IsPaidOff {
return s.recurringRepo.Deactivate(rr.ID)
}
// Build funding source DTOs from template
fundingSources := make([]FundingSourceDTO, len(sources))
for i, src := range sources {
accountID := ""
if src.AccountID != nil {
accountID = *src.AccountID
}
fundingSources[i] = FundingSourceDTO{
SourceType: src.SourceType,
AccountID: accountID,
Amount: src.AmountCents,
}
}
rrID := rr.ID
dto := CreateReceiptDTO{
LoanID: rr.LoanID,
SpaceID: rr.SpaceID,
UserID: rr.CreatedBy,
Description: rr.Description,
TotalAmount: rr.TotalAmountCents,
Date: rr.NextOccurrence,
FundingSources: fundingSources,
RecurringReceiptID: &rrID,
}
if _, err := s.receiptService.CreateReceipt(dto); err != nil {
slog.Warn("recurring receipt skipped", "id", rr.ID, "error", err)
}
rr.NextOccurrence = AdvanceDate(rr.NextOccurrence, rr.Frequency)
}
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
if err := s.recurringRepo.Deactivate(rr.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(rr.ID, rr.NextOccurrence)
}
func (s *RecurringReceiptService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time {
spaceKey := "space:" + spaceID
if loc, ok := cache[spaceKey]; ok {
return now.In(loc)
}
space, err := s.spaceRepo.ByID(spaceID)
if err == nil && space != nil {
if loc := space.Location(); loc != nil {
cache[spaceKey] = loc
return now.In(loc)
}
}
userKey := "user:" + userID
if loc, ok := cache[userKey]; ok {
return now.In(loc)
}
loc := time.UTC
profile, err := s.profileRepo.ByUserID(userID)
if err == nil && profile != nil {
loc = profile.Location()
}
cache[userKey] = loc
return now.In(loc)
}

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
"strings"
)
templ Space(title string, space *model.Space) {
@ -90,6 +91,16 @@ templ Space(title string, space *model.Space) {
<span>Budgets</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/loans",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/loans" || strings.HasPrefix(ctxkeys.URLPath(ctx), "/app/spaces/"+space.ID+"/loans/"),
Tooltip: "Loans",
}) {
@icon.Landmark(icon.Props{Class: "size-4"})
<span>Loans</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/accounts",

View file

@ -0,0 +1,607 @@
package pages
import (
"fmt"
"strconv"
"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/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance int) {
@layouts.Space(loan.Name, space) {
<div class="space-y-6">
// Loan Summary Card
@LoanSummaryCard(space.ID, loan)
// Actions
if !loan.IsPaidOff {
<div class="flex gap-2">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
Make Payment
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Make Payment
}
@dialog.Description() {
Record a payment toward { loan.Name }
}
}
@CreateReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm, Variant: button.VariantOutline}) {
@icon.Repeat(icon.Props{Class: "size-4 mr-1"})
Set Up Recurring
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Recurring Payment
}
@dialog.Description() {
Automatically create payments on a schedule
}
}
@CreateRecurringReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
</div>
}
// Recurring Receipts
if len(recurringReceipts) > 0 {
<div class="space-y-2">
<h2 class="text-lg font-semibold">Recurring Payments</h2>
<div class="border rounded-lg divide-y">
for _, rr := range recurringReceipts {
@RecurringReceiptItem(space.ID, loan.ID, rr)
}
</div>
</div>
}
// Receipt History
<div class="space-y-2">
<h2 class="text-lg font-semibold">Payment History</h2>
<div class="border rounded-lg">
<div id="receipts-list-wrapper">
@ReceiptsListContent(space.ID, loan.ID, receipts, currentPage, totalPages)
</div>
</div>
</div>
</div>
}
}
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
@card.Card(card.Props{}) {
@card.Header() {
<div class="flex justify-between items-start">
<div>
@card.Title() {
{ loan.Name }
}
@card.Description() {
if loan.Description != "" {
{ loan.Description }
}
}
</div>
<div class="flex items-center gap-2">
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Loan
}
@dialog.Description() {
Are you sure? This will delete all payment records for this loan. Linked expenses and account transfers will be kept as history.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
@card.Content() {
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-sm text-muted-foreground">Original</p>
<p class="text-lg font-semibold">{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Paid</p>
<p class="text-lg font-semibold text-green-600">{ fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold">
if loan.RemainingCents > 0 {
{ fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }
} else {
$0.00
}
</p>
</div>
</div>
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-3",
})
<div class="flex justify-between text-sm text-muted-foreground">
<span>{ strconv.Itoa(progressPct) }% paid</span>
if loan.InterestRateBps > 0 {
<span>{ fmt.Sprintf("%.2f%% interest", float64(loan.InterestRateBps)/100.0) }</span>
}
</div>
</div>
}
}
}
templ ReceiptsListContent(spaceID, loanID string, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int) {
<div id="receipts-list" class="divide-y">
if len(receipts) == 0 {
<p class="p-4 text-sm text-muted-foreground">No payments recorded yet.</p>
}
for _, receipt := range receipts {
@ReceiptListItem(spaceID, loanID, receipt)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage-1),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, pg),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage+1),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesAndAccounts) {
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(receipt.TotalAmountCents)/100.0) }</span>
<span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span>
if receipt.RecurringReceiptID != nil {
@icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"})
}
</div>
if receipt.Description != "" {
<p class="text-sm text-muted-foreground">{ receipt.Description }</p>
}
<div class="flex flex-wrap gap-1">
for _, src := range receipt.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ fmt.Sprintf("%s $%.2f", src.AccountName, float64(src.AmountCents)/100.0) }
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Payment
}
@dialog.Description() {
This will also reverse the linked expense and account transfers.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/receipts/%s", spaceID, loanID, receipt.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWithSources) {
<div class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
@icon.Repeat(icon.Props{Class: "size-4"})
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(rr.TotalAmountCents)/100.0) }</span>
<span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span>
if !rr.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
if rr.Description != "" {
<p class="text-sm text-muted-foreground">{ rr.Description }</p>
}
<p class="text-xs text-muted-foreground">
Next: { rr.NextOccurrence.Format("Jan 2, 2006") }
</p>
<div class="flex flex-wrap gap-1">
for _, src := range rr.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
if src.AccountID != nil {
{ fmt.Sprintf("Account $%.2f", float64(src.AmountCents)/100.0) }
}
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s/toggle", spaceID, loanID, rr.ID),
},
}) {
if rr.IsActive {
@icon.Pause(icon.Props{Class: "size-4"})
} else {
@icon.Play(icon.Props{Class: "size-4"})
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Recurring Payment
}
@dialog.Description() {
This will stop future automatic payments. Past payments are not affected.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s", spaceID, loanID, rr.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Available balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
</p>
<div id="funding-sources" class="space-y-2">
<div class="flex gap-2 items-center source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Record Payment
}
}
</form>
<script>
// Update hidden account_id when select changes
document.getElementById('funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Frequency
}
<select name="frequency" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" required>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="yearly">Yearly</option>
</select>
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Current balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
</p>
<div id="recurring-funding-sources" class="space-y-2">
<div class="flex gap-2 items-center recurring-source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .recurring-source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #recurring-funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Recurring Payment
}
}
</form>
<script>
document.getElementById('recurring-funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}

View file

@ -0,0 +1,234 @@
package pages
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoansPage(space *model.Space, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
@layouts.Space("Loans", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Loans</h1>
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
New Loan
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
New Loan
}
@dialog.Description() {
Track a new loan or financing
}
}
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans", space.ID) }
hx-target="#loans-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterRequest if event.detail.successful call window.tui.dialog.close() then reset() me"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Name
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "name",
Placeholder: "e.g., Car Loan",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Total Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01",
"required": "true",
},
})
}
@form.Item() {
@form.Label() {
Interest Rate (%)
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "interest_rate",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0",
},
})
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Additional notes about this loan",
})
}
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Loan
}
}
</form>
}
}
</div>
<div id="loans-list-wrapper">
@LoansListContent(space.ID, loans, currentPage, totalPages)
</div>
</div>
}
}
templ LoansListContent(spaceID string, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(loans) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No loans yet. Create one to start tracking payments.</p>
}
for _, loan := range loans {
@LoanCard(spaceID, loan)
}
</div>
if totalPages > 1 {
<div class="mt-4">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage-1),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, pg),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage+1),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
<a href={ templ.SafeURL(fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID)) } class="block">
@card.Card(card.Props{Class: "hover:border-primary/50 transition-colors cursor-pointer"}) {
@card.Header() {
<div class="flex justify-between items-start">
@card.Title() {
{ loan.Name }
}
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
</div>
@card.Description() {
{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }
if loan.InterestRateBps > 0 {
{ fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) }
}
}
}
@card.Content() {
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-2",
})
<div class="flex justify-between text-sm text-muted-foreground mt-2">
<span>Paid: { fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</span>
if loan.RemainingCents > 0 {
<span>Left: { fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }</span>
} else {
<span class="text-green-600">Fully paid</span>
}
</div>
<p class="text-xs text-muted-foreground mt-1">
{ strconv.Itoa(loan.ReceiptCount) } payment(s)
</p>
}
}
</a>
}