Merge branch 'feat/recurring-account-deposit'

This commit is contained in:
juancwu 2026-02-20 16:23:05 +00:00
commit b34f336aea
14 changed files with 1128 additions and 46 deletions

View file

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

View file

@ -25,6 +25,7 @@ type App struct {
MoneyAccountService *service.MoneyAccountService MoneyAccountService *service.MoneyAccountService
PaymentMethodService *service.PaymentMethodService PaymentMethodService *service.PaymentMethodService
RecurringExpenseService *service.RecurringExpenseService RecurringExpenseService *service.RecurringExpenseService
RecurringDepositService *service.RecurringDepositService
BudgetService *service.BudgetService BudgetService *service.BudgetService
ReportService *service.ReportService ReportService *service.ReportService
} }
@ -55,6 +56,7 @@ func New(cfg *config.Config) (*App, error) {
moneyAccountRepository := repository.NewMoneyAccountRepository(database) moneyAccountRepository := repository.NewMoneyAccountRepository(database)
paymentMethodRepository := repository.NewPaymentMethodRepository(database) paymentMethodRepository := repository.NewPaymentMethodRepository(database)
recurringExpenseRepository := repository.NewRecurringExpenseRepository(database) recurringExpenseRepository := repository.NewRecurringExpenseRepository(database)
recurringDepositRepository := repository.NewRecurringDepositRepository(database)
budgetRepository := repository.NewBudgetRepository(database) budgetRepository := repository.NewBudgetRepository(database)
// Services // Services
@ -86,6 +88,7 @@ func New(cfg *config.Config) (*App, error) {
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository) moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository) paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository) recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository)
recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService)
budgetService := service.NewBudgetService(budgetRepository) budgetService := service.NewBudgetService(budgetRepository)
reportService := service.NewReportService(expenseRepository) reportService := service.NewReportService(expenseRepository)
@ -104,6 +107,7 @@ func New(cfg *config.Config) (*App, error) {
MoneyAccountService: moneyAccountService, MoneyAccountService: moneyAccountService,
PaymentMethodService: paymentMethodService, PaymentMethodService: paymentMethodService,
RecurringExpenseService: recurringExpenseService, RecurringExpenseService: recurringExpenseService,
RecurringDepositService: recurringDepositService,
BudgetService: budgetService, BudgetService: budgetService,
ReportService: reportService, ReportService: reportService,
}, nil }, nil

View file

