diff --git a/cmd/server/main.go b/cmd/server/main.go index ea9406a..87d6f41 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index ed6f9f5..226ab44 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/db/migrations/00017_create_loans_and_receipts_tables.sql b/internal/db/migrations/00017_create_loans_and_receipts_tables.sql new file mode 100644 index 0000000..7f045e8 --- /dev/null +++ b/internal/db/migrations/00017_create_loans_and_receipts_tables.sql @@ -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; diff --git a/internal/handler/space.go b/internal/handler/space.go index 8adf851..e514daf 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -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, } } diff --git a/internal/handler/space_loans.go b/internal/handler/space_loans.go new file mode 100644 index 0000000..4c2f041 --- /dev/null +++ b/internal/handler/space_loans.go @@ -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 +} diff --git a/internal/handler/space_test.go b/internal/handler/space_test.go index 5e4fe99..3efd09c 100644 --- a/internal/handler/space_test.go +++ b/internal/handler/space_test.go @@ -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, ) } diff --git a/internal/model/loan.go b/internal/model/loan.go new file mode 100644 index 0000000..5c915b6 --- /dev/null +++ b/internal/model/loan.go @@ -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 +} diff --git a/internal/model/receipt.go b/internal/model/receipt.go new file mode 100644 index 0000000..30bfc80 --- /dev/null +++ b/internal/model/receipt.go @@ -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 +} diff --git a/internal/model/recurring_receipt.go b/internal/model/recurring_receipt.go new file mode 100644 index 0000000..5c8b909 --- /dev/null +++ b/internal/model/recurring_receipt.go @@ -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 +} diff --git a/internal/repository/loan.go b/internal/repository/loan.go new file mode 100644 index 0000000..dcfc38a --- /dev/null +++ b/internal/repository/loan.go @@ -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 +} diff --git a/internal/repository/receipt.go b/internal/repository/receipt.go new file mode 100644 index 0000000..b03b7fd --- /dev/null +++ b/internal/repository/receipt.go @@ -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() +} diff --git a/internal/repository/recurring_receipt.go b/internal/repository/recurring_receipt.go new file mode 100644 index 0000000..216be7f --- /dev/null +++ b/internal/repository/recurring_receipt.go @@ -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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5606bf4..020c6d1 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 6b71c14..acad95e 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -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) + } } diff --git a/internal/service/loan.go b/internal/service/loan.go new file mode 100644 index 0000000..2af8088 --- /dev/null +++ b/internal/service/loan.go @@ -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) +} diff --git a/internal/service/receipt.go b/internal/service/receipt.go new file mode 100644 index 0000000..8d2cd5b --- /dev/null +++ b/internal/service/receipt.go @@ -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 +} diff --git a/internal/service/recurring_receipt.go b/internal/service/recurring_receipt.go new file mode 100644 index 0000000..cd76b52 --- /dev/null +++ b/internal/service/recurring_receipt.go @@ -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) +} diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ index 1bf7aeb..5351f17 100644 --- a/internal/ui/layouts/space.templ +++ b/internal/ui/layouts/space.templ @@ -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) { Budgets } } + @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"}) + Loans + } + } @sidebar.MenuItem() { @sidebar.MenuButton(sidebar.MenuButtonProps{ Href: "/app/spaces/" + space.ID + "/accounts", diff --git a/internal/ui/pages/app_space_loan_detail.templ b/internal/ui/pages/app_space_loan_detail.templ new file mode 100644 index 0000000..202daf4 --- /dev/null +++ b/internal/ui/pages/app_space_loan_detail.templ @@ -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) { +
Original
+{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }
+Paid
+{ fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }
+Remaining
++ if loan.RemainingCents > 0 { + { fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) } + } else { + $0.00 + } +
+No payments recorded yet.
+ } + for _, receipt := range receipts { + @ReceiptListItem(spaceID, loanID, receipt) + } +{ receipt.Description }
+ } +{ rr.Description }
+ } ++ Next: { rr.NextOccurrence.Format("Jan 2, 2006") } +
+No loans yet. Create one to start tracking payments.
+ } + for _, loan := range loans { + @LoanCard(spaceID, loan) + } ++ { strconv.Itoa(loan.ReceiptCount) } payment(s) +
+ } + } + +}