diff --git a/internal/db/migrations/00018_add_decimal_amount_columns.sql b/internal/db/migrations/00018_add_decimal_amount_columns.sql new file mode 100644 index 0000000..bad2b44 --- /dev/null +++ b/internal/db/migrations/00018_add_decimal_amount_columns.sql @@ -0,0 +1,54 @@ +-- +goose Up + +-- expenses +ALTER TABLE expenses ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE expenses SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- account_transfers +ALTER TABLE account_transfers ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE account_transfers SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- recurring_expenses +ALTER TABLE recurring_expenses ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE recurring_expenses SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- budgets +ALTER TABLE budgets ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE budgets SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- recurring_deposits +ALTER TABLE recurring_deposits ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE recurring_deposits SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- loans +ALTER TABLE loans ADD COLUMN original_amount TEXT NOT NULL DEFAULT '0'; +UPDATE loans SET original_amount = CAST(original_amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(original_amount_cents) % 100 AS TEXT), -2, 2); + +-- recurring_receipts +ALTER TABLE recurring_receipts ADD COLUMN total_amount TEXT NOT NULL DEFAULT '0'; +UPDATE recurring_receipts SET total_amount = CAST(total_amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(total_amount_cents) % 100 AS TEXT), -2, 2); + +-- recurring_receipt_sources +ALTER TABLE recurring_receipt_sources ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE recurring_receipt_sources SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- receipts +ALTER TABLE receipts ADD COLUMN total_amount TEXT NOT NULL DEFAULT '0'; +UPDATE receipts SET total_amount = CAST(total_amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(total_amount_cents) % 100 AS TEXT), -2, 2); + +-- receipt_funding_sources +ALTER TABLE receipt_funding_sources ADD COLUMN amount TEXT NOT NULL DEFAULT '0'; +UPDATE receipt_funding_sources SET amount = CAST(amount_cents / 100 AS TEXT) || '.' || SUBSTR('00' || CAST(ABS(amount_cents) % 100 AS TEXT), -2, 2); + +-- +goose Down +-- SQLite does not support DROP COLUMN in older versions, but modernc.org/sqlite supports it. +ALTER TABLE expenses DROP COLUMN amount; +ALTER TABLE account_transfers DROP COLUMN amount; +ALTER TABLE recurring_expenses DROP COLUMN amount; +ALTER TABLE budgets DROP COLUMN amount; +ALTER TABLE recurring_deposits DROP COLUMN amount; +ALTER TABLE loans DROP COLUMN original_amount; +ALTER TABLE recurring_receipts DROP COLUMN total_amount; +ALTER TABLE recurring_receipt_sources DROP COLUMN amount; +ALTER TABLE receipts DROP COLUMN total_amount; +ALTER TABLE receipt_funding_sources DROP COLUMN amount; diff --git a/internal/handler/account_handler.go b/internal/handler/account_handler.go index 9982148..f95940e 100644 --- a/internal/handler/account_handler.go +++ b/internal/handler/account_handler.go @@ -73,7 +73,7 @@ func (h *AccountHandler) AccountsPage(w http.ResponseWriter, r *http.Request) { return } - availableBalance := totalBalance - totalAllocated + availableBalance := totalBalance.Sub(totalAllocated) transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) if err != nil { @@ -113,7 +113,7 @@ func (h *AccountHandler) CreateAccount(w http.ResponseWriter, r *http.Request) { acctWithBalance := model.MoneyAccountWithBalance{ MoneyAccount: *account, - BalanceCents: 0, + Balance: decimal.Zero, } ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) @@ -157,7 +157,7 @@ func (h *AccountHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { acctWithBalance := model.MoneyAccountWithBalance{ MoneyAccount: *updatedAccount, - BalanceCents: balance, + Balance: balance, } ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) @@ -188,7 +188,7 @@ func (h *AccountHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) } - ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) + ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true)) ui.RenderToast(w, r, toast.Toast(toast.Props{ Title: "Account deleted", Variant: toast.VariantSuccess, @@ -221,7 +221,7 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal // Calculate available space balance for deposit validation totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID) @@ -236,11 +236,11 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - availableBalance := totalBalance - totalAllocated + availableBalance := totalBalance.Sub(totalAllocated) // Validate balance limits before creating transfer - if direction == model.TransferDirectionDeposit && amountCents > availableBalance { - ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0), http.StatusUnprocessableEntity) + if direction == model.TransferDirectionDeposit && amount.GreaterThan(availableBalance) { + ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to %s.", model.FormatMoney(availableBalance)), http.StatusUnprocessableEntity) return } @@ -251,15 +251,15 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - if amountCents > acctBalance { - ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0), http.StatusUnprocessableEntity) + if amount.GreaterThan(acctBalance) { + ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to %s.", model.FormatMoney(acctBalance)), http.StatusUnprocessableEntity) return } } _, err = h.accountService.CreateTransfer(service.CreateTransferDTO{ AccountID: accountID, - Amount: amountCents, + Amount: amount, Direction: direction, Note: note, CreatedBy: user.ID, @@ -281,12 +281,12 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) account, _ := h.accountService.GetAccount(accountID) acctWithBalance := model.MoneyAccountWithBalance{ MoneyAccount: *account, - BalanceCents: accountBalance, + Balance: accountBalance, } // Recalculate available balance after transfer totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID) - newAvailable := totalBalance - totalAllocated + newAvailable := totalBalance.Sub(totalAllocated) w.Header().Set("HX-Trigger", "transferSuccess") ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) @@ -323,14 +323,14 @@ func (h *AccountHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) account, _ := h.accountService.GetAccount(accountID) acctWithBalance := model.MoneyAccountWithBalance{ MoneyAccount: *account, - BalanceCents: accountBalance, + Balance: accountBalance, } totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID) totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID) ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) - ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) + ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true)) transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) diff --git a/internal/handler/budget_handler.go b/internal/handler/budget_handler.go index 8095267..ff5a998 100644 --- a/internal/handler/budget_handler.go +++ b/internal/handler/budget_handler.go @@ -106,7 +106,7 @@ func (h *BudgetHandler) CreateBudget(w http.ResponseWriter, r *http.Request) { ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal startDate, err := time.Parse("2006-01-02", startDateStr) if err != nil { @@ -127,7 +127,7 @@ func (h *BudgetHandler) CreateBudget(w http.ResponseWriter, r *http.Request) { _, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{ SpaceID: spaceID, TagIDs: tagIDs, - Amount: amountCents, + Amount: amount, Period: model.BudgetPeriod(periodStr), StartDate: startDate, EndDate: endDate, @@ -186,7 +186,7 @@ func (h *BudgetHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) { ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal startDate, err := time.Parse("2006-01-02", startDateStr) if err != nil { @@ -207,7 +207,7 @@ func (h *BudgetHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) { _, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{ ID: budgetID, TagIDs: tagIDs, - Amount: amountCents, + Amount: amount, Period: model.BudgetPeriod(periodStr), StartDate: startDate, EndDate: endDate, diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index 9948894..25bde90 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -81,9 +81,9 @@ func (h *ExpenseHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - totalAllocated = 0 + totalAllocated = decimal.Zero } - balance -= totalAllocated + balance = balance.Sub(totalAllocated) tags, err := h.tagService.GetTagsForSpace(spaceID) if err != nil { @@ -147,7 +147,7 @@ func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal date, err := time.Parse("2006-01-02", dateStr) if err != nil { @@ -220,7 +220,7 @@ func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { SpaceID: spaceID, UserID: user.ID, Description: description, - Amount: amountCents, + Amount: amount, Type: expenseType, Date: date, TagIDs: finalTagIDs, @@ -263,9 +263,9 @@ func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - totalAllocated = 0 + totalAllocated = decimal.Zero } - balance -= totalAllocated + balance = balance.Sub(totalAllocated) // Return the full paginated list for page 1 so the new expense appears expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, 1) @@ -317,7 +317,7 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal date, err := time.Parse("2006-01-02", dateStr) if err != nil { @@ -377,7 +377,7 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { ID: expenseID, SpaceID: spaceID, Description: description, - Amount: amountCents, + Amount: amount, Type: expenseType, Date: date, TagIDs: finalTagIDs, @@ -407,9 +407,9 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - totalAllocated = 0 + totalAllocated = decimal.Zero } - balance -= totalAllocated + balance = balance.Sub(totalAllocated) methods, _ := h.methodService.GetMethodsForSpace(spaceID) updatedTags, _ := h.tagService.GetTagsForSpace(spaceID) @@ -438,9 +438,9 @@ func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - totalAllocated = 0 + totalAllocated = decimal.Zero } - balance -= totalAllocated + balance = balance.Sub(totalAllocated) ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, true)) ui.RenderToast(w, r, toast.Toast(toast.Props{ @@ -485,9 +485,9 @@ func (h *ExpenseHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - totalAllocated = 0 + totalAllocated = decimal.Zero } - balance -= totalAllocated + balance = balance.Sub(totalAllocated) ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, false)) } diff --git a/internal/handler/recurring_handler.go b/internal/handler/recurring_handler.go index 70d4eae..3f638c1 100644 --- a/internal/handler/recurring_handler.go +++ b/internal/handler/recurring_handler.go @@ -107,7 +107,7 @@ func (h *RecurringHandler) CreateRecurringExpense(w http.ResponseWriter, r *http ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal startDate, err := time.Parse("2006-01-02", startDateStr) if err != nil { @@ -176,7 +176,7 @@ func (h *RecurringHandler) CreateRecurringExpense(w http.ResponseWriter, r *http SpaceID: spaceID, UserID: user.ID, Description: description, - Amount: amountCents, + Amount: amount, Type: expenseType, PaymentMethodID: paymentMethodID, Frequency: frequency, @@ -235,7 +235,7 @@ func (h *RecurringHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) return } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + amount := amountDecimal startDate, err := time.Parse("2006-01-02", startDateStr) if err != nil { @@ -290,7 +290,7 @@ func (h *RecurringHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{ ID: recurringID, Description: description, - Amount: amountCents, + Amount: amount, Type: model.ExpenseType(typeStr), PaymentMethodID: paymentMethodID, Frequency: model.Frequency(frequencyStr), diff --git a/internal/handler/space.go b/internal/handler/space.go index 56473a9..6959405 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/shopspring/decimal" + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/service" @@ -112,9 +114,9 @@ func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) { allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) if err != nil { slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) - allocated = 0 + allocated = decimal.Zero } - balance -= allocated + balance = balance.Sub(allocated) // This month's report now := time.Now() diff --git a/internal/handler/space_loans.go b/internal/handler/space_loans.go index 4c2f041..50b915a 100644 --- a/internal/handler/space_loans.go +++ b/internal/handler/space_loans.go @@ -62,8 +62,6 @@ func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) return } - amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - interestStr := r.FormValue("interest_rate") var interestBps int if interestStr != "" { @@ -93,7 +91,7 @@ func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) { UserID: user.ID, Name: name, Description: description, - OriginalAmount: amountCents, + OriginalAmount: amount, InterestRateBps: interestBps, StartDate: startDate, EndDate: endDate, @@ -165,7 +163,7 @@ func (h *SpaceHandler) LoanDetailPage(w http.ResponseWriter, r *http.Request) { balance, err := h.expenseService.GetBalanceForSpace(spaceID) if err != nil { slog.Error("failed to get balance", "error", err) - balance = 0 + balance = decimal.Zero } ui.Render(w, r, pages.SpaceLoanDetailPage(space, loan, receipts, page, totalPages, recurringReceipts, accounts, balance)) @@ -190,8 +188,6 @@ func (h *SpaceHandler) UpdateLoan(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) return } - amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - interestStr := r.FormValue("interest_rate") var interestBps int if interestStr != "" { @@ -220,7 +216,7 @@ func (h *SpaceHandler) UpdateLoan(w http.ResponseWriter, r *http.Request) { ID: loanID, Name: name, Description: description, - OriginalAmount: amountCents, + OriginalAmount: amount, InterestRateBps: interestBps, StartDate: startDate, EndDate: endDate, @@ -267,8 +263,6 @@ func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) return } - totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - dateStr := r.FormValue("date") date, err := time.Parse("2006-01-02", dateStr) if err != nil { @@ -288,7 +282,7 @@ func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) { SpaceID: spaceID, UserID: user.ID, Description: description, - TotalAmount: totalAmountCents, + TotalAmount: amount, Date: date, FundingSources: fundingSources, } @@ -320,8 +314,6 @@ func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) return } - totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - dateStr := r.FormValue("date") date, err := time.Parse("2006-01-02", dateStr) if err != nil { @@ -340,7 +332,7 @@ func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) { SpaceID: spaceID, UserID: user.ID, Description: description, - TotalAmount: totalAmountCents, + TotalAmount: amount, Date: date, FundingSources: fundingSources, } @@ -405,8 +397,6 @@ func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusUnprocessableEntity) return } - totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - frequency := model.Frequency(r.FormValue("frequency")) startDateStr := r.FormValue("start_date") @@ -436,7 +426,7 @@ func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Req SpaceID: spaceID, UserID: user.ID, Description: description, - TotalAmount: totalAmountCents, + TotalAmount: amount, Frequency: frequency, StartDate: startDate, EndDate: endDate, @@ -468,8 +458,6 @@ func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Req w.WriteHeader(http.StatusUnprocessableEntity) return } - totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - frequency := model.Frequency(r.FormValue("frequency")) startDateStr := r.FormValue("start_date") @@ -497,7 +485,7 @@ func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Req dto := service.UpdateRecurringReceiptDTO{ ID: recurringReceiptID, Description: description, - TotalAmount: totalAmountCents, + TotalAmount: amount, Frequency: frequency, StartDate: startDate, EndDate: endDate, @@ -570,11 +558,9 @@ func parseFundingSources(r *http.Request) ([]service.FundingSourceDTO, error) { if err != nil || amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("invalid funding source amount") } - amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart()) - src := service.FundingSourceDTO{ SourceType: model.FundingSourceType(srcType), - Amount: amountCents, + Amount: amount, } if srcType == string(model.FundingSourceAccount) { diff --git a/internal/model/budget.go b/internal/model/budget.go index 1519abf..8f5f220 100644 --- a/internal/model/budget.go +++ b/internal/model/budget.go @@ -3,6 +3,8 @@ package model import ( "strings" "time" + + "github.com/shopspring/decimal" ) type BudgetPeriod string @@ -22,22 +24,23 @@ const ( ) type Budget struct { - ID string `db:"id"` - SpaceID string `db:"space_id"` - AmountCents int `db:"amount_cents"` - Period BudgetPeriod `db:"period"` - StartDate time.Time `db:"start_date"` - EndDate *time.Time `db:"end_date"` - IsActive bool `db:"is_active"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility + Period BudgetPeriod `db:"period"` + StartDate time.Time `db:"start_date"` + EndDate *time.Time `db:"end_date"` + IsActive bool `db:"is_active"` + CreatedBy string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type BudgetWithSpent struct { Budget Tags []*Tag - SpentCents int + Spent decimal.Decimal Percentage float64 Status BudgetStatus } diff --git a/internal/model/expense.go b/internal/model/expense.go index c2134ce..2adef6e 100644 --- a/internal/model/expense.go +++ b/internal/model/expense.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type ExpenseType string @@ -10,17 +14,18 @@ const ( ) type Expense struct { - ID string `db:"id"` - SpaceID string `db:"space_id"` - CreatedBy string `db:"created_by"` - Description string `db:"description"` - AmountCents int `db:"amount_cents"` - Type ExpenseType `db:"type"` - Date time.Time `db:"date"` - PaymentMethodID *string `db:"payment_method_id"` - RecurringExpenseID *string `db:"recurring_expense_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + CreatedBy string `db:"created_by"` + Description string `db:"description"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility + Type ExpenseType `db:"type"` + Date time.Time `db:"date"` + PaymentMethodID *string `db:"payment_method_id"` + RecurringExpenseID *string `db:"recurring_expense_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type ExpenseWithTags struct { @@ -45,8 +50,8 @@ type ExpenseItem struct { } type TagExpenseSummary struct { - TagID string `db:"tag_id"` - TagName string `db:"tag_name"` - TagColor *string `db:"tag_color"` - TotalAmount int `db:"total_amount"` + TagID string `db:"tag_id"` + TagName string `db:"tag_name"` + TagColor *string `db:"tag_color"` + TotalAmount decimal.Decimal `db:"total_amount"` } diff --git a/internal/model/loan.go b/internal/model/loan.go index 5c915b6..31fff9b 100644 --- a/internal/model/loan.go +++ b/internal/model/loan.go @@ -1,25 +1,30 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type Loan struct { - ID string `db:"id"` - SpaceID string `db:"space_id"` - Name string `db:"name"` - Description string `db:"description"` - OriginalAmountCents int `db:"original_amount_cents"` - InterestRateBps int `db:"interest_rate_bps"` - StartDate time.Time `db:"start_date"` - EndDate *time.Time `db:"end_date"` - IsPaidOff bool `db:"is_paid_off"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + Name string `db:"name"` + Description string `db:"description"` + OriginalAmount decimal.Decimal `db:"original_amount"` + OriginalAmountCents int `db:"original_amount_cents"` // deprecated: kept for SELECT * compatibility + InterestRateBps int `db:"interest_rate_bps"` + StartDate time.Time `db:"start_date"` + EndDate *time.Time `db:"end_date"` + IsPaidOff bool `db:"is_paid_off"` + CreatedBy string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type LoanWithPaymentSummary struct { Loan - TotalPaidCents int - RemainingCents int - ReceiptCount int + TotalPaid decimal.Decimal + Remaining decimal.Decimal + ReceiptCount int } diff --git a/internal/model/money.go b/internal/model/money.go new file mode 100644 index 0000000..426c5ac --- /dev/null +++ b/internal/model/money.go @@ -0,0 +1,17 @@ +package model + +import ( + "fmt" + + "github.com/shopspring/decimal" +) + +// FormatMoney formats a decimal as a dollar string like "$12.50" +func FormatMoney(d decimal.Decimal) string { + return fmt.Sprintf("$%s", d.StringFixed(2)) +} + +// FormatDecimal formats a decimal for form input values like "12.50" +func FormatDecimal(d decimal.Decimal) string { + return d.StringFixed(2) +} diff --git a/internal/model/money_account.go b/internal/model/money_account.go index 3682c82..bdbf209 100644 --- a/internal/model/money_account.go +++ b/internal/model/money_account.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type TransferDirection string @@ -21,7 +25,8 @@ type MoneyAccount struct { type AccountTransfer struct { ID string `db:"id"` AccountID string `db:"account_id"` - AmountCents int `db:"amount_cents"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility Direction TransferDirection `db:"direction"` Note string `db:"note"` RecurringDepositID *string `db:"recurring_deposit_id"` @@ -31,7 +36,7 @@ type AccountTransfer struct { type MoneyAccountWithBalance struct { MoneyAccount - BalanceCents int + Balance decimal.Decimal } type AccountTransferWithAccount struct { diff --git a/internal/model/receipt.go b/internal/model/receipt.go index 30bfc80..cc4544d 100644 --- a/internal/model/receipt.go +++ b/internal/model/receipt.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type FundingSourceType string @@ -10,16 +14,17 @@ const ( ) type Receipt struct { - ID string `db:"id"` - LoanID string `db:"loan_id"` - SpaceID string `db:"space_id"` - Description string `db:"description"` - TotalAmountCents int `db:"total_amount_cents"` - Date time.Time `db:"date"` - RecurringReceiptID *string `db:"recurring_receipt_id"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + LoanID string `db:"loan_id"` + SpaceID string `db:"space_id"` + Description string `db:"description"` + TotalAmount decimal.Decimal `db:"total_amount"` + TotalAmountCents int `db:"total_amount_cents"` // deprecated: kept for SELECT * compatibility + Date time.Time `db:"date"` + RecurringReceiptID *string `db:"recurring_receipt_id"` + CreatedBy string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type ReceiptFundingSource struct { @@ -27,7 +32,8 @@ type ReceiptFundingSource struct { ReceiptID string `db:"receipt_id"` SourceType FundingSourceType `db:"source_type"` AccountID *string `db:"account_id"` - AmountCents int `db:"amount_cents"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility LinkedExpenseID *string `db:"linked_expense_id"` LinkedTransferID *string `db:"linked_transfer_id"` } diff --git a/internal/model/recurring_expense.go b/internal/model/recurring_expense.go index bb2ad5c..4fd7bca 100644 --- a/internal/model/recurring_expense.go +++ b/internal/model/recurring_expense.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type Frequency string @@ -13,20 +17,21 @@ const ( ) type RecurringExpense struct { - ID string `db:"id"` - SpaceID string `db:"space_id"` - CreatedBy string `db:"created_by"` - Description string `db:"description"` - AmountCents int `db:"amount_cents"` - Type ExpenseType `db:"type"` - PaymentMethodID *string `db:"payment_method_id"` - 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"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + CreatedBy string `db:"created_by"` + Description string `db:"description"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility + Type ExpenseType `db:"type"` + PaymentMethodID *string `db:"payment_method_id"` + 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"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type RecurringExpenseWithTags struct { diff --git a/internal/model/recurring_receipt.go b/internal/model/recurring_receipt.go index 5c8b909..10fa1bd 100644 --- a/internal/model/recurring_receipt.go +++ b/internal/model/recurring_receipt.go @@ -1,21 +1,26 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type RecurringReceipt struct { - ID string `db:"id"` - LoanID string `db:"loan_id"` - SpaceID string `db:"space_id"` - Description string `db:"description"` - TotalAmountCents int `db:"total_amount_cents"` - Frequency Frequency `db:"frequency"` - StartDate time.Time `db:"start_date"` - EndDate *time.Time `db:"end_date"` - NextOccurrence time.Time `db:"next_occurrence"` - IsActive bool `db:"is_active"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + LoanID string `db:"loan_id"` + SpaceID string `db:"space_id"` + Description string `db:"description"` + TotalAmount decimal.Decimal `db:"total_amount"` + TotalAmountCents int `db:"total_amount_cents"` // deprecated: kept for SELECT * compatibility + Frequency Frequency `db:"frequency"` + StartDate time.Time `db:"start_date"` + EndDate *time.Time `db:"end_date"` + NextOccurrence time.Time `db:"next_occurrence"` + IsActive bool `db:"is_active"` + CreatedBy string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type RecurringReceiptSource struct { @@ -23,7 +28,8 @@ type RecurringReceiptSource struct { RecurringReceiptID string `db:"recurring_receipt_id"` SourceType FundingSourceType `db:"source_type"` AccountID *string `db:"account_id"` - AmountCents int `db:"amount_cents"` + Amount decimal.Decimal `db:"amount"` + AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility } type RecurringReceiptWithSources struct { diff --git a/internal/model/report.go b/internal/model/report.go index 2507188..46945ae 100644 --- a/internal/model/report.go +++ b/internal/model/report.go @@ -1,15 +1,19 @@ package model -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) type DailySpending struct { - Date time.Time `db:"date"` - TotalCents int `db:"total_cents"` + Date time.Time `db:"date"` + Total decimal.Decimal `db:"total"` } type MonthlySpending struct { - Month string `db:"month"` - TotalCents int `db:"total_cents"` + Month string `db:"month"` + Total decimal.Decimal `db:"total"` } type SpendingReport struct { @@ -17,7 +21,7 @@ type SpendingReport struct { DailySpending []*DailySpending MonthlySpending []*MonthlySpending TopExpenses []*ExpenseWithTagsAndMethod - TotalIncome int - TotalExpenses int - NetBalance int + TotalIncome decimal.Decimal + TotalExpenses decimal.Decimal + NetBalance decimal.Decimal } diff --git a/internal/repository/budget.go b/internal/repository/budget.go index 7a5e998..3374c85 100644 --- a/internal/repository/budget.go +++ b/internal/repository/budget.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) var ( @@ -17,7 +18,7 @@ type BudgetRepository interface { Create(budget *model.Budget, tagIDs []string) error GetByID(id string) (*model.Budget, error) GetBySpaceID(spaceID string) ([]*model.Budget, error) - GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error) + GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error) GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error) Update(budget *model.Budget, tagIDs []string) error Delete(id string) error @@ -33,9 +34,9 @@ func NewBudgetRepository(db *sqlx.DB) BudgetRepository { func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - query := `INSERT INTO budgets (id, space_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);` - if _, err := tx.Exec(query, budget.ID, budget.SpaceID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt); err != nil { + query := `INSERT INTO budgets (id, space_id, amount, period, start_date, end_date, is_active, created_by, created_at, updated_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);` + if _, err := tx.Exec(query, budget.ID, budget.SpaceID, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt); err != nil { return err } @@ -67,23 +68,23 @@ func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error) return budgets, err } -func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error) { +func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error) { if len(tagIDs) == 0 { - return 0, nil + return decimal.Zero, nil } query, args, err := sqlx.In(` - SELECT COALESCE(SUM(e.amount_cents), 0) + SELECT COALESCE(SUM(CAST(e.amount AS DECIMAL)), 0) FROM expenses e WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ? AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?)) `, spaceID, periodStart, periodEnd, tagIDs) if err != nil { - return 0, err + return decimal.Zero, err } query = r.db.Rebind(query) - var spent int + var spent decimal.Decimal err = r.db.Get(&spent, query, args...) return spent, err } @@ -132,8 +133,8 @@ func (r *budgetRepository) GetTagsByBudgetIDs(budgetIDs []string) (map[string][] func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - query := `UPDATE budgets SET amount_cents = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;` - if _, err := tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil { + query := `UPDATE budgets SET amount = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;` + if _, err := tx.Exec(query, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil { return err } diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 3b029ce..9235630 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) var ( @@ -28,7 +29,7 @@ type ExpenseRepository interface { GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error) GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error) - GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error) + GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error) } type expenseRepository struct { @@ -41,10 +42,9 @@ func NewExpenseRepository(db *sqlx.DB) ExpenseRepository { func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);` - _, err := tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt) - if err != nil { + queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);` + if _, err := tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt); err != nil { return err } @@ -113,7 +113,7 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti t.id as tag_id, t.name as tag_name, t.color as tag_color, - SUM(e.amount_cents) as total_amount + SUM(CAST(e.amount AS DECIMAL)) as total_amount FROM expenses e JOIN expense_tags et ON e.id = et.expense_id JOIN tags t ON et.tag_id = t.id @@ -215,8 +215,8 @@ func (r *expenseRepository) GetPaymentMethodsByExpenseIDs(expenseIDs []string) ( func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;` - if _, err := tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil { + query := `UPDATE expenses SET description = $1, amount = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;` + if _, err := tx.Exec(query, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil { return err } @@ -252,7 +252,7 @@ func (r *expenseRepository) Delete(id string) error { func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) { var results []*model.DailySpending query := ` - SELECT date, SUM(amount_cents) as total_cents + SELECT date, SUM(CAST(amount AS DECIMAL)) as total FROM expenses WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 GROUP BY date @@ -267,14 +267,14 @@ func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Tim var query string if r.db.DriverName() == "sqlite" { query = ` - SELECT strftime('%Y-%m', date) as month, SUM(amount_cents) as total_cents + SELECT strftime('%Y-%m', date) as month, SUM(CAST(amount AS DECIMAL)) as total FROM expenses WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 GROUP BY strftime('%Y-%m', date) ORDER BY month ASC;` } else { query = ` - SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(amount_cents) as total_cents + SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(CAST(amount AS DECIMAL)) as total FROM expenses WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 GROUP BY TO_CHAR(date, 'YYYY-MM') @@ -289,37 +289,38 @@ func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, l query := ` SELECT * FROM expenses WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 - ORDER BY amount_cents DESC + ORDER BY CAST(amount AS DECIMAL) DESC LIMIT $4; ` err := r.db.Select(&results, query, spaceID, from, to, limit) return results, err } -func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error) { +func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error) { type summary struct { - Type string `db:"type"` - Total int `db:"total"` + Type string `db:"type"` + Total decimal.Decimal `db:"total"` } var results []summary query := ` - SELECT type, COALESCE(SUM(amount_cents), 0) as total + SELECT type, COALESCE(SUM(CAST(amount AS DECIMAL)), 0) as total FROM expenses WHERE space_id = $1 AND date >= $2 AND date <= $3 GROUP BY type; ` err := r.db.Select(&results, query, spaceID, from, to) if err != nil { - return 0, 0, err + return decimal.Zero, decimal.Zero, err } - var income, expenses int + income := decimal.Zero + expenseTotal := decimal.Zero for _, r := range results { if r.Type == "topup" { income = r.Total } else if r.Type == "expense" { - expenses = r.Total + expenseTotal = r.Total } } - return income, expenses, nil + return income, expenseTotal, nil } diff --git a/internal/repository/expense_test.go b/internal/repository/expense_test.go index 40e038c..10a4497 100644 --- a/internal/repository/expense_test.go +++ b/internal/repository/expense_test.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/testutil" "github.com/google/uuid" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +25,7 @@ func TestExpenseRepository_Create(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Lunch", - AmountCents: 1500, + Amount: decimal.RequireFromString("15.00"), Type: model.ExpenseTypeExpense, Date: now, CreatedAt: now, @@ -38,7 +39,7 @@ func TestExpenseRepository_Create(t *testing.T) { require.NoError(t, err) assert.Equal(t, expense.ID, fetched.ID) assert.Equal(t, "Lunch", fetched.Description) - assert.Equal(t, 1500, fetched.AmountCents) + assert.True(t, decimal.RequireFromString("15.00").Equal(fetched.Amount)) assert.Equal(t, model.ExpenseTypeExpense, fetched.Type) }) } @@ -49,9 +50,9 @@ func TestExpenseRepository_GetBySpaceIDPaginated(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", 1000, model.ExpenseTypeExpense) - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", 2000, model.ExpenseTypeExpense) - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 3", 3000, model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.00"), model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.00"), model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 3", decimal.RequireFromString("30.00"), model.ExpenseTypeExpense) expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0) require.NoError(t, err) @@ -65,8 +66,8 @@ func TestExpenseRepository_CountBySpaceID(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", 1000, model.ExpenseTypeExpense) - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", 2000, model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.00"), model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.00"), model.ExpenseTypeExpense) count, err := repo.CountBySpaceID(space.ID) require.NoError(t, err) @@ -87,7 +88,7 @@ func TestExpenseRepository_GetTagsByExpenseIDs(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Weekly groceries", - AmountCents: 5000, + Amount: decimal.RequireFromString("50.00"), Type: model.ExpenseTypeExpense, Date: now, CreatedAt: now, @@ -119,7 +120,7 @@ func TestExpenseRepository_GetPaymentMethodsByExpenseIDs(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Online purchase", - AmountCents: 3000, + Amount: decimal.RequireFromString("30.00"), Type: model.ExpenseTypeExpense, Date: now, PaymentMethodID: &method.ID, @@ -156,7 +157,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Lunch", - AmountCents: 1500, + Amount: decimal.RequireFromString("15.00"), Type: model.ExpenseTypeExpense, Date: now, CreatedAt: now, @@ -170,7 +171,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Dinner", - AmountCents: 2500, + Amount: decimal.RequireFromString("25.00"), Type: model.ExpenseTypeExpense, Date: now, CreatedAt: now, @@ -184,7 +185,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) { require.Len(t, summaries, 1) assert.Equal(t, tag.ID, summaries[0].TagID) assert.Equal(t, "Food", summaries[0].TagName) - assert.Equal(t, 4000, summaries[0].TotalAmount) + assert.True(t, decimal.RequireFromString("40.00").Equal(summaries[0].TotalAmount)) }) } @@ -202,7 +203,7 @@ func TestExpenseRepository_Update(t *testing.T) { SpaceID: space.ID, CreatedBy: user.ID, Description: "Original", - AmountCents: 1000, + Amount: decimal.RequireFromString("10.00"), Type: model.ExpenseTypeExpense, Date: now, CreatedAt: now, @@ -234,7 +235,7 @@ func TestExpenseRepository_Delete(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") - expense := testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "To Delete", 500, model.ExpenseTypeExpense) + expense := testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "To Delete", decimal.RequireFromString("5.00"), model.ExpenseTypeExpense) err := repo.Delete(expense.ID) require.NoError(t, err) diff --git a/internal/repository/loan.go b/internal/repository/loan.go index dcfc38a..b089b01 100644 --- a/internal/repository/loan.go +++ b/internal/repository/loan.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) var ( @@ -22,7 +23,7 @@ type LoanRepository interface { Update(loan *model.Loan) error Delete(id string) error SetPaidOff(id string, paidOff bool) error - GetTotalPaidForLoan(loanID string) (int, error) + GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) GetReceiptCountForLoan(loanID string) (int, error) } @@ -35,9 +36,9 @@ func NewLoanRepository(db *sqlx.DB) LoanRepository { } func (r *loanRepository) Create(loan *model.Loan) error { - query := `INSERT INTO loans (id, space_id, name, description, original_amount_cents, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);` - _, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt) + query := `INSERT INTO loans (id, space_id, name, description, original_amount, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at, original_amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 0);` + _, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt) return err } @@ -72,8 +73,8 @@ func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) { } func (r *loanRepository) Update(loan *model.Loan) error { - query := `UPDATE loans SET name = $1, description = $2, original_amount_cents = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;` - result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID) + query := `UPDATE loans SET name = $1, description = $2, original_amount = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;` + result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID) if err != nil { return err } @@ -94,9 +95,9 @@ func (r *loanRepository) SetPaidOff(id string, paidOff bool) error { return err } -func (r *loanRepository) GetTotalPaidForLoan(loanID string) (int, error) { - var total int - err := r.db.Get(&total, `SELECT COALESCE(SUM(total_amount_cents), 0) FROM receipts WHERE loan_id = $1;`, loanID) +func (r *loanRepository) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) { + var total decimal.Decimal + err := r.db.Get(&total, `SELECT COALESCE(SUM(CAST(total_amount AS DECIMAL)), 0) FROM receipts WHERE loan_id = $1;`, loanID) return total, err } diff --git a/internal/repository/money_account.go b/internal/repository/money_account.go index fc3b1c6..16dc008 100644 --- a/internal/repository/money_account.go +++ b/internal/repository/money_account.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) var ( @@ -25,8 +26,8 @@ type MoneyAccountRepository interface { GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error) DeleteTransfer(id string) error - GetAccountBalance(accountID string) (int, error) - GetTotalAllocatedForSpace(spaceID string) (int, error) + GetAccountBalance(accountID string) (decimal.Decimal, error) + GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) CountTransfersBySpaceID(spaceID string) (int, error) @@ -94,8 +95,8 @@ func (r *moneyAccountRepository) Delete(id string) error { } func (r *moneyAccountRepository) CreateTransfer(transfer *model.AccountTransfer) error { - 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.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt) + query := `INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);` + _, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt) return err } @@ -122,16 +123,16 @@ func (r *moneyAccountRepository) DeleteTransfer(id string) error { return err } -func (r *moneyAccountRepository) GetAccountBalance(accountID string) (int, error) { - var balance int - query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN amount_cents ELSE -amount_cents END), 0) FROM account_transfers WHERE account_id = $1;` +func (r *moneyAccountRepository) GetAccountBalance(accountID string) (decimal.Decimal, error) { + var balance decimal.Decimal + query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN CAST(amount AS DECIMAL) ELSE -CAST(amount AS DECIMAL) END), 0) FROM account_transfers WHERE account_id = $1;` err := r.db.Get(&balance, query, accountID) return balance, err } -func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int, error) { - var total int - query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN t.amount_cents ELSE -t.amount_cents END), 0) +func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) { + var total decimal.Decimal + query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN CAST(t.amount AS DECIMAL) ELSE -CAST(t.amount AS DECIMAL) END), 0) FROM account_transfers t JOIN money_accounts a ON t.account_id = a.id WHERE a.space_id = $1;` @@ -141,7 +142,7 @@ func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int, func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) { var transfers []*model.AccountTransferWithAccount - query := `SELECT t.id, t.account_id, t.amount_cents, t.direction, t.note, + query := `SELECT t.id, t.account_id, t.amount, t.direction, t.note, t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name FROM account_transfers t JOIN money_accounts a ON t.account_id = a.id diff --git a/internal/repository/money_account_test.go b/internal/repository/money_account_test.go index a00c727..541bb65 100644 --- a/internal/repository/money_account_test.go +++ b/internal/repository/money_account_test.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/testutil" "github.com/google/uuid" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -96,13 +97,13 @@ func TestMoneyAccountRepository_CreateTransfer(t *testing.T) { account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) transfer := &model.AccountTransfer{ - ID: uuid.NewString(), - AccountID: account.ID, - AmountCents: 5000, - Direction: model.TransferDirectionDeposit, - Note: "Initial deposit", - CreatedBy: user.ID, - CreatedAt: time.Now(), + ID: uuid.NewString(), + AccountID: account.ID, + Amount: decimal.RequireFromString("50.00"), + Direction: model.TransferDirectionDeposit, + Note: "Initial deposit", + CreatedBy: user.ID, + CreatedAt: time.Now(), } err := repo.CreateTransfer(transfer) @@ -112,7 +113,7 @@ func TestMoneyAccountRepository_CreateTransfer(t *testing.T) { require.NoError(t, err) require.Len(t, transfers, 1) assert.Equal(t, transfer.ID, transfers[0].ID) - assert.Equal(t, 5000, transfers[0].AmountCents) + assert.True(t, decimal.RequireFromString("50.00").Equal(transfers[0].Amount)) assert.Equal(t, model.TransferDirectionDeposit, transfers[0].Direction) }) } @@ -123,7 +124,7 @@ func TestMoneyAccountRepository_DeleteTransfer(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) - transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID) + transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.00"), model.TransferDirectionDeposit, user.ID) err := repo.DeleteTransfer(transfer.ID) require.NoError(t, err) @@ -141,12 +142,12 @@ func TestMoneyAccountRepository_GetAccountBalance(t *testing.T) { space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account.ID, 300, model.TransferDirectionWithdrawal, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.00"), model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("3.00"), model.TransferDirectionWithdrawal, user.ID) balance, err := repo.GetAccountBalance(account.ID) require.NoError(t, err) - assert.Equal(t, 700, balance) + assert.True(t, decimal.RequireFromString("7.00").Equal(balance)) }) } @@ -159,11 +160,11 @@ func TestMoneyAccountRepository_GetTotalAllocatedForSpace(t *testing.T) { account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID) account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account1.ID, 2000, model.TransferDirectionDeposit, user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 3000, model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("20.00"), model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("30.00"), model.TransferDirectionDeposit, user.ID) total, err := repo.GetTotalAllocatedForSpace(space.ID) require.NoError(t, err) - assert.Equal(t, 5000, total) + assert.True(t, decimal.RequireFromString("50.00").Equal(total)) }) } diff --git a/internal/repository/receipt.go b/internal/repository/receipt.go index b03b7fd..806157d 100644 --- a/internal/repository/receipt.go +++ b/internal/repository/receipt.go @@ -6,6 +6,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) var ( @@ -57,9 +58,9 @@ func (r *receiptRepository) CreateWithSources( // Insert receipt _, err = tx.Exec( - `INSERT INTO receipts (id, loan_id, space_id, description, total_amount_cents, date, recurring_receipt_id, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`, - receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt, + `INSERT INTO receipts (id, loan_id, space_id, description, total_amount, date, recurring_receipt_id, created_by, created_at, updated_at, total_amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`, + receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmount, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt, ) if err != nil { return err @@ -68,9 +69,9 @@ func (r *receiptRepository) CreateWithSources( // Insert balance expense if present if balanceExpense != nil { _, err = tx.Exec( - `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`, - balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, + `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`, + balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, ) if err != nil { return err @@ -80,9 +81,9 @@ func (r *receiptRepository) CreateWithSources( // Insert account transfers for _, transfer := range accountTransfers { _, err = tx.Exec( - `INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`, - transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, + `INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`, + transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, ) if err != nil { return err @@ -92,9 +93,9 @@ func (r *receiptRepository) CreateWithSources( // Insert funding sources for _, src := range sources { _, err = tx.Exec( - `INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id) - VALUES ($1, $2, $3, $4, $5, $6, $7);`, - src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID, + `INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents) + Values ($1, $2, $3, $4, $5, $6, $7, 0);`, + src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID, ) if err != nil { return err @@ -157,14 +158,14 @@ func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs ReceiptID string `db:"receipt_id"` SourceType model.FundingSourceType `db:"source_type"` AccountID *string `db:"account_id"` - AmountCents int `db:"amount_cents"` + Amount decimal.Decimal `db:"amount"` LinkedExpenseID *string `db:"linked_expense_id"` LinkedTransferID *string `db:"linked_transfer_id"` AccountName *string `db:"account_name"` } query, args, err := sqlx.In(` - SELECT rfs.id, rfs.receipt_id, rfs.source_type, rfs.account_id, rfs.amount_cents, + SELECT rfs.id, rfs.receipt_id, rfs.source_type, rfs.account_id, rfs.amount, rfs.linked_expense_id, rfs.linked_transfer_id, ma.name AS account_name FROM receipt_funding_sources rfs @@ -194,7 +195,7 @@ func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs ReceiptID: rw.ReceiptID, SourceType: rw.SourceType, AccountID: rw.AccountID, - AmountCents: rw.AmountCents, + Amount: rw.Amount, LinkedExpenseID: rw.LinkedExpenseID, LinkedTransferID: rw.LinkedTransferID, }, @@ -279,8 +280,8 @@ func (r *receiptRepository) UpdateWithSources( // Update receipt _, err = tx.Exec( - `UPDATE receipts SET description = $1, total_amount_cents = $2, date = $3, updated_at = $4 WHERE id = $5;`, - receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.UpdatedAt, receipt.ID, + `UPDATE receipts SET description = $1, total_amount = $2, date = $3, updated_at = $4 WHERE id = $5;`, + receipt.Description, receipt.TotalAmount, receipt.Date, receipt.UpdatedAt, receipt.ID, ) if err != nil { return err @@ -289,9 +290,9 @@ func (r *receiptRepository) UpdateWithSources( // Insert new balance expense if balanceExpense != nil { _, err = tx.Exec( - `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`, - balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, + `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`, + balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, ) if err != nil { return err @@ -301,9 +302,9 @@ func (r *receiptRepository) UpdateWithSources( // Insert new account transfers for _, transfer := range accountTransfers { _, err = tx.Exec( - `INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`, - transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, + `INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`, + transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, ) if err != nil { return err @@ -313,9 +314,9 @@ func (r *receiptRepository) UpdateWithSources( // Insert new funding sources for _, src := range sources { _, err = tx.Exec( - `INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id) - VALUES ($1, $2, $3, $4, $5, $6, $7);`, - src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID, + `INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents) + Values ($1, $2, $3, $4, $5, $6, $7, 0);`, + src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID, ) if err != nil { return err diff --git a/internal/repository/recurring_expense.go b/internal/repository/recurring_expense.go index f55ad9e..2501998 100644 --- a/internal/repository/recurring_expense.go +++ b/internal/repository/recurring_expense.go @@ -38,9 +38,9 @@ func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository { func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);` - if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil { + query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at, amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 0);` + if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil { return err } @@ -161,8 +161,8 @@ func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error { return WithTx(r.db, func(tx *sqlx.Tx) error { - query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;` - if _, err := tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil { + query := `UPDATE recurring_expenses SET description = $1, amount = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;` + if _, err := tx.Exec(query, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil { return err } diff --git a/internal/repository/recurring_receipt.go b/internal/repository/recurring_receipt.go index 216be7f..f50fa93 100644 --- a/internal/repository/recurring_receipt.go +++ b/internal/repository/recurring_receipt.go @@ -44,9 +44,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources defer tx.Rollback() _, err = tx.Exec( - `INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount_cents, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);`, - rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt, + `INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at, total_amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`, + rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt, ) if err != nil { return err @@ -54,9 +54,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources for _, src := range sources { _, err = tx.Exec( - `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents) - VALUES ($1, $2, $3, $4, $5);`, - src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents, + `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents) + VALUES ($1, $2, $3, $4, $5, 0);`, + src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount, ) if err != nil { return err @@ -105,8 +105,8 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources defer tx.Rollback() _, err = tx.Exec( - `UPDATE recurring_receipts SET description = $1, total_amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`, - rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID, + `UPDATE recurring_receipts SET description = $1, total_amount = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`, + rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID, ) if err != nil { return err @@ -119,9 +119,9 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources for _, src := range sources { _, err = tx.Exec( - `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents) - VALUES ($1, $2, $3, $4, $5);`, - src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents, + `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents) + VALUES ($1, $2, $3, $4, $5, 0);`, + src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount, ) if err != nil { return err diff --git a/internal/service/budget.go b/internal/service/budget.go index 8fc6291..736b377 100644 --- a/internal/service/budget.go +++ b/internal/service/budget.go @@ -7,12 +7,13 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateBudgetDTO struct { SpaceID string TagIDs []string - Amount int + Amount decimal.Decimal Period model.BudgetPeriod StartDate time.Time EndDate *time.Time @@ -22,7 +23,7 @@ type CreateBudgetDTO struct { type UpdateBudgetDTO struct { ID string TagIDs []string - Amount int + Amount decimal.Decimal Period model.BudgetPeriod StartDate time.Time EndDate *time.Time @@ -37,7 +38,7 @@ func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService { } func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) { - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("budget amount must be positive") } @@ -47,16 +48,16 @@ func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) now := time.Now() budget := &model.Budget{ - ID: uuid.NewString(), - SpaceID: dto.SpaceID, - AmountCents: dto.Amount, - Period: dto.Period, - StartDate: dto.StartDate, - EndDate: dto.EndDate, - IsActive: true, - CreatedBy: dto.CreatedBy, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + SpaceID: dto.SpaceID, + Amount: dto.Amount, + Period: dto.Period, + StartDate: dto.StartDate, + EndDate: dto.EndDate, + IsActive: true, + CreatedBy: dto.CreatedBy, + CreatedAt: now, + UpdatedAt: now, } if err := s.budgetRepo.Create(budget, dto.TagIDs); err != nil { @@ -99,12 +100,12 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWith start, end := GetCurrentPeriodBounds(b.Period, time.Now()) spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end) if err != nil { - spent = 0 + spent = decimal.Zero } var percentage float64 - if b.AmountCents > 0 { - percentage = float64(spent) / float64(b.AmountCents) * 100 + if b.Amount.GreaterThan(decimal.Zero) { + percentage, _ = spent.Div(b.Amount).Mul(decimal.NewFromInt(100)).Float64() } var status model.BudgetStatus @@ -120,7 +121,7 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWith bws := &model.BudgetWithSpent{ Budget: *b, Tags: tags, - SpentCents: spent, + Spent: spent, Percentage: percentage, Status: status, } @@ -131,7 +132,7 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWith } func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) { - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("budget amount must be positive") } @@ -144,7 +145,7 @@ func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) return nil, err } - existing.AmountCents = dto.Amount + existing.Amount = dto.Amount existing.Period = dto.Period existing.StartDate = dto.StartDate existing.EndDate = dto.EndDate diff --git a/internal/service/expense.go b/internal/service/expense.go index 854bffc..d9d6adb 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -7,13 +7,14 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateExpenseDTO struct { SpaceID string UserID string Description string - Amount int + Amount decimal.Decimal Type model.ExpenseType Date time.Time TagIDs []string @@ -25,7 +26,7 @@ type UpdateExpenseDTO struct { ID string SpaceID string Description string - Amount int + Amount decimal.Decimal Type model.ExpenseType Date time.Time TagIDs []string @@ -48,7 +49,7 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er if dto.Description == "" { return nil, fmt.Errorf("expense description cannot be empty") } - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -58,7 +59,7 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er SpaceID: dto.SpaceID, CreatedBy: dto.UserID, Description: dto.Description, - AmountCents: dto.Amount, + Amount: dto.Amount, Type: dto.Type, Date: dto.Date, PaymentMethodID: dto.PaymentMethodID, @@ -78,18 +79,18 @@ func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense, return s.expenseRepo.GetBySpaceID(spaceID) } -func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) { +func (s *ExpenseService) GetBalanceForSpace(spaceID string) (decimal.Decimal, error) { expenses, err := s.expenseRepo.GetBySpaceID(spaceID) if err != nil { - return 0, err + return decimal.Zero, err } - var balance int + balance := decimal.Zero for _, expense := range expenses { if expense.Type == model.ExpenseTypeExpense { - balance -= expense.AmountCents + balance = balance.Sub(expense.Amount) } else if expense.Type == model.ExpenseTypeTopup { - balance += expense.AmountCents + balance = balance.Add(expense.Amount) } } @@ -212,7 +213,7 @@ func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, er if dto.Description == "" { return nil, fmt.Errorf("expense description cannot be empty") } - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -222,7 +223,7 @@ func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, er } existing.Description = dto.Description - existing.AmountCents = dto.Amount + existing.Amount = dto.Amount existing.Type = dto.Type existing.Date = dto.Date existing.PaymentMethodID = dto.PaymentMethodID diff --git a/internal/service/expense_test.go b/internal/service/expense_test.go index 428c489..1c7b32f 100644 --- a/internal/service/expense_test.go +++ b/internal/service/expense_test.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/testutil" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +25,7 @@ func TestExpenseService_CreateExpense(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Lunch", - Amount: 1500, + Amount: decimal.RequireFromString("15.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), TagIDs: []string{tag.ID}, @@ -32,7 +33,7 @@ func TestExpenseService_CreateExpense(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, expense.ID) assert.Equal(t, "Lunch", expense.Description) - assert.Equal(t, 1500, expense.AmountCents) + assert.True(t, decimal.RequireFromString("15.00").Equal(expense.Amount)) assert.Equal(t, model.ExpenseTypeExpense, expense.Type) }) } @@ -46,7 +47,7 @@ func TestExpenseService_CreateExpense_EmptyDescription(t *testing.T) { SpaceID: "some-space", UserID: "some-user", Description: "", - Amount: 1000, + Amount: decimal.RequireFromString("10.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), }) @@ -64,7 +65,7 @@ func TestExpenseService_CreateExpense_ZeroAmount(t *testing.T) { SpaceID: "some-space", UserID: "some-user", Description: "Something", - Amount: 0, + Amount: decimal.Zero, Type: model.ExpenseTypeExpense, Date: time.Now(), }) @@ -87,7 +88,7 @@ func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Bus fare", - Amount: 250, + Amount: decimal.RequireFromString("2.50"), Type: model.ExpenseTypeExpense, Date: time.Now(), TagIDs: []string{tag.ID}, @@ -99,7 +100,7 @@ func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Coffee", - Amount: 500, + Amount: decimal.RequireFromString("5.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), }) @@ -132,12 +133,12 @@ func TestExpenseService_GetBalanceForSpace(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-balance@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Balance Space") - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Topup", 10000, model.ExpenseTypeTopup) - testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", 3000, model.ExpenseTypeExpense) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Topup", decimal.RequireFromString("100.00"), model.ExpenseTypeTopup) + testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", decimal.RequireFromString("30.00"), model.ExpenseTypeExpense) balance, err := svc.GetBalanceForSpace(space.ID) require.NoError(t, err) - assert.Equal(t, 7000, balance) + assert.True(t, decimal.RequireFromString("70.00").Equal(balance)) }) } @@ -156,7 +157,7 @@ func TestExpenseService_GetExpensesByTag(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Dinner", - Amount: 2500, + Amount: decimal.RequireFromString("25.00"), Type: model.ExpenseTypeExpense, Date: now, TagIDs: []string{tag.ID}, @@ -169,7 +170,7 @@ func TestExpenseService_GetExpensesByTag(t *testing.T) { require.NoError(t, err) require.Len(t, summaries, 1) assert.Equal(t, tag.ID, summaries[0].TagID) - assert.Equal(t, 2500, summaries[0].TotalAmount) + assert.True(t, decimal.RequireFromString("25.00").Equal(summaries[0].TotalAmount)) }) } @@ -185,7 +186,7 @@ func TestExpenseService_UpdateExpense(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Old Description", - Amount: 1000, + Amount: decimal.RequireFromString("10.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), }) @@ -195,13 +196,13 @@ func TestExpenseService_UpdateExpense(t *testing.T) { ID: created.ID, SpaceID: space.ID, Description: "New Description", - Amount: 2000, + Amount: decimal.RequireFromString("20.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), }) require.NoError(t, err) assert.Equal(t, "New Description", updated.Description) - assert.Equal(t, 2000, updated.AmountCents) + assert.True(t, decimal.RequireFromString("20.00").Equal(updated.Amount)) }) } @@ -217,7 +218,7 @@ func TestExpenseService_DeleteExpense(t *testing.T) { SpaceID: space.ID, UserID: user.ID, Description: "Doomed Expense", - Amount: 500, + Amount: decimal.RequireFromString("5.00"), Type: model.ExpenseTypeExpense, Date: time.Now(), }) diff --git a/internal/service/loan.go b/internal/service/loan.go index 2af8088..914d641 100644 --- a/internal/service/loan.go +++ b/internal/service/loan.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateLoanDTO struct { @@ -14,7 +15,7 @@ type CreateLoanDTO struct { UserID string Name string Description string - OriginalAmount int + OriginalAmount decimal.Decimal InterestRateBps int StartDate time.Time EndDate *time.Time @@ -24,7 +25,7 @@ type UpdateLoanDTO struct { ID string Name string Description string - OriginalAmount int + OriginalAmount decimal.Decimal InterestRateBps int StartDate time.Time EndDate *time.Time @@ -48,24 +49,24 @@ func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) { if dto.Name == "" { return nil, fmt.Errorf("loan name cannot be empty") } - if dto.OriginalAmount <= 0 { + if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } now := time.Now() loan := &model.Loan{ - ID: uuid.NewString(), - SpaceID: dto.SpaceID, - Name: dto.Name, - Description: dto.Description, - OriginalAmountCents: dto.OriginalAmount, - InterestRateBps: dto.InterestRateBps, - StartDate: dto.StartDate, - EndDate: dto.EndDate, - IsPaidOff: false, - CreatedBy: dto.UserID, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + SpaceID: dto.SpaceID, + Name: dto.Name, + Description: dto.Description, + OriginalAmount: dto.OriginalAmount, + InterestRateBps: dto.InterestRateBps, + StartDate: dto.StartDate, + EndDate: dto.EndDate, + IsPaidOff: false, + CreatedBy: dto.UserID, + CreatedAt: now, + UpdatedAt: now, } if err := s.loanRepo.Create(loan); err != nil { @@ -95,10 +96,10 @@ func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSumma } return &model.LoanWithPaymentSummary{ - Loan: *loan, - TotalPaidCents: totalPaid, - RemainingCents: loan.OriginalAmountCents - totalPaid, - ReceiptCount: receiptCount, + Loan: *loan, + TotalPaid: totalPaid, + Remaining: loan.OriginalAmount.Sub(totalPaid), + ReceiptCount: receiptCount, }, nil } @@ -154,10 +155,10 @@ func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPay return nil, err } result[i] = &model.LoanWithPaymentSummary{ - Loan: *loan, - TotalPaidCents: totalPaid, - RemainingCents: loan.OriginalAmountCents - totalPaid, - ReceiptCount: receiptCount, + Loan: *loan, + TotalPaid: totalPaid, + Remaining: loan.OriginalAmount.Sub(totalPaid), + ReceiptCount: receiptCount, } } return result, nil @@ -167,7 +168,7 @@ func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) { if dto.Name == "" { return nil, fmt.Errorf("loan name cannot be empty") } - if dto.OriginalAmount <= 0 { + if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -178,7 +179,7 @@ func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) { existing.Name = dto.Name existing.Description = dto.Description - existing.OriginalAmountCents = dto.OriginalAmount + existing.OriginalAmount = dto.OriginalAmount existing.InterestRateBps = dto.InterestRateBps existing.StartDate = dto.StartDate existing.EndDate = dto.EndDate diff --git a/internal/service/money_account.go b/internal/service/money_account.go index 004ce84..84002b6 100644 --- a/internal/service/money_account.go +++ b/internal/service/money_account.go @@ -8,6 +8,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateMoneyAccountDTO struct { @@ -23,7 +24,7 @@ type UpdateMoneyAccountDTO struct { type CreateTransferDTO struct { AccountID string - Amount int + Amount decimal.Decimal Direction model.TransferDirection Note string CreatedBy string @@ -77,7 +78,7 @@ func (s *MoneyAccountService) GetAccountsForSpace(spaceID string) ([]model.Money } result[i] = model.MoneyAccountWithBalance{ MoneyAccount: *acct, - BalanceCents: balance, + Balance: balance, } } @@ -113,8 +114,8 @@ func (s *MoneyAccountService) DeleteAccount(id string) error { return s.accountRepo.Delete(id) } -func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance int) (*model.AccountTransfer, error) { - if dto.Amount <= 0 { +func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance decimal.Decimal) (*model.AccountTransfer, error) { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -123,7 +124,7 @@ func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpa } if dto.Direction == model.TransferDirectionDeposit { - if dto.Amount > availableSpaceBalance { + if dto.Amount.GreaterThan(availableSpaceBalance) { return nil, fmt.Errorf("insufficient available balance") } } @@ -133,19 +134,19 @@ func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpa if err != nil { return nil, err } - if dto.Amount > accountBalance { + if dto.Amount.GreaterThan(accountBalance) { return nil, fmt.Errorf("insufficient account balance") } } transfer := &model.AccountTransfer{ - ID: uuid.NewString(), - AccountID: dto.AccountID, - AmountCents: dto.Amount, - Direction: dto.Direction, - Note: strings.TrimSpace(dto.Note), - CreatedBy: dto.CreatedBy, - CreatedAt: time.Now(), + ID: uuid.NewString(), + AccountID: dto.AccountID, + Amount: dto.Amount, + Direction: dto.Direction, + Note: strings.TrimSpace(dto.Note), + CreatedBy: dto.CreatedBy, + CreatedAt: time.Now(), } err := s.accountRepo.CreateTransfer(transfer) @@ -164,11 +165,11 @@ func (s *MoneyAccountService) DeleteTransfer(id string) error { return s.accountRepo.DeleteTransfer(id) } -func (s *MoneyAccountService) GetAccountBalance(accountID string) (int, error) { +func (s *MoneyAccountService) GetAccountBalance(accountID string) (decimal.Decimal, error) { return s.accountRepo.GetAccountBalance(accountID) } -func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (int, error) { +func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) { return s.accountRepo.GetTotalAllocatedForSpace(spaceID) } diff --git a/internal/service/money_account_test.go b/internal/service/money_account_test.go index 97b1c5d..9fb65dc 100644 --- a/internal/service/money_account_test.go +++ b/internal/service/money_account_test.go @@ -6,6 +6,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/testutil" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -53,13 +54,13 @@ func TestMoneyAccountService_GetAccountsForSpace(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space") account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("50.00"), model.TransferDirectionDeposit, user.ID) accounts, err := svc.GetAccountsForSpace(space.ID) require.NoError(t, err) require.Len(t, accounts, 1) assert.Equal(t, "Checking", accounts[0].Name) - assert.Equal(t, 5000, accounts[0].BalanceCents) + assert.True(t, decimal.RequireFromString("50.00").Equal(accounts[0].Balance)) }) } @@ -74,14 +75,14 @@ func TestMoneyAccountService_CreateTransfer_Deposit(t *testing.T) { transfer, err := svc.CreateTransfer(CreateTransferDTO{ AccountID: account.ID, - Amount: 3000, + Amount: decimal.RequireFromString("30.00"), Direction: model.TransferDirectionDeposit, Note: "Initial deposit", CreatedBy: user.ID, - }, 10000) + }, decimal.RequireFromString("100.00")) require.NoError(t, err) assert.NotEmpty(t, transfer.ID) - assert.Equal(t, 3000, transfer.AmountCents) + assert.True(t, decimal.RequireFromString("30.00").Equal(transfer.Amount)) assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction) }) } @@ -97,11 +98,11 @@ func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) { transfer, err := svc.CreateTransfer(CreateTransferDTO{ AccountID: account.ID, - Amount: 5000, + Amount: decimal.RequireFromString("50.00"), Direction: model.TransferDirectionDeposit, Note: "Too much", CreatedBy: user.ID, - }, 1000) + }, decimal.RequireFromString("10.00")) assert.Error(t, err) assert.Nil(t, transfer) }) @@ -115,18 +116,18 @@ func TestMoneyAccountService_CreateTransfer_Withdrawal(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space") account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("50.00"), model.TransferDirectionDeposit, user.ID) transfer, err := svc.CreateTransfer(CreateTransferDTO{ AccountID: account.ID, - Amount: 2000, + Amount: decimal.RequireFromString("20.00"), Direction: model.TransferDirectionWithdrawal, Note: "Withdrawal", CreatedBy: user.ID, - }, 0) + }, decimal.Zero) require.NoError(t, err) assert.NotEmpty(t, transfer.ID) - assert.Equal(t, 2000, transfer.AmountCents) + assert.True(t, decimal.RequireFromString("20.00").Equal(transfer.Amount)) assert.Equal(t, model.TransferDirectionWithdrawal, transfer.Direction) }) } @@ -140,14 +141,14 @@ func TestMoneyAccountService_GetTotalAllocatedForSpace(t *testing.T) { space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space") account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account1.ID, 3000, model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("30.00"), model.TransferDirectionDeposit, user.ID) account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 2", user.ID) - testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 2000, model.TransferDirectionDeposit, user.ID) + testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("20.00"), model.TransferDirectionDeposit, user.ID) total, err := svc.GetTotalAllocatedForSpace(space.ID) require.NoError(t, err) - assert.Equal(t, 5000, total) + assert.True(t, decimal.RequireFromString("50.00").Equal(total)) }) } @@ -177,7 +178,7 @@ func TestMoneyAccountService_DeleteTransfer(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space") account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID) - transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID) + transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.00"), model.TransferDirectionDeposit, user.ID) err := svc.DeleteTransfer(transfer.ID) require.NoError(t, err) diff --git a/internal/service/receipt.go b/internal/service/receipt.go index 8d2cd5b..e5f4358 100644 --- a/internal/service/receipt.go +++ b/internal/service/receipt.go @@ -7,12 +7,13 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type FundingSourceDTO struct { SourceType model.FundingSourceType AccountID string - Amount int + Amount decimal.Decimal } type CreateReceiptDTO struct { @@ -20,7 +21,7 @@ type CreateReceiptDTO struct { SpaceID string UserID string Description string - TotalAmount int + TotalAmount decimal.Decimal Date time.Time FundingSources []FundingSourceDTO RecurringReceiptID *string @@ -31,7 +32,7 @@ type UpdateReceiptDTO struct { SpaceID string UserID string Description string - TotalAmount int + TotalAmount decimal.Decimal Date time.Time FundingSources []FundingSourceDTO } @@ -57,7 +58,7 @@ func NewReceiptService( } func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) { - if dto.TotalAmount <= 0 { + if dto.TotalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } if len(dto.FundingSources) == 0 { @@ -65,15 +66,15 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith } // Validate funding sources sum to total - var sum int + sum := decimal.Zero for _, src := range dto.FundingSources { - if src.Amount <= 0 { + if src.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("each funding source amount must be positive") } - sum += src.Amount + sum = sum.Add(src.Amount) } - if sum != dto.TotalAmount { - return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount) + if !sum.Equal(dto.TotalAmount) { + return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount) } // Validate loan exists and is not paid off @@ -91,7 +92,7 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith LoanID: dto.LoanID, SpaceID: dto.SpaceID, Description: dto.Description, - TotalAmountCents: dto.TotalAmount, + TotalAmount: dto.TotalAmount, Date: dto.Date, RecurringReceiptID: dto.RecurringReceiptID, CreatedBy: dto.UserID, @@ -107,7 +108,7 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith // Check if loan is now fully paid off totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID) - if err == nil && totalPaid >= loan.OriginalAmountCents { + if err == nil && totalPaid.GreaterThanOrEqual(loan.OriginalAmount) { _ = s.loanRepo.SetPaidOff(loan.ID, true) } @@ -127,10 +128,10 @@ func (s *ReceiptService) buildLinkedRecords( for _, src := range fundingSources { fs := model.ReceiptFundingSource{ - ID: uuid.NewString(), - ReceiptID: receipt.ID, - SourceType: src.SourceType, - AmountCents: src.Amount, + ID: uuid.NewString(), + ReceiptID: receipt.ID, + SourceType: src.SourceType, + Amount: src.Amount, } if src.SourceType == model.FundingSourceBalance { @@ -139,7 +140,7 @@ func (s *ReceiptService) buildLinkedRecords( SpaceID: spaceID, CreatedBy: userID, Description: fmt.Sprintf("Loan payment: %s", description), - AmountCents: src.Amount, + Amount: src.Amount, Type: model.ExpenseTypeExpense, Date: date, CreatedAt: now, @@ -151,13 +152,13 @@ func (s *ReceiptService) buildLinkedRecords( acctID := src.AccountID fs.AccountID = &acctID transfer := &model.AccountTransfer{ - ID: uuid.NewString(), - AccountID: src.AccountID, - AmountCents: src.Amount, - Direction: model.TransferDirectionWithdrawal, - Note: fmt.Sprintf("Loan payment: %s", description), - CreatedBy: userID, - CreatedAt: now, + ID: uuid.NewString(), + AccountID: src.AccountID, + Amount: src.Amount, + Direction: model.TransferDirectionWithdrawal, + Note: fmt.Sprintf("Loan payment: %s", description), + CreatedBy: userID, + CreatedAt: now, } accountTransfers = append(accountTransfers, transfer) fs.LinkedTransferID = &transfer.ID @@ -255,7 +256,7 @@ func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error { if err != nil { return nil } - if loan.IsPaidOff && totalPaid < loan.OriginalAmountCents { + if loan.IsPaidOff && totalPaid.LessThan(loan.OriginalAmount) { _ = s.loanRepo.SetPaidOff(loan.ID, false) } @@ -263,22 +264,22 @@ func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error { } func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWithSources, error) { - if dto.TotalAmount <= 0 { + if dto.TotalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } if len(dto.FundingSources) == 0 { return nil, fmt.Errorf("at least one funding source is required") } - var sum int + sum := decimal.Zero for _, src := range dto.FundingSources { - if src.Amount <= 0 { + if src.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("each funding source amount must be positive") } - sum += src.Amount + sum = sum.Add(src.Amount) } - if sum != dto.TotalAmount { - return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount) + if !sum.Equal(dto.TotalAmount) { + return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount) } existing, err := s.receiptRepo.GetByID(dto.ID) @@ -290,7 +291,7 @@ func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWith } existing.Description = dto.Description - existing.TotalAmountCents = dto.TotalAmount + existing.TotalAmount = dto.TotalAmount existing.Date = dto.Date existing.UpdatedAt = time.Now() @@ -305,9 +306,9 @@ func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWith if err == nil { totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID) if err == nil { - if totalPaid >= loan.OriginalAmountCents && !loan.IsPaidOff { + if totalPaid.GreaterThanOrEqual(loan.OriginalAmount) && !loan.IsPaidOff { _ = s.loanRepo.SetPaidOff(loan.ID, true) - } else if totalPaid < loan.OriginalAmountCents && loan.IsPaidOff { + } else if totalPaid.LessThan(loan.OriginalAmount) && loan.IsPaidOff { _ = s.loanRepo.SetPaidOff(loan.ID, false) } } diff --git a/internal/service/recurring_expense.go b/internal/service/recurring_expense.go index 94ae983..0a2a684 100644 --- a/internal/service/recurring_expense.go +++ b/internal/service/recurring_expense.go @@ -8,13 +8,14 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateRecurringExpenseDTO struct { SpaceID string UserID string Description string - Amount int + Amount decimal.Decimal Type model.ExpenseType PaymentMethodID *string Frequency model.Frequency @@ -26,7 +27,7 @@ type CreateRecurringExpenseDTO struct { type UpdateRecurringExpenseDTO struct { ID string Description string - Amount int + Amount decimal.Decimal Type model.ExpenseType PaymentMethodID *string Frequency model.Frequency @@ -55,7 +56,7 @@ func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpe if dto.Description == "" { return nil, fmt.Errorf("description cannot be empty") } - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -65,7 +66,7 @@ func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpe SpaceID: dto.SpaceID, CreatedBy: dto.UserID, Description: dto.Description, - AmountCents: dto.Amount, + Amount: dto.Amount, Type: dto.Type, PaymentMethodID: dto.PaymentMethodID, Frequency: dto.Frequency, @@ -127,7 +128,7 @@ func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpe if dto.Description == "" { return nil, fmt.Errorf("description cannot be empty") } - if dto.Amount <= 0 { + if dto.Amount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -137,7 +138,7 @@ func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpe } existing.Description = dto.Description - existing.AmountCents = dto.Amount + existing.Amount = dto.Amount existing.Type = dto.Type existing.PaymentMethodID = dto.PaymentMethodID existing.Frequency = dto.Frequency @@ -229,7 +230,7 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, SpaceID: re.SpaceID, CreatedBy: re.CreatedBy, Description: re.Description, - AmountCents: re.AmountCents, + Amount: re.Amount, Type: re.Type, Date: re.NextOccurrence, PaymentMethodID: re.PaymentMethodID, diff --git a/internal/service/recurring_receipt.go b/internal/service/recurring_receipt.go index cd76b52..3c7747a 100644 --- a/internal/service/recurring_receipt.go +++ b/internal/service/recurring_receipt.go @@ -8,6 +8,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/google/uuid" + "github.com/shopspring/decimal" ) type CreateRecurringReceiptDTO struct { @@ -15,7 +16,7 @@ type CreateRecurringReceiptDTO struct { SpaceID string UserID string Description string - TotalAmount int + TotalAmount decimal.Decimal Frequency model.Frequency StartDate time.Time EndDate *time.Time @@ -25,7 +26,7 @@ type CreateRecurringReceiptDTO struct { type UpdateRecurringReceiptDTO struct { ID string Description string - TotalAmount int + TotalAmount decimal.Decimal Frequency model.Frequency StartDate time.Time EndDate *time.Time @@ -57,36 +58,36 @@ func NewRecurringReceiptService( } func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) { - if dto.TotalAmount <= 0 { + if dto.TotalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } if len(dto.FundingSources) == 0 { return nil, fmt.Errorf("at least one funding source is required") } - var sum int + sum := decimal.Zero for _, src := range dto.FundingSources { - sum += src.Amount + sum = sum.Add(src.Amount) } - if sum != dto.TotalAmount { + if !sum.Equal(dto.TotalAmount) { return nil, fmt.Errorf("funding source amounts must equal total amount") } now := time.Now() rr := &model.RecurringReceipt{ - ID: uuid.NewString(), - LoanID: dto.LoanID, - SpaceID: dto.SpaceID, - Description: dto.Description, - TotalAmountCents: dto.TotalAmount, - Frequency: dto.Frequency, - StartDate: dto.StartDate, - EndDate: dto.EndDate, - NextOccurrence: dto.StartDate, - IsActive: true, - CreatedBy: dto.UserID, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + LoanID: dto.LoanID, + SpaceID: dto.SpaceID, + Description: dto.Description, + TotalAmount: dto.TotalAmount, + Frequency: dto.Frequency, + StartDate: dto.StartDate, + EndDate: dto.EndDate, + NextOccurrence: dto.StartDate, + IsActive: true, + CreatedBy: dto.UserID, + CreatedAt: now, + UpdatedAt: now, } sources := make([]model.RecurringReceiptSource, len(dto.FundingSources)) @@ -95,7 +96,7 @@ func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringRece ID: uuid.NewString(), RecurringReceiptID: rr.ID, SourceType: src.SourceType, - AmountCents: src.Amount, + Amount: src.Amount, } if src.SourceType == model.FundingSourceAccount { acctID := src.AccountID @@ -142,7 +143,7 @@ func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID } func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) { - if dto.TotalAmount <= 0 { + if dto.TotalAmount.LessThanOrEqual(decimal.Zero) { return nil, fmt.Errorf("amount must be positive") } @@ -152,7 +153,7 @@ func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringRece } existing.Description = dto.Description - existing.TotalAmountCents = dto.TotalAmount + existing.TotalAmount = dto.TotalAmount existing.Frequency = dto.Frequency existing.StartDate = dto.StartDate existing.EndDate = dto.EndDate @@ -168,7 +169,7 @@ func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringRece ID: uuid.NewString(), RecurringReceiptID: existing.ID, SourceType: src.SourceType, - AmountCents: src.Amount, + Amount: src.Amount, } if src.SourceType == model.FundingSourceAccount { acctID := src.AccountID @@ -262,7 +263,7 @@ func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, fundingSources[i] = FundingSourceDTO{ SourceType: src.SourceType, AccountID: accountID, - Amount: src.AmountCents, + Amount: src.Amount, } } @@ -272,7 +273,7 @@ func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, SpaceID: rr.SpaceID, UserID: rr.CreatedBy, Description: rr.Description, - TotalAmount: rr.TotalAmountCents, + TotalAmount: rr.TotalAmount, Date: rr.NextOccurrence, FundingSources: fundingSources, RecurringReceiptID: &rrID, diff --git a/internal/service/report.go b/internal/service/report.go index 16831af..4de051b 100644 --- a/internal/service/report.go +++ b/internal/service/report.go @@ -73,7 +73,7 @@ func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (* TopExpenses: topWithTags, TotalIncome: totalIncome, TotalExpenses: totalExpenses, - NetBalance: totalIncome - totalExpenses, + NetBalance: totalIncome.Sub(totalExpenses), }, nil } diff --git a/internal/testutil/seed.go b/internal/testutil/seed.go index 7f59364..ec9ebe4 100644 --- a/internal/testutil/seed.go +++ b/internal/testutil/seed.go @@ -7,6 +7,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) // CreateTestUser inserts a user directly into the database. @@ -152,7 +153,7 @@ func CreateTestListItem(t *testing.T, db *sqlx.DB, listID, name, createdBy strin } // CreateTestExpense inserts an expense directly into the database. -func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount int, typ model.ExpenseType) *model.Expense { +func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount decimal.Decimal, typ model.ExpenseType) *model.Expense { t.Helper() now := time.Now() expense := &model.Expense{ @@ -160,15 +161,15 @@ func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, SpaceID: spaceID, CreatedBy: userID, Description: desc, - AmountCents: amount, + Amount: amount, Type: typ, Date: now, CreatedAt: now, UpdatedAt: now, } _, err := db.Exec( - `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, + `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, created_at, updated_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0)`, + expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt, ) if err != nil { @@ -200,20 +201,20 @@ func CreateTestMoneyAccount(t *testing.T, db *sqlx.DB, spaceID, name, createdBy } // CreateTestTransfer inserts an account transfer directly into the database. -func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount int, direction model.TransferDirection, createdBy string) *model.AccountTransfer { +func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount decimal.Decimal, direction model.TransferDirection, createdBy string) *model.AccountTransfer { t.Helper() transfer := &model.AccountTransfer{ - ID: uuid.NewString(), - AccountID: accountID, - AmountCents: amount, - Direction: direction, - Note: "test transfer", - CreatedBy: createdBy, - CreatedAt: time.Now(), + ID: uuid.NewString(), + AccountID: accountID, + Amount: amount, + Direction: direction, + Note: "test transfer", + CreatedBy: createdBy, + CreatedAt: time.Now(), } _, err := db.Exec( - `INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt, + `INSERT INTO account_transfers (id, account_id, amount, direction, note, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, 0)`, + transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt, ) if err != nil { t.Fatalf("CreateTestTransfer: %v", err) diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 2b512e6..12bdf5a 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -14,6 +14,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod" "git.juancwu.dev/juancwu/budgit/internal/ui/components/radio" "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox" + "github.com/shopspring/decimal" ) type AddExpenseFormProps struct { @@ -215,7 +216,7 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho Name: "amount", ID: "edit-amount-" + exp.ID, Type: "number", - Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0), + Value: model.FormatDecimal(exp.Amount), Attributes: templ.Attributes{"step": "0.01", "required": "true"}, }) @@ -345,7 +346,7 @@ templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob boo } -templ BalanceCard(spaceID string, balance int, allocated int, oob bool) { +templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) {
- { fmt.Sprintf("$%.2f", float64(balance)/100.0) } - if allocated > 0 { +
+ { model.FormatMoney(balance) } + if allocated.GreaterThan(decimal.Zero) { - ({ fmt.Sprintf("$%.2f", float64(allocated)/100.0) } in accounts) + ({ model.FormatMoney(allocated) } in accounts) }
diff --git a/internal/ui/components/moneyaccount/moneyaccount.templ b/internal/ui/components/moneyaccount/moneyaccount.templ index a482164..1eedd79 100644 --- a/internal/ui/components/moneyaccount/moneyaccount.templ +++ b/internal/ui/components/moneyaccount/moneyaccount.templ @@ -11,9 +11,10 @@ import ( "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/pagination" + "github.com/shopspring/decimal" ) -templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, oob bool) { +templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {Total Balance
-- { fmt.Sprintf("$%.2f", float64(totalBalance)/100.0) } +
+ { model.FormatMoney(totalBalance) }
Allocated
- { fmt.Sprintf("$%.2f", float64(totalBalance-availableBalance)/100.0) } + { model.FormatMoney(totalBalance.Sub(availableBalance)) }
Available
-- { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) } +
+ { model.FormatMoney(availableBalance) }
- { fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) } +
+ { model.FormatMoney(acct.Balance) }
- - { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) } + - { model.FormatMoney(re.Amount) }
} else {- + { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) } + + { model.FormatMoney(re.Amount) }
} // Toggle pause/resume @@ -352,7 +352,7 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet Name: "amount", ID: "edit-recurring-amount-" + re.ID, Type: "number", - Value: fmt.Sprintf("%.2f", float64(re.AmountCents)/100.0), + Value: model.FormatDecimal(re.Amount), Attributes: templ.Attributes{"step": "0.01", "required": "true"}, })Over budget by { fmt.Sprintf("$%.2f", float64(b.SpentCents-b.AmountCents)/100.0) }
+Over budget by { model.FormatMoney(b.Spent.Sub(b.Amount)) }
}- - { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } + - { model.FormatMoney(exp.Amount) }
} else {- + { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } + + { model.FormatMoney(exp.Amount) }
} // Edit button @@ -186,12 +187,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methoOriginal
-{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }
+{ model.FormatMoney(loan.OriginalAmount) }
Paid
-{ fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }
+{ model.FormatMoney(loan.TotalPaid) }
Remaining
- if loan.RemainingCents > 0 { - { fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) } + if loan.Remaining.GreaterThan(decimal.Zero) { + { model.FormatMoney(loan.Remaining) } } else { $0.00 } @@ -244,7 +245,7 @@ templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesA
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } + { model.FormatMoney(exp.Amount) }
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } + { model.FormatMoney(exp.Amount) }