@ -0,0 +1,31 @@
-- +goose Up
CREATE TABLE recurring_deposits (
id TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL,
account_id TEXT NOT NULL,
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,
title TEXT NOT NULL DEFAULT '',
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 (account_id) REFERENCES money_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_recurring_deposits_space_id ON recurring_deposits(space_id);
CREATE INDEX idx_recurring_deposits_account_id ON recurring_deposits(account_id);
CREATE INDEX idx_recurring_deposits_next_occurrence ON recurring_deposits(next_occurrence);
CREATE INDEX idx_recurring_deposits_active ON recurring_deposits(is_active);
ALTER TABLE account_transfers ADD COLUMN recurring_deposit_id TEXT
REFERENCES recurring_deposits(id) ON DELETE SET NULL;
-- +goose Down
ALTER TABLE account_transfers DROP COLUMN IF EXISTS recurring_deposit_id;
DROP TABLE IF EXISTS recurring_deposits;

View file

@ -23,30 +23,32 @@ import (
) )
type SpaceHandler struct { type SpaceHandler struct {
spaceService *service.SpaceService spaceService *service.SpaceService
tagService *service.TagService tagService *service.TagService
listService *service.ShoppingListService listService *service.ShoppingListService
expenseService *service.ExpenseService expenseService *service.ExpenseService
inviteService *service.InviteService inviteService *service.InviteService
accountService *service.MoneyAccountService accountService *service.MoneyAccountService
methodService *service.PaymentMethodService methodService *service.PaymentMethodService
recurringService *service.RecurringExpenseService recurringService *service.RecurringExpenseService
budgetService *service.BudgetService recurringDepositService *service.RecurringDepositService
reportService *service.ReportService budgetService *service.BudgetService
reportService *service.ReportService
} }
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, 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) *SpaceHandler {
return &SpaceHandler{ return &SpaceHandler{
spaceService: ss, spaceService: ss,
tagService: ts, tagService: ts,
listService: sls, listService: sls,
expenseService: es, expenseService: es,
inviteService: is, inviteService: is,
accountService: mas, accountService: mas,
methodService: pms, methodService: pms,
recurringService: rs, recurringService: rs,
budgetService: bs, recurringDepositService: rds,
reportService: rps, budgetService: bs,
reportService: rps,
} }
} }
@ -1259,6 +1261,9 @@ func (h *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
return return
} }
// Lazy process recurring deposits
h.recurringDepositService.ProcessDueRecurrencesForSpace(spaceID, time.Now())
accounts, err := h.accountService.GetAccountsForSpace(spaceID) accounts, err := h.accountService.GetAccountsForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID) slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID)
@ -1282,7 +1287,13 @@ func (h *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
availableBalance := totalBalance - totalAllocated availableBalance := totalBalance - totalAllocated
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance)) recurringDeposits, err := h.recurringDepositService.GetRecurringDepositsWithAccountsForSpace(spaceID)
if err != nil {
slog.Error("failed to get recurring deposits", "error", err, "space_id", spaceID)
recurringDeposits = nil
}
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, recurringDeposits))
} }
func (h *SpaceHandler) CreateAccount(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
@ -1537,6 +1548,245 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
})) }))
} }
// --- Recurring Deposits ---
func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit {
rd, err := h.recurringDepositService.GetRecurringDeposit(depositID)
if err != nil {
http.Error(w, "Recurring deposit not found", http.StatusNotFound)
return nil
}
if rd.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return rd
}
func (h *SpaceHandler) CreateRecurringDeposit(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
accountID := r.FormValue("account_id")
amountStr := r.FormValue("amount")
frequencyStr := r.FormValue("frequency")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
title := r.FormValue("title")
if accountID == "" || amountStr == "" || frequencyStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
return
}
// Verify account belongs to space
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil || amountFloat <= 0 {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity)
return
}
endDate = &ed
}
rd, err := h.recurringDepositService.CreateRecurringDeposit(service.CreateRecurringDepositDTO{
SpaceID: spaceID,
AccountID: accountID,
Amount: amountCents,
Frequency: model.Frequency(frequencyStr),
StartDate: startDate,
EndDate: endDate,
Title: title,
CreatedBy: user.ID,
})
if err != nil {
slog.Error("failed to create recurring deposit", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Build response with account name
accounts, _ := h.accountService.GetAccountsForSpace(spaceID)
var accountName string
for _, acct := range accounts {
if acct.ID == rd.AccountID {
accountName = acct.Name
break
}
}
rdWithAccount := &model.RecurringDepositWithAccount{
RecurringDeposit: *rd,
AccountName: accountName,
}
ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts))
}
func (h *SpaceHandler) UpdateRecurringDeposit(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringDepositID := r.PathValue("recurringDepositID")
if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil {
return
}
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
accountID := r.FormValue("account_id")
amountStr := r.FormValue("amount")
frequencyStr := r.FormValue("frequency")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
title := r.FormValue("title")
if accountID == "" || amountStr == "" || frequencyStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
return
}
// Verify account belongs to space
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil || amountFloat <= 0 {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountFloat * 100)
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity)
return
}
var endDate *time.Time
if endDateStr != "" {
ed, err := time.Parse("2006-01-02", endDateStr)
if err != nil {
ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity)
return
}
endDate = &ed
}
updated, err := h.recurringDepositService.UpdateRecurringDeposit(service.UpdateRecurringDepositDTO{
ID: recurringDepositID,
AccountID: accountID,
Amount: amountCents,
Frequency: model.Frequency(frequencyStr),
StartDate: startDate,
EndDate: endDate,
Title: title,
})
if err != nil {
slog.Error("failed to update recurring deposit", "error", err, "id", recurringDepositID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
accounts, _ := h.accountService.GetAccountsForSpace(spaceID)
var accountName string
for _, acct := range accounts {
if acct.ID == updated.AccountID {
accountName = acct.Name
break
}
}
rdWithAccount := &model.RecurringDepositWithAccount{
RecurringDeposit: *updated,
AccountName: accountName,
}
ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts))
}
func (h *SpaceHandler) DeleteRecurringDeposit(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringDepositID := r.PathValue("recurringDepositID")
if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil {
return
}
if err := h.recurringDepositService.DeleteRecurringDeposit(recurringDepositID); err != nil {
slog.Error("failed to delete recurring deposit", "error", err, "id", recurringDepositID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Recurring deposit deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) ToggleRecurringDeposit(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
recurringDepositID := r.PathValue("recurringDepositID")
if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil {
return
}
updated, err := h.recurringDepositService.ToggleRecurringDeposit(recurringDepositID)
if err != nil {
slog.Error("failed to toggle recurring deposit", "error", err, "id", recurringDepositID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
accounts, _ := h.accountService.GetAccountsForSpace(spaceID)
var accountName string
for _, acct := range accounts {
if acct.ID == updated.AccountID {
accountName = acct.Name
break
}
}
rdWithAccount := &model.RecurringDepositWithAccount{
RecurringDeposit: *updated,
AccountName: accountName,
}
ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts))
}
// --- Payment Methods --- // --- Payment Methods ---
func (h *SpaceHandler) getMethodForSpace(w http.ResponseWriter, spaceID, methodID string) *model.PaymentMethod { func (h *SpaceHandler) getMethodForSpace(w http.ResponseWriter, spaceID, methodID string) *model.PaymentMethod {

View file

@ -23,18 +23,21 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB) accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
methodRepo := repository.NewPaymentMethodRepository(dbi.DB) methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
recurringRepo := repository.NewRecurringExpenseRepository(dbi.DB) recurringRepo := repository.NewRecurringExpenseRepository(dbi.DB)
recurringDepositRepo := repository.NewRecurringDepositRepository(dbi.DB)
budgetRepo := repository.NewBudgetRepository(dbi.DB) budgetRepo := repository.NewBudgetRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
expenseSvc := service.NewExpenseService(expenseRepo)
return NewSpaceHandler( return NewSpaceHandler(
service.NewSpaceService(spaceRepo), service.NewSpaceService(spaceRepo),
service.NewTagService(tagRepo), service.NewTagService(tagRepo),
service.NewShoppingListService(listRepo, itemRepo), service.NewShoppingListService(listRepo, itemRepo),
service.NewExpenseService(expenseRepo), expenseSvc,
service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc), service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc),
service.NewMoneyAccountService(accountRepo), service.NewMoneyAccountService(accountRepo),
service.NewPaymentMethodService(methodRepo), service.NewPaymentMethodService(methodRepo),
service.NewRecurringExpenseService(recurringRepo, expenseRepo), service.NewRecurringExpenseService(recurringRepo, expenseRepo),
service.NewRecurringDepositService(recurringDepositRepo, accountRepo, expenseSvc),
service.NewBudgetService(budgetRepo), service.NewBudgetService(budgetRepo),
service.NewReportService(expenseRepo), service.NewReportService(expenseRepo),
) )

View file

@ -19,13 +19,14 @@ type MoneyAccount struct {
} }
type AccountTransfer struct { type AccountTransfer struct {
ID string `db:"id"` ID string `db:"id"`
AccountID string `db:"account_id"` AccountID string `db:"account_id"`
AmountCents int `db:"amount_cents"` AmountCents int `db:"amount_cents"`
Direction TransferDirection `db:"direction"` Direction TransferDirection `db:"direction"`
Note string `db:"note"` Note string `db:"note"`
CreatedBy string `db:"created_by"` RecurringDepositID *string `db:"recurring_deposit_id"`
CreatedAt time.Time `db:"created_at"` CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
} }
type MoneyAccountWithBalance struct { type MoneyAccountWithBalance struct {

View file

@ -0,0 +1,24 @@
package model
import "time"
type RecurringDeposit struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
AccountID string `db:"account_id"`
AmountCents int `db:"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"`
Title string `db:"title"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type RecurringDepositWithAccount struct {
RecurringDeposit
AccountName string
}

View file

@ -91,8 +91,8 @@ func (r *moneyAccountRepository) Delete(id string) error {
} }
func (r *moneyAccountRepository) CreateTransfer(transfer *model.AccountTransfer) error { func (r *moneyAccountRepository) CreateTransfer(transfer *model.AccountTransfer) error {
query := `INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7);` query := `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);`
_, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt) _, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt)
return err return err
} }

View file

@ -0,0 +1,105 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringDepositNotFound = errors.New("recurring deposit not found")
)
type RecurringDepositRepository interface {
Create(rd *model.RecurringDeposit) error
GetByID(id string) (*model.RecurringDeposit, error)
GetBySpaceID(spaceID string) ([]*model.RecurringDeposit, error)
Update(rd *model.RecurringDeposit) error
Delete(id string) error
SetActive(id string, active bool) error
GetDueRecurrences(now time.Time) ([]*model.RecurringDeposit, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringDeposit, error)
UpdateNextOccurrence(id string, next time.Time) error
Deactivate(id string) error
}
type recurringDepositRepository struct {
db *sqlx.DB
}
func NewRecurringDepositRepository(db *sqlx.DB) RecurringDepositRepository {
return &recurringDepositRepository{db: db}
}
func (r *recurringDepositRepository) Create(rd *model.RecurringDeposit) error {
query := `INSERT INTO recurring_deposits (id, space_id, account_id, amount_cents, frequency, start_date, end_date, next_occurrence, is_active, title, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);`
_, err := r.db.Exec(query, rd.ID, rd.SpaceID, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.IsActive, rd.Title, rd.CreatedBy, rd.CreatedAt, rd.UpdatedAt)
return err
}
func (r *recurringDepositRepository) GetByID(id string) (*model.RecurringDeposit, error) {
rd := &model.RecurringDeposit{}
query := `SELECT * FROM recurring_deposits WHERE id = $1;`
err := r.db.Get(rd, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringDepositNotFound
}
return rd, err
}
func (r *recurringDepositRepository) GetBySpaceID(spaceID string) ([]*model.RecurringDeposit, error) {
var results []*model.RecurringDeposit
query := `SELECT * FROM recurring_deposits WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringDepositRepository) Update(rd *model.RecurringDeposit) error {
query := `UPDATE recurring_deposits SET account_id = $1, amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, title = $7, updated_at = $8 WHERE id = $9;`
result, err := r.db.Exec(query, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.Title, rd.UpdatedAt, rd.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrRecurringDepositNotFound
}
return err
}
func (r *recurringDepositRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM recurring_deposits WHERE id = $1;`, id)
return err
}
func (r *recurringDepositRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_deposits SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringDepositRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringDeposit, error) {
var results []*model.RecurringDeposit
query := `SELECT * FROM recurring_deposits WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringDepositRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringDeposit, error) {
var results []*model.RecurringDeposit
query := `SELECT * FROM recurring_deposits 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 *recurringDepositRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_deposits SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}
func (r *recurringDepositRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}

View file

@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
settings := handler.NewSettingsHandler(a.AuthService, a.UserService) settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, 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)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -157,6 +157,23 @@ func SetupRoutes(a *app.App) http.Handler {
deleteTransferWithAuth := middleware.RequireAuth(deleteTransferHandler) deleteTransferWithAuth := middleware.RequireAuth(deleteTransferHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", crudLimiter(deleteTransferWithAuth)) mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", crudLimiter(deleteTransferWithAuth))
// Recurring Deposit routes
createRecurringDepositHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateRecurringDeposit)
createRecurringDepositWithAuth := middleware.RequireAuth(createRecurringDepositHandler)
mux.Handle("POST /app/spaces/{spaceID}/accounts/recurring", crudLimiter(createRecurringDepositWithAuth))
updateRecurringDepositHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateRecurringDeposit)
updateRecurringDepositWithAuth := middleware.RequireAuth(updateRecurringDepositHandler)
mux.Handle("PATCH /app/spaces/{spaceID}/accounts/recurring/{recurringDepositID}", crudLimiter(updateRecurringDepositWithAuth))
deleteRecurringDepositHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteRecurringDeposit)
deleteRecurringDepositWithAuth := middleware.RequireAuth(deleteRecurringDepositHandler)
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/recurring/{recurringDepositID}", crudLimiter(deleteRecurringDepositWithAuth))
toggleRecurringDepositHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ToggleRecurringDeposit)
toggleRecurringDepositWithAuth := middleware.RequireAuth(toggleRecurringDepositHandler)
mux.Handle("POST /app/spaces/{spaceID}/accounts/recurring/{recurringDepositID}/toggle", crudLimiter(toggleRecurringDepositWithAuth))
// Payment Method routes // Payment Method routes
methodsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.PaymentMethodsPage) methodsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.PaymentMethodsPage)
methodsPageWithAuth := middleware.RequireAuth(methodsPageHandler) methodsPageWithAuth := middleware.RequireAuth(methodsPageHandler)

View file

@ -9,14 +9,16 @@ import (
) )
type Scheduler struct { type Scheduler struct {
recurringService *service.RecurringExpenseService recurringService *service.RecurringExpenseService
interval time.Duration recurringDepositService *service.RecurringDepositService
interval time.Duration
} }
func New(recurringService *service.RecurringExpenseService) *Scheduler { func New(recurringService *service.RecurringExpenseService, recurringDepositService *service.RecurringDepositService) *Scheduler {
return &Scheduler{ return &Scheduler{
recurringService: recurringService, recurringService: recurringService,
interval: 1 * time.Hour, recurringDepositService: recurringDepositService,
interval: 1 * time.Hour,
} }
} }
@ -39,9 +41,15 @@ func (s *Scheduler) Start(ctx context.Context) {
} }
func (s *Scheduler) run() { func (s *Scheduler) run() {
slog.Info("scheduler: processing due recurring expenses")
now := time.Now() now := time.Now()
slog.Info("scheduler: processing due recurring expenses")
if err := s.recurringService.ProcessDueRecurrences(now); err != nil { if err := s.recurringService.ProcessDueRecurrences(now); err != nil {
slog.Error("scheduler: failed to process recurring expenses", "error", err) slog.Error("scheduler: failed to process recurring expenses", "error", err)
} }
slog.Info("scheduler: processing due recurring deposits")
if err := s.recurringDepositService.ProcessDueRecurrences(now); err != nil {
slog.Error("scheduler: failed to process recurring deposits", "error", err)
}
} }

View file

@ -0,0 +1,246 @@
package service
import (
"fmt"
"log/slog"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreateRecurringDepositDTO struct {
SpaceID string
AccountID string
Amount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
Title string
CreatedBy string
}
type UpdateRecurringDepositDTO struct {
ID string
AccountID string
Amount int
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
Title string
}
type RecurringDepositService struct {
recurringRepo repository.RecurringDepositRepository
accountRepo repository.MoneyAccountRepository
expenseService *ExpenseService
}
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService) *RecurringDepositService {
return &RecurringDepositService{
recurringRepo: recurringRepo,
accountRepo: accountRepo,
expenseService: expenseService,
}
}
func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
rd := &model.RecurringDeposit{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
AccountID: dto.AccountID,
AmountCents: dto.Amount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
Title: strings.TrimSpace(dto.Title),
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.recurringRepo.Create(rd); err != nil {
return nil, err
}
return rd, nil
}
func (s *RecurringDepositService) GetRecurringDeposit(id string) (*model.RecurringDeposit, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringDepositService) GetRecurringDepositsForSpace(spaceID string) ([]*model.RecurringDeposit, error) {
return s.recurringRepo.GetBySpaceID(spaceID)
}
func (s *RecurringDepositService) GetRecurringDepositsWithAccountsForSpace(spaceID string) ([]*model.RecurringDepositWithAccount, error) {
deposits, err := s.recurringRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
accountNames := make(map[string]string, len(accounts))
for _, acct := range accounts {
accountNames[acct.ID] = acct.Name
}
result := make([]*model.RecurringDepositWithAccount, len(deposits))
for i, rd := range deposits {
result[i] = &model.RecurringDepositWithAccount{
RecurringDeposit: *rd,
AccountName: accountNames[rd.AccountID],
}
}
return result, nil
}
func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.AccountID = dto.AccountID
existing.AmountCents = dto.Amount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.Title = strings.TrimSpace(dto.Title)
existing.UpdatedAt = time.Now()
// Recalculate next occurrence if start date moved forward
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
if err := s.recurringRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringDepositService) DeleteRecurringDeposit(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringDepositService) ToggleRecurringDeposit(id string) (*model.RecurringDeposit, error) {
rd, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !rd.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
rd.IsActive = newActive
return rd, nil
}
func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurring deposits: %w", err)
}
for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
return nil
}
func (s *RecurringDepositService) 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 deposits for space: %w", err)
}
for _, rd := range dues {
if err := s.processRecurrence(rd, now); err != nil {
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
}
}
return nil
}
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get space balance: %w", err)
}
totalAllocated, err := s.accountRepo.GetTotalAllocatedForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get total allocated: %w", err)
}
return totalBalance - totalAllocated, nil
}
func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit, now time.Time) error {
for !rd.NextOccurrence.After(now) {
// Check if end_date has been passed
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
return s.recurringRepo.Deactivate(rd.ID)
}
// Check available balance
availableBalance, err := s.getAvailableBalance(rd.SpaceID)
if err != nil {
return err
}
if availableBalance >= rd.AmountCents {
rdID := rd.ID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: rd.AccountID,
AmountCents: rd.AmountCents,
Direction: model.TransferDirectionDeposit,
Note: rd.Title,
RecurringDepositID: &rdID,
CreatedBy: rd.CreatedBy,
CreatedAt: time.Now(),
}
if err := s.accountRepo.CreateTransfer(transfer); err != nil {
return fmt.Errorf("failed to create deposit transfer: %w", err)
}
} else {
slog.Warn("recurring deposit skipped: insufficient available balance",
"recurring_deposit_id", rd.ID,
"space_id", rd.SpaceID,
"needed", rd.AmountCents,
"available", availableBalance,
)
}
rd.NextOccurrence = AdvanceDate(rd.NextOccurrence, rd.Frequency)
}
// Check if the new next occurrence exceeds end date
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
if err := s.recurringRepo.Deactivate(rd.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(rd.ID, rd.NextOccurrence)
}

View file

@ -5,12 +5,31 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" "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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
) )
func frequencyLabel(f model.Frequency) string {
switch f {
case model.FrequencyDaily:
return "Daily"
case model.FrequencyWeekly:
return "Weekly"
case model.FrequencyBiweekly:
return "Biweekly"
case model.FrequencyMonthly:
return "Monthly"
case model.FrequencyYearly:
return "Yearly"
default:
return string(f)
}
}
templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, oob bool) { templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, oob bool) {
<div <div
id="accounts-balance-summary" id="accounts-balance-summary"
@ -235,9 +254,9 @@ templ TransferForm(spaceID string, accountID string, direction model.TransferDir
Amount Amount
} }
@input.Input(input.Props{ @input.Input(input.Props{
Name: "amount", Name: "amount",
ID: "transfer-amount-" + accountID + "-" + string(direction), ID: "transfer-amount-" + accountID + "-" + string(direction),
Type: "number", Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"}, Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
}) })
<p id={ errorID } class="text-sm text-destructive mt-1"></p> <p id={ errorID } class="text-sm text-destructive mt-1"></p>
@ -263,3 +282,376 @@ templ TransferForm(spaceID string, accountID string, direction model.TransferDir
</div> </div>
</form> </form>
} }
templ RecurringDepositsSection(spaceID string, deposits []*model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) {
<div class="space-y-4 mt-8">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">Recurring Deposits</h2>
if len(accounts) > 0 {
@dialog.Dialog(dialog.Props{ID: "add-recurring-deposit-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Recurring Deposit
}
@dialog.Description() {
Automatically deposit into an account on a schedule.
}
}
@AddRecurringDepositForm(spaceID, accounts, "add-recurring-deposit-dialog")
}
}
}
</div>
<div class="border rounded-lg">
<div id="recurring-deposits-list" class="divide-y">
if len(deposits) == 0 {
<p class="p-4 text-sm text-muted-foreground">No recurring deposits set up yet.</p>
}
for _, rd := range deposits {
@RecurringDepositItem(spaceID, rd, accounts)
}
</div>
</div>
</div>
}
templ RecurringDepositItem(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) {
{{ editDialogID := "edit-rd-" + rd.ID }}
{{ delDialogID := "del-rd-" + rd.ID }}
<div
id={ "recurring-deposit-" + rd.ID }
class={ "flex items-center justify-between p-4 gap-4", templ.KV("opacity-50", !rd.IsActive) }
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium truncate">
if rd.Title != "" {
{ rd.Title }
} else {
Deposit to { rd.AccountName }
}
</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{ frequencyLabel(rd.Frequency) }
</span>
if !rd.IsActive {
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
Paused
</span>
}
</div>
<div class="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{ rd.AccountName }</span>
<span>&middot;</span>
<span>Next: { rd.NextOccurrence.Format("Jan 2, 2006") }</span>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(rd.AmountCents)/100.0) }
</span>
// Toggle
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7",
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s/toggle", spaceID, rd.ID),
"hx-target": "#recurring-deposit-" + rd.ID,
"hx-swap": "outerHTML",
},
}) {
if rd.IsActive {
@icon.Pause(icon.Props{Size: 14})
} else {
@icon.Play(icon.Props{Size: 14})
}
}
// Edit
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Pencil(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Edit Recurring Deposit
}
@dialog.Description() {
Update the recurring deposit settings.
}
}
@EditRecurringDepositForm(spaceID, rd, accounts, editDialogID)
}
}
// Delete
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Recurring Deposit
}
@dialog.Description() {
Are you sure? This will not affect past deposits already made.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID),
"hx-target": "#recurring-deposit-" + rd.ID,
"hx-swap": "delete",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithBalance, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/accounts/recurring" }
hx-target="#recurring-deposits-list"
hx-swap="beforeend"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
// Account
<div>
@label.Label(label.Props{}) {
Account
}
@selectbox.SelectBox(selectbox.Props{ID: "rd-account"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "account_id"}) {
@selectbox.Value()
}
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
for i, acct := range accounts {
@selectbox.Item(selectbox.ItemProps{Value: acct.ID, Selected: i == 0}) {
{ acct.Name }
}
}
}
}
</div>
// Amount
<div>
@label.Label(label.Props{For: "rd-amount"}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "rd-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{}) {
Frequency
}
@selectbox.SelectBox(selectbox.Props{ID: "rd-frequency"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
@selectbox.Value()
}
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
@selectbox.Item(selectbox.ItemProps{Value: "daily"}) {
Daily
}
@selectbox.Item(selectbox.ItemProps{Value: "weekly"}) {
Weekly
}
@selectbox.Item(selectbox.ItemProps{Value: "biweekly"}) {
Biweekly
}
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: true}) {
Monthly
}
@selectbox.Item(selectbox.ItemProps{Value: "yearly"}) {
Yearly
}
}
}
</div>
// Start Date
<div>
@label.Label(label.Props{For: "rd-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "rd-start-date",
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "rd-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "rd-end-date",
Name: "end_date",
})
</div>
// Title (optional)
<div>
@label.Label(label.Props{For: "rd-title"}) {
Title (optional)
}
@input.Input(input.Props{
Name: "title",
ID: "rd-title",
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) {
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID) }
hx-target={ "#recurring-deposit-" + rd.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Account
<div>
@label.Label(label.Props{}) {
Account
}
@selectbox.SelectBox(selectbox.Props{ID: "edit-rd-account-" + rd.ID}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "account_id"}) {
@selectbox.Value()
}
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
for _, acct := range accounts {
@selectbox.Item(selectbox.ItemProps{Value: acct.ID, Selected: acct.ID == rd.AccountID}) {
{ acct.Name }
}
}
}
}
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-rd-amount-" + rd.ID}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-rd-amount-" + rd.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(rd.AmountCents)/100.0),
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{}) {
Frequency
}
@selectbox.SelectBox(selectbox.Props{ID: "edit-rd-frequency-" + rd.ID}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
@selectbox.Value()
}
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
@selectbox.Item(selectbox.ItemProps{Value: "daily", Selected: rd.Frequency == model.FrequencyDaily}) {
Daily
}
@selectbox.Item(selectbox.ItemProps{Value: "weekly", Selected: rd.Frequency == model.FrequencyWeekly}) {
Weekly
}
@selectbox.Item(selectbox.ItemProps{Value: "biweekly", Selected: rd.Frequency == model.FrequencyBiweekly}) {
Biweekly
}
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: rd.Frequency == model.FrequencyMonthly}) {
Monthly
}
@selectbox.Item(selectbox.ItemProps{Value: "yearly", Selected: rd.Frequency == model.FrequencyYearly}) {
Yearly
}
}
}
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-rd-start-date-" + rd.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-rd-start-date-" + rd.ID,
Name: "start_date",
Value: rd.StartDate,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "edit-rd-end-date-" + rd.ID}) {
End Date (optional)
}
if rd.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-rd-end-date-" + rd.ID,
Name: "end_date",
Value: *rd.EndDate,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-rd-end-date-" + rd.ID,
Name: "end_date",
})
}
</div>
// Title (optional)
<div>
@label.Label(label.Props{For: "edit-rd-title-" + rd.ID}) {
Title (optional)
}
@input.Input(input.Props{
Name: "title",
ID: "edit-rd-title-" + rd.ID,
Value: rd.Title,
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}

View file

@ -8,7 +8,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
) )
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int) { templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, recurringDeposits []*model.RecurringDepositWithAccount) {
@layouts.Space("Accounts", space) { @layouts.Space("Accounts", space) {
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -41,6 +41,7 @@ templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBal
@moneyaccount.AccountCard(space.ID, &acct) @moneyaccount.AccountCard(space.ID, &acct)
} }
</div> </div>
@moneyaccount.RecurringDepositsSection(space.ID, recurringDeposits, accounts)
</div> </div>
} }
} }