Merge branch 'fix/calculation-accuracy' into main
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s

Combines the decimal migration (int cents → decimal.Decimal via
shopspring/decimal) with main's handler refactor (split space.go into
domain handlers, WithTx/Paginate helpers, recurring deposit removal).

- Repository layer: WithTx pattern + decimal column names/types
- Handler layer: decimal arithmetic (.Sub/.Add) instead of int operators
- Models: deprecated amount_cents fields kept for SELECT * compatibility
- INSERT statements: old columns set to literal 0 for NOT NULL constraints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-03-14 16:48:40 -04:00
commit 89c5d76e5e
No known key found for this signature in database
46 changed files with 661 additions and 539 deletions

View file

@ -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))

View file

@ -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,

View file

@ -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))
}

View file

@ -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),

View file

@ -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()

View file

@ -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) {