Merge branch 'feat/receipts'
This commit is contained in:
commit
e1ad197624
20 changed files with 3195 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
592
internal/handler/space_loans.go
Normal file
592
internal/handler/space_loans.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
25
internal/model/loan.go
Normal 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
48
internal/model/receipt.go
Normal 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
|
||||
}
|
||||
38
internal/model/recurring_receipt.go
Normal file
38
internal/model/recurring_receipt.go
Normal 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
107
internal/repository/loan.go
Normal 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
|
||||
}
|
||||
326
internal/repository/receipt.go
Normal file
326
internal/repository/receipt.go
Normal 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()
|
||||
}
|
||||
165
internal/repository/recurring_receipt.go
Normal file
165
internal/repository/recurring_receipt.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
195
internal/service/loan.go
Normal 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
317
internal/service/receipt.go
Normal 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
|
||||
}
|
||||
323
internal/service/recurring_receipt.go
Normal file
323
internal/service/recurring_receipt.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
607
internal/ui/pages/app_space_loan_detail.templ
Normal file
607
internal/ui/pages/app_space_loan_detail.templ
Normal 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>
|
||||
}
|
||||
234
internal/ui/pages/app_space_loans.templ
Normal file
234
internal/ui/pages/app_space_loans.templ
Normal 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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue