Merge branch 'feat/recurring-account-deposit'
This commit is contained in:
commit
b34f336aea
14 changed files with 1128 additions and 46 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -31,11 +31,12 @@ type SpaceHandler struct {
|
||||||
accountService *service.MoneyAccountService
|
accountService *service.MoneyAccountService
|
||||||
methodService *service.PaymentMethodService
|
methodService *service.PaymentMethodService
|
||||||
recurringService *service.RecurringExpenseService
|
recurringService *service.RecurringExpenseService
|
||||||
|
recurringDepositService *service.RecurringDepositService
|
||||||
budgetService *service.BudgetService
|
budgetService *service.BudgetService
|
||||||
reportService *service.ReportService
|
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,
|
||||||
|
|
@ -45,6 +46,7 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
|
||||||
accountService: mas,
|
accountService: mas,
|
||||||
methodService: pms,
|
methodService: pms,
|
||||||
recurringService: rs,
|
recurringService: rs,
|
||||||
|
recurringDepositService: rds,
|
||||||
budgetService: bs,
|
budgetService: bs,
|
||||||
reportService: rps,
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type AccountTransfer struct {
|
||||||
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"`
|
||||||
|
RecurringDepositID *string `db:"recurring_deposit_id"`
|
||||||
CreatedBy string `db:"created_by"`
|
CreatedBy string `db:"created_by"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
internal/model/recurring_deposit.go
Normal file
24
internal/model/recurring_deposit.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
105
internal/repository/recurring_deposit.go
Normal file
105
internal/repository/recurring_deposit.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ import (
|
||||||
|
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
recurringService *service.RecurringExpenseService
|
recurringService *service.RecurringExpenseService
|
||||||
|
recurringDepositService *service.RecurringDepositService
|
||||||
interval time.Duration
|
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,
|
||||||
|
recurringDepositService: recurringDepositService,
|
||||||
interval: 1 * time.Hour,
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
246
internal/service/recurring_deposit.go
Normal file
246
internal/service/recurring_deposit.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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>·</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>
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue