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

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

View file

@ -73,7 +73,7 @@ func (h *AccountHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
return return
} }
availableBalance := totalBalance - totalAllocated availableBalance := totalBalance.Sub(totalAllocated)
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
if err != nil { if err != nil {
@ -113,7 +113,7 @@ func (h *AccountHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
acctWithBalance := model.MoneyAccountWithBalance{ acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account, MoneyAccount: *account,
BalanceCents: 0, Balance: decimal.Zero,
} }
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) 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{ acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *updatedAccount, MoneyAccount: *updatedAccount,
BalanceCents: balance, Balance: balance,
} }
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) 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) 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{ ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Account deleted", Title: "Account deleted",
Variant: toast.VariantSuccess, 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) ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
// Calculate available space balance for deposit validation // Calculate available space balance for deposit validation
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID) 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) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
availableBalance := totalBalance - totalAllocated availableBalance := totalBalance.Sub(totalAllocated)
// Validate balance limits before creating transfer // Validate balance limits before creating transfer
if direction == model.TransferDirectionDeposit && amountCents > availableBalance { if direction == model.TransferDirectionDeposit && amount.GreaterThan(availableBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0), http.StatusUnprocessableEntity) ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to %s.", model.FormatMoney(availableBalance)), http.StatusUnprocessableEntity)
return return
} }
@ -251,15 +251,15 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
if amountCents > acctBalance { if amount.GreaterThan(acctBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0), http.StatusUnprocessableEntity) ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to %s.", model.FormatMoney(acctBalance)), http.StatusUnprocessableEntity)
return return
} }
} }
_, err = h.accountService.CreateTransfer(service.CreateTransferDTO{ _, err = h.accountService.CreateTransfer(service.CreateTransferDTO{
AccountID: accountID, AccountID: accountID,
Amount: amountCents, Amount: amount,
Direction: direction, Direction: direction,
Note: note, Note: note,
CreatedBy: user.ID, CreatedBy: user.ID,
@ -281,12 +281,12 @@ func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request)
account, _ := h.accountService.GetAccount(accountID) account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{ acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account, MoneyAccount: *account,
BalanceCents: accountBalance, Balance: accountBalance,
} }
// Recalculate available balance after transfer // Recalculate available balance after transfer
totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID) totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID)
newAvailable := totalBalance - totalAllocated newAvailable := totalBalance.Sub(totalAllocated)
w.Header().Set("HX-Trigger", "transferSuccess") w.Header().Set("HX-Trigger", "transferSuccess")
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) 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) account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{ acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account, MoneyAccount: *account,
BalanceCents: accountBalance, Balance: accountBalance,
} }
totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID) totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID)
totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID) totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID)
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) 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) transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) 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) ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
startDate, err := time.Parse("2006-01-02", startDateStr) startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil { if err != nil {
@ -127,7 +127,7 @@ func (h *BudgetHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
_, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{ _, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{
SpaceID: spaceID, SpaceID: spaceID,
TagIDs: tagIDs, TagIDs: tagIDs,
Amount: amountCents, Amount: amount,
Period: model.BudgetPeriod(periodStr), Period: model.BudgetPeriod(periodStr),
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
@ -186,7 +186,7 @@ func (h *BudgetHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
startDate, err := time.Parse("2006-01-02", startDateStr) startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil { if err != nil {
@ -207,7 +207,7 @@ func (h *BudgetHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
_, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{ _, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{
ID: budgetID, ID: budgetID,
TagIDs: tagIDs, TagIDs: tagIDs,
Amount: amountCents, Amount: amount,
Period: model.BudgetPeriod(periodStr), Period: model.BudgetPeriod(periodStr),
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,

View file

@ -81,9 +81,9 @@ func (h *ExpenseHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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) tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil { 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) ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
date, err := time.Parse("2006-01-02", dateStr) date, err := time.Parse("2006-01-02", dateStr)
if err != nil { if err != nil {
@ -220,7 +220,7 @@ func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
SpaceID: spaceID, SpaceID: spaceID,
UserID: user.ID, UserID: user.ID,
Description: description, Description: description,
Amount: amountCents, Amount: amount,
Type: expenseType, Type: expenseType,
Date: date, Date: date,
TagIDs: finalTagIDs, TagIDs: finalTagIDs,
@ -263,9 +263,9 @@ func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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 // Return the full paginated list for page 1 so the new expense appears
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, 1) 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) ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
date, err := time.Parse("2006-01-02", dateStr) date, err := time.Parse("2006-01-02", dateStr)
if err != nil { if err != nil {
@ -377,7 +377,7 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
ID: expenseID, ID: expenseID,
SpaceID: spaceID, SpaceID: spaceID,
Description: description, Description: description,
Amount: amountCents, Amount: amount,
Type: expenseType, Type: expenseType,
Date: date, Date: date,
TagIDs: finalTagIDs, TagIDs: finalTagIDs,
@ -407,9 +407,9 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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) methods, _ := h.methodService.GetMethodsForSpace(spaceID)
updatedTags, _ := h.tagService.GetTagsForSpace(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) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{ 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) totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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)) 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) ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
startDate, err := time.Parse("2006-01-02", startDateStr) startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil { if err != nil {
@ -176,7 +176,7 @@ func (h *RecurringHandler) CreateRecurringExpense(w http.ResponseWriter, r *http
SpaceID: spaceID, SpaceID: spaceID,
UserID: user.ID, UserID: user.ID,
Description: description, Description: description,
Amount: amountCents, Amount: amount,
Type: expenseType, Type: expenseType,
PaymentMethodID: paymentMethodID, PaymentMethodID: paymentMethodID,
Frequency: frequency, Frequency: frequency,
@ -235,7 +235,7 @@ func (h *RecurringHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) amount := amountDecimal
startDate, err := time.Parse("2006-01-02", startDateStr) startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil { if err != nil {
@ -290,7 +290,7 @@ func (h *RecurringHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http
updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{ updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{
ID: recurringID, ID: recurringID,
Description: description, Description: description,
Amount: amountCents, Amount: amount,
Type: model.ExpenseType(typeStr), Type: model.ExpenseType(typeStr),
PaymentMethodID: paymentMethodID, PaymentMethodID: paymentMethodID,
Frequency: model.Frequency(frequencyStr), Frequency: model.Frequency(frequencyStr),

View file

@ -7,6 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service" "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) allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID) 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 // This month's report
now := time.Now() now := time.Now()

View file

@ -62,8 +62,6 @@ func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
interestStr := r.FormValue("interest_rate") interestStr := r.FormValue("interest_rate")
var interestBps int var interestBps int
if interestStr != "" { if interestStr != "" {
@ -93,7 +91,7 @@ func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) {
UserID: user.ID, UserID: user.ID,
Name: name, Name: name,
Description: description, Description: description,
OriginalAmount: amountCents, OriginalAmount: amount,
InterestRateBps: interestBps, InterestRateBps: interestBps,
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
@ -165,7 +163,7 @@ func (h *SpaceHandler) LoanDetailPage(w http.ResponseWriter, r *http.Request) {
balance, err := h.expenseService.GetBalanceForSpace(spaceID) balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get balance", "error", err) 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)) 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) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
interestStr := r.FormValue("interest_rate") interestStr := r.FormValue("interest_rate")
var interestBps int var interestBps int
if interestStr != "" { if interestStr != "" {
@ -220,7 +216,7 @@ func (h *SpaceHandler) UpdateLoan(w http.ResponseWriter, r *http.Request) {
ID: loanID, ID: loanID,
Name: name, Name: name,
Description: description, Description: description,
OriginalAmount: amountCents, OriginalAmount: amount,
InterestRateBps: interestBps, InterestRateBps: interestBps,
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
@ -267,8 +263,6 @@ func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
dateStr := r.FormValue("date") dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr) date, err := time.Parse("2006-01-02", dateStr)
if err != nil { if err != nil {
@ -288,7 +282,7 @@ func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) {
SpaceID: spaceID, SpaceID: spaceID,
UserID: user.ID, UserID: user.ID,
Description: description, Description: description,
TotalAmount: totalAmountCents, TotalAmount: amount,
Date: date, Date: date,
FundingSources: fundingSources, FundingSources: fundingSources,
} }
@ -320,8 +314,6 @@ func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
dateStr := r.FormValue("date") dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr) date, err := time.Parse("2006-01-02", dateStr)
if err != nil { if err != nil {
@ -340,7 +332,7 @@ func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) {
SpaceID: spaceID, SpaceID: spaceID,
UserID: user.ID, UserID: user.ID,
Description: description, Description: description,
TotalAmount: totalAmountCents, TotalAmount: amount,
Date: date, Date: date,
FundingSources: fundingSources, FundingSources: fundingSources,
} }
@ -405,8 +397,6 @@ func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
frequency := model.Frequency(r.FormValue("frequency")) frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date") startDateStr := r.FormValue("start_date")
@ -436,7 +426,7 @@ func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Req
SpaceID: spaceID, SpaceID: spaceID,
UserID: user.ID, UserID: user.ID,
Description: description, Description: description,
TotalAmount: totalAmountCents, TotalAmount: amount,
Frequency: frequency, Frequency: frequency,
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
@ -468,8 +458,6 @@ func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
return return
} }
totalAmountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
frequency := model.Frequency(r.FormValue("frequency")) frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date") startDateStr := r.FormValue("start_date")
@ -497,7 +485,7 @@ func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Req
dto := service.UpdateRecurringReceiptDTO{ dto := service.UpdateRecurringReceiptDTO{
ID: recurringReceiptID, ID: recurringReceiptID,
Description: description, Description: description,
TotalAmount: totalAmountCents, TotalAmount: amount,
Frequency: frequency, Frequency: frequency,
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
@ -570,11 +558,9 @@ func parseFundingSources(r *http.Request) ([]service.FundingSourceDTO, error) {
if err != nil || amount.LessThanOrEqual(decimal.Zero) { if err != nil || amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("invalid funding source amount") return nil, fmt.Errorf("invalid funding source amount")
} }
amountCents := int(amount.Mul(decimal.NewFromInt(100)).IntPart())
src := service.FundingSourceDTO{ src := service.FundingSourceDTO{
SourceType: model.FundingSourceType(srcType), SourceType: model.FundingSourceType(srcType),
Amount: amountCents, Amount: amount,
} }
if srcType == string(model.FundingSourceAccount) { if srcType == string(model.FundingSourceAccount) {

View file

@ -3,6 +3,8 @@ package model
import ( import (
"strings" "strings"
"time" "time"
"github.com/shopspring/decimal"
) )
type BudgetPeriod string type BudgetPeriod string
@ -22,22 +24,23 @@ const (
) )
type Budget struct { type Budget struct {
ID string `db:"id"` ID string `db:"id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
AmountCents int `db:"amount_cents"` Amount decimal.Decimal `db:"amount"`
Period BudgetPeriod `db:"period"` AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility
StartDate time.Time `db:"start_date"` Period BudgetPeriod `db:"period"`
EndDate *time.Time `db:"end_date"` StartDate time.Time `db:"start_date"`
IsActive bool `db:"is_active"` EndDate *time.Time `db:"end_date"`
CreatedBy string `db:"created_by"` IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"` CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type BudgetWithSpent struct { type BudgetWithSpent struct {
Budget Budget
Tags []*Tag Tags []*Tag
SpentCents int Spent decimal.Decimal
Percentage float64 Percentage float64
Status BudgetStatus Status BudgetStatus
} }

View file

@ -1,6 +1,10 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type ExpenseType string type ExpenseType string
@ -10,17 +14,18 @@ const (
) )
type Expense struct { type Expense struct {
ID string `db:"id"` ID string `db:"id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
CreatedBy string `db:"created_by"` CreatedBy string `db:"created_by"`
Description string `db:"description"` Description string `db:"description"`
AmountCents int `db:"amount_cents"` Amount decimal.Decimal `db:"amount"`
Type ExpenseType `db:"type"` AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility
Date time.Time `db:"date"` Type ExpenseType `db:"type"`
PaymentMethodID *string `db:"payment_method_id"` Date time.Time `db:"date"`
RecurringExpenseID *string `db:"recurring_expense_id"` PaymentMethodID *string `db:"payment_method_id"`
CreatedAt time.Time `db:"created_at"` RecurringExpenseID *string `db:"recurring_expense_id"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type ExpenseWithTags struct { type ExpenseWithTags struct {
@ -45,8 +50,8 @@ type ExpenseItem struct {
} }
type TagExpenseSummary struct { type TagExpenseSummary struct {
TagID string `db:"tag_id"` TagID string `db:"tag_id"`
TagName string `db:"tag_name"` TagName string `db:"tag_name"`
TagColor *string `db:"tag_color"` TagColor *string `db:"tag_color"`
TotalAmount int `db:"total_amount"` TotalAmount decimal.Decimal `db:"total_amount"`
} }

View file

@ -1,25 +1,30 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type Loan struct { type Loan struct {
ID string `db:"id"` ID string `db:"id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
Name string `db:"name"` Name string `db:"name"`
Description string `db:"description"` Description string `db:"description"`
OriginalAmountCents int `db:"original_amount_cents"` OriginalAmount decimal.Decimal `db:"original_amount"`
InterestRateBps int `db:"interest_rate_bps"` OriginalAmountCents int `db:"original_amount_cents"` // deprecated: kept for SELECT * compatibility
StartDate time.Time `db:"start_date"` InterestRateBps int `db:"interest_rate_bps"`
EndDate *time.Time `db:"end_date"` StartDate time.Time `db:"start_date"`
IsPaidOff bool `db:"is_paid_off"` EndDate *time.Time `db:"end_date"`
CreatedBy string `db:"created_by"` IsPaidOff bool `db:"is_paid_off"`
CreatedAt time.Time `db:"created_at"` CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type LoanWithPaymentSummary struct { type LoanWithPaymentSummary struct {
Loan Loan
TotalPaidCents int TotalPaid decimal.Decimal
RemainingCents int Remaining decimal.Decimal
ReceiptCount int ReceiptCount int
} }

17
internal/model/money.go Normal file
View file

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

View file

@ -1,6 +1,10 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type TransferDirection string type TransferDirection string
@ -21,7 +25,8 @@ type MoneyAccount struct {
type AccountTransfer struct { type AccountTransfer struct {
ID string `db:"id"` ID string `db:"id"`
AccountID string `db:"account_id"` AccountID string `db:"account_id"`
AmountCents int `db:"amount_cents"` Amount decimal.Decimal `db:"amount"`
AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility
Direction TransferDirection `db:"direction"` Direction TransferDirection `db:"direction"`
Note string `db:"note"` Note string `db:"note"`
RecurringDepositID *string `db:"recurring_deposit_id"` RecurringDepositID *string `db:"recurring_deposit_id"`
@ -31,7 +36,7 @@ type AccountTransfer struct {
type MoneyAccountWithBalance struct { type MoneyAccountWithBalance struct {
MoneyAccount MoneyAccount
BalanceCents int Balance decimal.Decimal
} }
type AccountTransferWithAccount struct { type AccountTransferWithAccount struct {

View file

@ -1,6 +1,10 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type FundingSourceType string type FundingSourceType string
@ -10,16 +14,17 @@ const (
) )
type Receipt struct { type Receipt struct {
ID string `db:"id"` ID string `db:"id"`
LoanID string `db:"loan_id"` LoanID string `db:"loan_id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
Description string `db:"description"` Description string `db:"description"`
TotalAmountCents int `db:"total_amount_cents"` TotalAmount decimal.Decimal `db:"total_amount"`
Date time.Time `db:"date"` TotalAmountCents int `db:"total_amount_cents"` // deprecated: kept for SELECT * compatibility
RecurringReceiptID *string `db:"recurring_receipt_id"` Date time.Time `db:"date"`
CreatedBy string `db:"created_by"` RecurringReceiptID *string `db:"recurring_receipt_id"`
CreatedAt time.Time `db:"created_at"` CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type ReceiptFundingSource struct { type ReceiptFundingSource struct {
@ -27,7 +32,8 @@ type ReceiptFundingSource struct {
ReceiptID string `db:"receipt_id"` ReceiptID string `db:"receipt_id"`
SourceType FundingSourceType `db:"source_type"` SourceType FundingSourceType `db:"source_type"`
AccountID *string `db:"account_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
LinkedExpenseID *string `db:"linked_expense_id"` LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"` LinkedTransferID *string `db:"linked_transfer_id"`
} }

View file

@ -1,6 +1,10 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type Frequency string type Frequency string
@ -13,20 +17,21 @@ const (
) )
type RecurringExpense struct { type RecurringExpense struct {
ID string `db:"id"` ID string `db:"id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
CreatedBy string `db:"created_by"` CreatedBy string `db:"created_by"`
Description string `db:"description"` Description string `db:"description"`
AmountCents int `db:"amount_cents"` Amount decimal.Decimal `db:"amount"`
Type ExpenseType `db:"type"` AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility
PaymentMethodID *string `db:"payment_method_id"` Type ExpenseType `db:"type"`
Frequency Frequency `db:"frequency"` PaymentMethodID *string `db:"payment_method_id"`
StartDate time.Time `db:"start_date"` Frequency Frequency `db:"frequency"`
EndDate *time.Time `db:"end_date"` StartDate time.Time `db:"start_date"`
NextOccurrence time.Time `db:"next_occurrence"` EndDate *time.Time `db:"end_date"`
IsActive bool `db:"is_active"` NextOccurrence time.Time `db:"next_occurrence"`
CreatedAt time.Time `db:"created_at"` IsActive bool `db:"is_active"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type RecurringExpenseWithTags struct { type RecurringExpenseWithTags struct {

View file

@ -1,21 +1,26 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type RecurringReceipt struct { type RecurringReceipt struct {
ID string `db:"id"` ID string `db:"id"`
LoanID string `db:"loan_id"` LoanID string `db:"loan_id"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
Description string `db:"description"` Description string `db:"description"`
TotalAmountCents int `db:"total_amount_cents"` TotalAmount decimal.Decimal `db:"total_amount"`
Frequency Frequency `db:"frequency"` TotalAmountCents int `db:"total_amount_cents"` // deprecated: kept for SELECT * compatibility
StartDate time.Time `db:"start_date"` Frequency Frequency `db:"frequency"`
EndDate *time.Time `db:"end_date"` StartDate time.Time `db:"start_date"`
NextOccurrence time.Time `db:"next_occurrence"` EndDate *time.Time `db:"end_date"`
IsActive bool `db:"is_active"` NextOccurrence time.Time `db:"next_occurrence"`
CreatedBy string `db:"created_by"` IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"` CreatedBy string `db:"created_by"`
UpdatedAt time.Time `db:"updated_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type RecurringReceiptSource struct { type RecurringReceiptSource struct {
@ -23,7 +28,8 @@ type RecurringReceiptSource struct {
RecurringReceiptID string `db:"recurring_receipt_id"` RecurringReceiptID string `db:"recurring_receipt_id"`
SourceType FundingSourceType `db:"source_type"` SourceType FundingSourceType `db:"source_type"`
AccountID *string `db:"account_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
} }
type RecurringReceiptWithSources struct { type RecurringReceiptWithSources struct {

View file

@ -1,15 +1,19 @@
package model package model
import "time" import (
"time"
"github.com/shopspring/decimal"
)
type DailySpending struct { type DailySpending struct {
Date time.Time `db:"date"` Date time.Time `db:"date"`
TotalCents int `db:"total_cents"` Total decimal.Decimal `db:"total"`
} }
type MonthlySpending struct { type MonthlySpending struct {
Month string `db:"month"` Month string `db:"month"`
TotalCents int `db:"total_cents"` Total decimal.Decimal `db:"total"`
} }
type SpendingReport struct { type SpendingReport struct {
@ -17,7 +21,7 @@ type SpendingReport struct {
DailySpending []*DailySpending DailySpending []*DailySpending
MonthlySpending []*MonthlySpending MonthlySpending []*MonthlySpending
TopExpenses []*ExpenseWithTagsAndMethod TopExpenses []*ExpenseWithTagsAndMethod
TotalIncome int TotalIncome decimal.Decimal
TotalExpenses int TotalExpenses decimal.Decimal
NetBalance int NetBalance decimal.Decimal
} }

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
var ( var (
@ -17,7 +18,7 @@ type BudgetRepository interface {
Create(budget *model.Budget, tagIDs []string) error Create(budget *model.Budget, tagIDs []string) error
GetByID(id string) (*model.Budget, error) GetByID(id string) (*model.Budget, error)
GetBySpaceID(spaceID 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) GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error)
Update(budget *model.Budget, tagIDs []string) error Update(budget *model.Budget, tagIDs []string) error
Delete(id 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 { func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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) 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);` VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`
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 { 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 return err
} }
@ -67,23 +68,23 @@ func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error)
return budgets, err 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 { if len(tagIDs) == 0 {
return 0, nil return decimal.Zero, nil
} }
query, args, err := sqlx.In(` query, args, err := sqlx.In(`
SELECT COALESCE(SUM(e.amount_cents), 0) SELECT COALESCE(SUM(CAST(e.amount AS DECIMAL)), 0)
FROM expenses e FROM expenses e
WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ? 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 (?)) AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?))
`, spaceID, periodStart, periodEnd, tagIDs) `, spaceID, periodStart, periodEnd, tagIDs)
if err != nil { if err != nil {
return 0, err return decimal.Zero, err
} }
query = r.db.Rebind(query) query = r.db.Rebind(query)
var spent int var spent decimal.Decimal
err = r.db.Get(&spent, query, args...) err = r.db.Get(&spent, query, args...)
return spent, err 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 { func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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;` 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.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil { if _, err := tx.Exec(query, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil {
return err return err
} }

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
var ( var (
@ -28,7 +29,7 @@ type ExpenseRepository interface {
GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error)
GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error) GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error)
GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, 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 { 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 { func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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) 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);` VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`
_, 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 := 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 {
if err != nil {
return err return err
} }
@ -113,7 +113,7 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti
t.id as tag_id, t.id as tag_id,
t.name as tag_name, t.name as tag_name,
t.color as tag_color, t.color as tag_color,
SUM(e.amount_cents) as total_amount SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id JOIN expense_tags et ON e.id = et.expense_id
JOIN tags t ON et.tag_id = t.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 { func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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;` 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.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil { if _, err := tx.Exec(query, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil {
return err 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) { func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
var results []*model.DailySpending var results []*model.DailySpending
query := ` query := `
SELECT date, SUM(amount_cents) as total_cents SELECT date, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY date GROUP BY date
@ -267,14 +267,14 @@ func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Tim
var query string var query string
if r.db.DriverName() == "sqlite" { if r.db.DriverName() == "sqlite" {
query = ` 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 FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY strftime('%Y-%m', date) GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC;` ORDER BY month ASC;`
} else { } else {
query = ` 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 FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY TO_CHAR(date, 'YYYY-MM') GROUP BY TO_CHAR(date, 'YYYY-MM')
@ -289,37 +289,38 @@ func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, l
query := ` query := `
SELECT * FROM expenses SELECT * FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3 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; LIMIT $4;
` `
err := r.db.Select(&results, query, spaceID, from, to, limit) err := r.db.Select(&results, query, spaceID, from, to, limit)
return results, err 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 summary struct {
Type string `db:"type"` Type string `db:"type"`
Total int `db:"total"` Total decimal.Decimal `db:"total"`
} }
var results []summary var results []summary
query := ` query := `
SELECT type, COALESCE(SUM(amount_cents), 0) as total SELECT type, COALESCE(SUM(CAST(amount AS DECIMAL)), 0) as total
FROM expenses FROM expenses
WHERE space_id = $1 AND date >= $2 AND date <= $3 WHERE space_id = $1 AND date >= $2 AND date <= $3
GROUP BY type; GROUP BY type;
` `
err := r.db.Select(&results, query, spaceID, from, to) err := r.db.Select(&results, query, spaceID, from, to)
if err != nil { 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 { for _, r := range results {
if r.Type == "topup" { if r.Type == "topup" {
income = r.Total income = r.Total
} else if r.Type == "expense" { } else if r.Type == "expense" {
expenses = r.Total expenseTotal = r.Total
} }
} }
return income, expenses, nil return income, expenseTotal, nil
} }

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil" "git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -24,7 +25,7 @@ func TestExpenseRepository_Create(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Lunch", Description: "Lunch",
AmountCents: 1500, Amount: decimal.RequireFromString("15.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
@ -38,7 +39,7 @@ func TestExpenseRepository_Create(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expense.ID, fetched.ID) assert.Equal(t, expense.ID, fetched.ID)
assert.Equal(t, "Lunch", fetched.Description) 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) 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) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") 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 1", decimal.RequireFromString("10.00"), 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 2", decimal.RequireFromString("20.00"), 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 3", decimal.RequireFromString("30.00"), model.ExpenseTypeExpense)
expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0) expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0)
require.NoError(t, err) require.NoError(t, err)
@ -65,8 +66,8 @@ func TestExpenseRepository_CountBySpaceID(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") 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 1", decimal.RequireFromString("10.00"), 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 2", decimal.RequireFromString("20.00"), model.ExpenseTypeExpense)
count, err := repo.CountBySpaceID(space.ID) count, err := repo.CountBySpaceID(space.ID)
require.NoError(t, err) require.NoError(t, err)
@ -87,7 +88,7 @@ func TestExpenseRepository_GetTagsByExpenseIDs(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Weekly groceries", Description: "Weekly groceries",
AmountCents: 5000, Amount: decimal.RequireFromString("50.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
@ -119,7 +120,7 @@ func TestExpenseRepository_GetPaymentMethodsByExpenseIDs(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Online purchase", Description: "Online purchase",
AmountCents: 3000, Amount: decimal.RequireFromString("30.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
PaymentMethodID: &method.ID, PaymentMethodID: &method.ID,
@ -156,7 +157,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Lunch", Description: "Lunch",
AmountCents: 1500, Amount: decimal.RequireFromString("15.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
@ -170,7 +171,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Dinner", Description: "Dinner",
AmountCents: 2500, Amount: decimal.RequireFromString("25.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
@ -184,7 +185,7 @@ func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
require.Len(t, summaries, 1) require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID) assert.Equal(t, tag.ID, summaries[0].TagID)
assert.Equal(t, "Food", summaries[0].TagName) 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, SpaceID: space.ID,
CreatedBy: user.ID, CreatedBy: user.ID,
Description: "Original", Description: "Original",
AmountCents: 1000, Amount: decimal.RequireFromString("10.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
@ -234,7 +235,7 @@ func TestExpenseRepository_Delete(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") 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) err := repo.Delete(expense.ID)
require.NoError(t, err) require.NoError(t, err)

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
var ( var (
@ -22,7 +23,7 @@ type LoanRepository interface {
Update(loan *model.Loan) error Update(loan *model.Loan) error
Delete(id string) error Delete(id string) error
SetPaidOff(id string, paidOff bool) error SetPaidOff(id string, paidOff bool) error
GetTotalPaidForLoan(loanID string) (int, error) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error)
GetReceiptCountForLoan(loanID string) (int, error) GetReceiptCountForLoan(loanID string) (int, error)
} }
@ -35,9 +36,9 @@ func NewLoanRepository(db *sqlx.DB) LoanRepository {
} }
func (r *loanRepository) Create(loan *model.Loan) error { 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) 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);` 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.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt) _, 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 return err
} }
@ -72,8 +73,8 @@ func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) {
} }
func (r *loanRepository) Update(loan *model.Loan) 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;` 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.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID) 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 { if err != nil {
return err return err
} }
@ -94,9 +95,9 @@ func (r *loanRepository) SetPaidOff(id string, paidOff bool) error {
return err return err
} }
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (int, error) { func (r *loanRepository) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) {
var total int var total decimal.Decimal
err := r.db.Get(&total, `SELECT COALESCE(SUM(total_amount_cents), 0) FROM receipts WHERE loan_id = $1;`, loanID) err := r.db.Get(&total, `SELECT COALESCE(SUM(CAST(total_amount AS DECIMAL)), 0) FROM receipts WHERE loan_id = $1;`, loanID)
return total, err return total, err
} }

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
var ( var (
@ -25,8 +26,8 @@ type MoneyAccountRepository interface {
GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error) GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error)
DeleteTransfer(id string) error DeleteTransfer(id string) error
GetAccountBalance(accountID string) (int, error) GetAccountBalance(accountID string) (decimal.Decimal, error)
GetTotalAllocatedForSpace(spaceID string) (int, error) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error)
GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error)
CountTransfersBySpaceID(spaceID string) (int, 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 { 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);` 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.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt) _, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt)
return err return err
} }
@ -122,16 +123,16 @@ func (r *moneyAccountRepository) DeleteTransfer(id string) error {
return err return err
} }
func (r *moneyAccountRepository) GetAccountBalance(accountID string) (int, error) { func (r *moneyAccountRepository) GetAccountBalance(accountID string) (decimal.Decimal, error) {
var balance int var balance decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN amount_cents ELSE -amount_cents END), 0) FROM account_transfers WHERE account_id = $1;` 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) err := r.db.Get(&balance, query, accountID)
return balance, err return balance, err
} }
func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int, error) { func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
var total int var total decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN t.amount_cents ELSE -t.amount_cents END), 0) 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 FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;` 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) { func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) {
var transfers []*model.AccountTransferWithAccount 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 t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name
FROM account_transfers t FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id JOIN money_accounts a ON t.account_id = a.id

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil" "git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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) account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := &model.AccountTransfer{ transfer := &model.AccountTransfer{
ID: uuid.NewString(), ID: uuid.NewString(),
AccountID: account.ID, AccountID: account.ID,
AmountCents: 5000, Amount: decimal.RequireFromString("50.00"),
Direction: model.TransferDirectionDeposit, Direction: model.TransferDirectionDeposit,
Note: "Initial deposit", Note: "Initial deposit",
CreatedBy: user.ID, CreatedBy: user.ID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
err := repo.CreateTransfer(transfer) err := repo.CreateTransfer(transfer)
@ -112,7 +113,7 @@ func TestMoneyAccountRepository_CreateTransfer(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, transfers, 1) require.Len(t, transfers, 1)
assert.Equal(t, transfer.ID, transfers[0].ID) 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) 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) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) 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) err := repo.DeleteTransfer(transfer.ID)
require.NoError(t, err) require.NoError(t, err)
@ -141,12 +142,12 @@ func TestMoneyAccountRepository_GetAccountBalance(t *testing.T) {
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) 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, decimal.RequireFromString("10.00"), 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("3.00"), model.TransferDirectionWithdrawal, user.ID)
balance, err := repo.GetAccountBalance(account.ID) balance, err := repo.GetAccountBalance(account.ID)
require.NoError(t, err) 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) account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", 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, account1.ID, decimal.RequireFromString("20.00"), model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 3000, 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) total, err := repo.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 5000, total) assert.True(t, decimal.RequireFromString("50.00").Equal(total))
}) })
} }

View file

@ -6,6 +6,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
var ( var (
@ -57,9 +58,9 @@ func (r *receiptRepository) CreateWithSources(
// Insert receipt // Insert receipt
_, err = tx.Exec( _, 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) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`,
receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt, receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmount, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt,
) )
if err != nil { if err != nil {
return err return err
@ -68,9 +69,9 @@ func (r *receiptRepository) CreateWithSources(
// Insert balance expense if present // Insert balance expense if present
if balanceExpense != nil { if balanceExpense != nil {
_, err = tx.Exec( _, 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) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, 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 { if err != nil {
return err return err
@ -80,9 +81,9 @@ func (r *receiptRepository) CreateWithSources(
// Insert account transfers // Insert account transfers
for _, transfer := range accountTransfers { for _, transfer := range accountTransfers {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
) )
if err != nil { if err != nil {
return err return err
@ -92,9 +93,9 @@ func (r *receiptRepository) CreateWithSources(
// Insert funding sources // Insert funding sources
for _, src := range sources { for _, src := range sources {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id) `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);`, Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID, src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
) )
if err != nil { if err != nil {
return err return err
@ -157,14 +158,14 @@ func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs
ReceiptID string `db:"receipt_id"` ReceiptID string `db:"receipt_id"`
SourceType model.FundingSourceType `db:"source_type"` SourceType model.FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"` AccountID *string `db:"account_id"`
AmountCents int `db:"amount_cents"` Amount decimal.Decimal `db:"amount"`
LinkedExpenseID *string `db:"linked_expense_id"` LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"` LinkedTransferID *string `db:"linked_transfer_id"`
AccountName *string `db:"account_name"` AccountName *string `db:"account_name"`
} }
query, args, err := sqlx.In(` 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, rfs.linked_expense_id, rfs.linked_transfer_id,
ma.name AS account_name ma.name AS account_name
FROM receipt_funding_sources rfs FROM receipt_funding_sources rfs
@ -194,7 +195,7 @@ func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs
ReceiptID: rw.ReceiptID, ReceiptID: rw.ReceiptID,
SourceType: rw.SourceType, SourceType: rw.SourceType,
AccountID: rw.AccountID, AccountID: rw.AccountID,
AmountCents: rw.AmountCents, Amount: rw.Amount,
LinkedExpenseID: rw.LinkedExpenseID, LinkedExpenseID: rw.LinkedExpenseID,
LinkedTransferID: rw.LinkedTransferID, LinkedTransferID: rw.LinkedTransferID,
}, },
@ -279,8 +280,8 @@ func (r *receiptRepository) UpdateWithSources(
// Update receipt // Update receipt
_, err = tx.Exec( _, err = tx.Exec(
`UPDATE receipts SET description = $1, total_amount_cents = $2, date = $3, updated_at = $4 WHERE id = $5;`, `UPDATE receipts SET description = $1, total_amount = $2, date = $3, updated_at = $4 WHERE id = $5;`,
receipt.Description, receipt.TotalAmountCents, receipt.Date, receipt.UpdatedAt, receipt.ID, receipt.Description, receipt.TotalAmount, receipt.Date, receipt.UpdatedAt, receipt.ID,
) )
if err != nil { if err != nil {
return err return err
@ -289,9 +290,9 @@ func (r *receiptRepository) UpdateWithSources(
// Insert new balance expense // Insert new balance expense
if balanceExpense != nil { if balanceExpense != nil {
_, err = tx.Exec( _, 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) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.AmountCents, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt, 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 { if err != nil {
return err return err
@ -301,9 +302,9 @@ func (r *receiptRepository) UpdateWithSources(
// Insert new account transfers // Insert new account transfers
for _, transfer := range accountTransfers { for _, transfer := range accountTransfers {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, recurring_deposit_id, created_by, created_at) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
) )
if err != nil { if err != nil {
return err return err
@ -313,9 +314,9 @@ func (r *receiptRepository) UpdateWithSources(
// Insert new funding sources // Insert new funding sources
for _, src := range sources { for _, src := range sources {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount_cents, linked_expense_id, linked_transfer_id) `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);`, Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.LinkedExpenseID, src.LinkedTransferID, src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
) )
if err != nil { if err != nil {
return err return err

View file

@ -38,9 +38,9 @@ func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository {
func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error { func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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) 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);` 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.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil { 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 return err
} }
@ -161,8 +161,8 @@ func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids
func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error { func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) 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;` 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.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil { 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 return err
} }

View file

@ -44,9 +44,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec( _, 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) `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);`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`,
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, 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 { if err != nil {
return err return err
@ -54,9 +54,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources
for _, src := range sources { for _, src := range sources {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents) `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5);`, VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
) )
if err != nil { if err != nil {
return err return err
@ -105,8 +105,8 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec( _, 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;`, `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.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID, rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
) )
if err != nil { if err != nil {
return err return err
@ -119,9 +119,9 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources
for _, src := range sources { for _, src := range sources {
_, err = tx.Exec( _, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents) `INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5);`, VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents, src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
) )
if err != nil { if err != nil {
return err return err

View file

@ -7,12 +7,13 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateBudgetDTO struct { type CreateBudgetDTO struct {
SpaceID string SpaceID string
TagIDs []string TagIDs []string
Amount int Amount decimal.Decimal
Period model.BudgetPeriod Period model.BudgetPeriod
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -22,7 +23,7 @@ type CreateBudgetDTO struct {
type UpdateBudgetDTO struct { type UpdateBudgetDTO struct {
ID string ID string
TagIDs []string TagIDs []string
Amount int Amount decimal.Decimal
Period model.BudgetPeriod Period model.BudgetPeriod
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -37,7 +38,7 @@ func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService {
} }
func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) { 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") 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() now := time.Now()
budget := &model.Budget{ budget := &model.Budget{
ID: uuid.NewString(), ID: uuid.NewString(),
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
AmountCents: dto.Amount, Amount: dto.Amount,
Period: dto.Period, Period: dto.Period,
StartDate: dto.StartDate, StartDate: dto.StartDate,
EndDate: dto.EndDate, EndDate: dto.EndDate,
IsActive: true, IsActive: true,
CreatedBy: dto.CreatedBy, CreatedBy: dto.CreatedBy,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := s.budgetRepo.Create(budget, dto.TagIDs); err != nil { 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()) start, end := GetCurrentPeriodBounds(b.Period, time.Now())
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end) spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end)
if err != nil { if err != nil {
spent = 0 spent = decimal.Zero
} }
var percentage float64 var percentage float64
if b.AmountCents > 0 { if b.Amount.GreaterThan(decimal.Zero) {
percentage = float64(spent) / float64(b.AmountCents) * 100 percentage, _ = spent.Div(b.Amount).Mul(decimal.NewFromInt(100)).Float64()
} }
var status model.BudgetStatus var status model.BudgetStatus
@ -120,7 +121,7 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWith
bws := &model.BudgetWithSpent{ bws := &model.BudgetWithSpent{
Budget: *b, Budget: *b,
Tags: tags, Tags: tags,
SpentCents: spent, Spent: spent,
Percentage: percentage, Percentage: percentage,
Status: status, Status: status,
} }
@ -131,7 +132,7 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWith
} }
func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) { 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") 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 return nil, err
} }
existing.AmountCents = dto.Amount existing.Amount = dto.Amount
existing.Period = dto.Period existing.Period = dto.Period
existing.StartDate = dto.StartDate existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate existing.EndDate = dto.EndDate

View file

@ -7,13 +7,14 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateExpenseDTO struct { type CreateExpenseDTO struct {
SpaceID string SpaceID string
UserID string UserID string
Description string Description string
Amount int Amount decimal.Decimal
Type model.ExpenseType Type model.ExpenseType
Date time.Time Date time.Time
TagIDs []string TagIDs []string
@ -25,7 +26,7 @@ type UpdateExpenseDTO struct {
ID string ID string
SpaceID string SpaceID string
Description string Description string
Amount int Amount decimal.Decimal
Type model.ExpenseType Type model.ExpenseType
Date time.Time Date time.Time
TagIDs []string TagIDs []string
@ -48,7 +49,7 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er
if dto.Description == "" { if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty") 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") return nil, fmt.Errorf("amount must be positive")
} }
@ -58,7 +59,7 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
CreatedBy: dto.UserID, CreatedBy: dto.UserID,
Description: dto.Description, Description: dto.Description,
AmountCents: dto.Amount, Amount: dto.Amount,
Type: dto.Type, Type: dto.Type,
Date: dto.Date, Date: dto.Date,
PaymentMethodID: dto.PaymentMethodID, PaymentMethodID: dto.PaymentMethodID,
@ -78,18 +79,18 @@ func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense,
return s.expenseRepo.GetBySpaceID(spaceID) 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) expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil { if err != nil {
return 0, err return decimal.Zero, err
} }
var balance int balance := decimal.Zero
for _, expense := range expenses { for _, expense := range expenses {
if expense.Type == model.ExpenseTypeExpense { if expense.Type == model.ExpenseTypeExpense {
balance -= expense.AmountCents balance = balance.Sub(expense.Amount)
} else if expense.Type == model.ExpenseTypeTopup { } 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 == "" { if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty") 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") 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.Description = dto.Description
existing.AmountCents = dto.Amount existing.Amount = dto.Amount
existing.Type = dto.Type existing.Type = dto.Type
existing.Date = dto.Date existing.Date = dto.Date
existing.PaymentMethodID = dto.PaymentMethodID existing.PaymentMethodID = dto.PaymentMethodID

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil" "git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -24,7 +25,7 @@ func TestExpenseService_CreateExpense(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Lunch", Description: "Lunch",
Amount: 1500, Amount: decimal.RequireFromString("15.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
TagIDs: []string{tag.ID}, TagIDs: []string{tag.ID},
@ -32,7 +33,7 @@ func TestExpenseService_CreateExpense(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, expense.ID) assert.NotEmpty(t, expense.ID)
assert.Equal(t, "Lunch", expense.Description) 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) assert.Equal(t, model.ExpenseTypeExpense, expense.Type)
}) })
} }
@ -46,7 +47,7 @@ func TestExpenseService_CreateExpense_EmptyDescription(t *testing.T) {
SpaceID: "some-space", SpaceID: "some-space",
UserID: "some-user", UserID: "some-user",
Description: "", Description: "",
Amount: 1000, Amount: decimal.RequireFromString("10.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
}) })
@ -64,7 +65,7 @@ func TestExpenseService_CreateExpense_ZeroAmount(t *testing.T) {
SpaceID: "some-space", SpaceID: "some-space",
UserID: "some-user", UserID: "some-user",
Description: "Something", Description: "Something",
Amount: 0, Amount: decimal.Zero,
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
}) })
@ -87,7 +88,7 @@ func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Bus fare", Description: "Bus fare",
Amount: 250, Amount: decimal.RequireFromString("2.50"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
TagIDs: []string{tag.ID}, TagIDs: []string{tag.ID},
@ -99,7 +100,7 @@ func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) {
SpaceID: space.ID, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Coffee", Description: "Coffee",
Amount: 500, Amount: decimal.RequireFromString("5.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), 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) user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-balance@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Balance Space") 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, "Topup", decimal.RequireFromString("100.00"), model.ExpenseTypeTopup)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", 3000, model.ExpenseTypeExpense) testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", decimal.RequireFromString("30.00"), model.ExpenseTypeExpense)
balance, err := svc.GetBalanceForSpace(space.ID) balance, err := svc.GetBalanceForSpace(space.ID)
require.NoError(t, err) 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, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Dinner", Description: "Dinner",
Amount: 2500, Amount: decimal.RequireFromString("25.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: now, Date: now,
TagIDs: []string{tag.ID}, TagIDs: []string{tag.ID},
@ -169,7 +170,7 @@ func TestExpenseService_GetExpensesByTag(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, summaries, 1) require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID) 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, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Old Description", Description: "Old Description",
Amount: 1000, Amount: decimal.RequireFromString("10.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
}) })
@ -195,13 +196,13 @@ func TestExpenseService_UpdateExpense(t *testing.T) {
ID: created.ID, ID: created.ID,
SpaceID: space.ID, SpaceID: space.ID,
Description: "New Description", Description: "New Description",
Amount: 2000, Amount: decimal.RequireFromString("20.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "New Description", updated.Description) 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, SpaceID: space.ID,
UserID: user.ID, UserID: user.ID,
Description: "Doomed Expense", Description: "Doomed Expense",
Amount: 500, Amount: decimal.RequireFromString("5.00"),
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: time.Now(), Date: time.Now(),
}) })

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateLoanDTO struct { type CreateLoanDTO struct {
@ -14,7 +15,7 @@ type CreateLoanDTO struct {
UserID string UserID string
Name string Name string
Description string Description string
OriginalAmount int OriginalAmount decimal.Decimal
InterestRateBps int InterestRateBps int
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -24,7 +25,7 @@ type UpdateLoanDTO struct {
ID string ID string
Name string Name string
Description string Description string
OriginalAmount int OriginalAmount decimal.Decimal
InterestRateBps int InterestRateBps int
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -48,24 +49,24 @@ func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
if dto.Name == "" { if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty") 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") return nil, fmt.Errorf("amount must be positive")
} }
now := time.Now() now := time.Now()
loan := &model.Loan{ loan := &model.Loan{
ID: uuid.NewString(), ID: uuid.NewString(),
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
Name: dto.Name, Name: dto.Name,
Description: dto.Description, Description: dto.Description,
OriginalAmountCents: dto.OriginalAmount, OriginalAmount: dto.OriginalAmount,
InterestRateBps: dto.InterestRateBps, InterestRateBps: dto.InterestRateBps,
StartDate: dto.StartDate, StartDate: dto.StartDate,
EndDate: dto.EndDate, EndDate: dto.EndDate,
IsPaidOff: false, IsPaidOff: false,
CreatedBy: dto.UserID, CreatedBy: dto.UserID,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := s.loanRepo.Create(loan); err != nil { if err := s.loanRepo.Create(loan); err != nil {
@ -95,10 +96,10 @@ func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSumma
} }
return &model.LoanWithPaymentSummary{ return &model.LoanWithPaymentSummary{
Loan: *loan, Loan: *loan,
TotalPaidCents: totalPaid, TotalPaid: totalPaid,
RemainingCents: loan.OriginalAmountCents - totalPaid, Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount, ReceiptCount: receiptCount,
}, nil }, nil
} }
@ -154,10 +155,10 @@ func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPay
return nil, err return nil, err
} }
result[i] = &model.LoanWithPaymentSummary{ result[i] = &model.LoanWithPaymentSummary{
Loan: *loan, Loan: *loan,
TotalPaidCents: totalPaid, TotalPaid: totalPaid,
RemainingCents: loan.OriginalAmountCents - totalPaid, Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount, ReceiptCount: receiptCount,
} }
} }
return result, nil return result, nil
@ -167,7 +168,7 @@ func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) {
if dto.Name == "" { if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty") 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") 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.Name = dto.Name
existing.Description = dto.Description existing.Description = dto.Description
existing.OriginalAmountCents = dto.OriginalAmount existing.OriginalAmount = dto.OriginalAmount
existing.InterestRateBps = dto.InterestRateBps existing.InterestRateBps = dto.InterestRateBps
existing.StartDate = dto.StartDate existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate existing.EndDate = dto.EndDate

View file

@ -8,6 +8,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateMoneyAccountDTO struct { type CreateMoneyAccountDTO struct {
@ -23,7 +24,7 @@ type UpdateMoneyAccountDTO struct {
type CreateTransferDTO struct { type CreateTransferDTO struct {
AccountID string AccountID string
Amount int Amount decimal.Decimal
Direction model.TransferDirection Direction model.TransferDirection
Note string Note string
CreatedBy string CreatedBy string
@ -77,7 +78,7 @@ func (s *MoneyAccountService) GetAccountsForSpace(spaceID string) ([]model.Money
} }
result[i] = model.MoneyAccountWithBalance{ result[i] = model.MoneyAccountWithBalance{
MoneyAccount: *acct, MoneyAccount: *acct,
BalanceCents: balance, Balance: balance,
} }
} }
@ -113,8 +114,8 @@ func (s *MoneyAccountService) DeleteAccount(id string) error {
return s.accountRepo.Delete(id) return s.accountRepo.Delete(id)
} }
func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance int) (*model.AccountTransfer, error) { func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance decimal.Decimal) (*model.AccountTransfer, error) {
if dto.Amount <= 0 { if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive") 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.Direction == model.TransferDirectionDeposit {
if dto.Amount > availableSpaceBalance { if dto.Amount.GreaterThan(availableSpaceBalance) {
return nil, fmt.Errorf("insufficient available balance") return nil, fmt.Errorf("insufficient available balance")
} }
} }
@ -133,19 +134,19 @@ func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpa
if err != nil { if err != nil {
return nil, err return nil, err
} }
if dto.Amount > accountBalance { if dto.Amount.GreaterThan(accountBalance) {
return nil, fmt.Errorf("insufficient account balance") return nil, fmt.Errorf("insufficient account balance")
} }
} }
transfer := &model.AccountTransfer{ transfer := &model.AccountTransfer{
ID: uuid.NewString(), ID: uuid.NewString(),
AccountID: dto.AccountID, AccountID: dto.AccountID,
AmountCents: dto.Amount, Amount: dto.Amount,
Direction: dto.Direction, Direction: dto.Direction,
Note: strings.TrimSpace(dto.Note), Note: strings.TrimSpace(dto.Note),
CreatedBy: dto.CreatedBy, CreatedBy: dto.CreatedBy,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
err := s.accountRepo.CreateTransfer(transfer) err := s.accountRepo.CreateTransfer(transfer)
@ -164,11 +165,11 @@ func (s *MoneyAccountService) DeleteTransfer(id string) error {
return s.accountRepo.DeleteTransfer(id) 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) 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) return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
} }

View file

@ -6,6 +6,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil" "git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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) user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID) 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) accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, accounts, 1) require.Len(t, accounts, 1)
assert.Equal(t, "Checking", accounts[0].Name) 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{ transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID, AccountID: account.ID,
Amount: 3000, Amount: decimal.RequireFromString("30.00"),
Direction: model.TransferDirectionDeposit, Direction: model.TransferDirectionDeposit,
Note: "Initial deposit", Note: "Initial deposit",
CreatedBy: user.ID, CreatedBy: user.ID,
}, 10000) }, decimal.RequireFromString("100.00"))
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, transfer.ID) 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) assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction)
}) })
} }
@ -97,11 +98,11 @@ func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) {
transfer, err := svc.CreateTransfer(CreateTransferDTO{ transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID, AccountID: account.ID,
Amount: 5000, Amount: decimal.RequireFromString("50.00"),
Direction: model.TransferDirectionDeposit, Direction: model.TransferDirectionDeposit,
Note: "Too much", Note: "Too much",
CreatedBy: user.ID, CreatedBy: user.ID,
}, 1000) }, decimal.RequireFromString("10.00"))
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, transfer) 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) user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID) 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{ transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID, AccountID: account.ID,
Amount: 2000, Amount: decimal.RequireFromString("20.00"),
Direction: model.TransferDirectionWithdrawal, Direction: model.TransferDirectionWithdrawal,
Note: "Withdrawal", Note: "Withdrawal",
CreatedBy: user.ID, CreatedBy: user.ID,
}, 0) }, decimal.Zero)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, transfer.ID) 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) 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") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID) 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) 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) total, err := svc.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err) 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) user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID) 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) err := svc.DeleteTransfer(transfer.ID)
require.NoError(t, err) require.NoError(t, err)

View file

@ -7,12 +7,13 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type FundingSourceDTO struct { type FundingSourceDTO struct {
SourceType model.FundingSourceType SourceType model.FundingSourceType
AccountID string AccountID string
Amount int Amount decimal.Decimal
} }
type CreateReceiptDTO struct { type CreateReceiptDTO struct {
@ -20,7 +21,7 @@ type CreateReceiptDTO struct {
SpaceID string SpaceID string
UserID string UserID string
Description string Description string
TotalAmount int TotalAmount decimal.Decimal
Date time.Time Date time.Time
FundingSources []FundingSourceDTO FundingSources []FundingSourceDTO
RecurringReceiptID *string RecurringReceiptID *string
@ -31,7 +32,7 @@ type UpdateReceiptDTO struct {
SpaceID string SpaceID string
UserID string UserID string
Description string Description string
TotalAmount int TotalAmount decimal.Decimal
Date time.Time Date time.Time
FundingSources []FundingSourceDTO FundingSources []FundingSourceDTO
} }
@ -57,7 +58,7 @@ func NewReceiptService(
} }
func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) { 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") return nil, fmt.Errorf("amount must be positive")
} }
if len(dto.FundingSources) == 0 { if len(dto.FundingSources) == 0 {
@ -65,15 +66,15 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith
} }
// Validate funding sources sum to total // Validate funding sources sum to total
var sum int sum := decimal.Zero
for _, src := range dto.FundingSources { 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") return nil, fmt.Errorf("each funding source amount must be positive")
} }
sum += src.Amount sum = sum.Add(src.Amount)
} }
if sum != dto.TotalAmount { if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, 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 // Validate loan exists and is not paid off
@ -91,7 +92,7 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith
LoanID: dto.LoanID, LoanID: dto.LoanID,
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
Description: dto.Description, Description: dto.Description,
TotalAmountCents: dto.TotalAmount, TotalAmount: dto.TotalAmount,
Date: dto.Date, Date: dto.Date,
RecurringReceiptID: dto.RecurringReceiptID, RecurringReceiptID: dto.RecurringReceiptID,
CreatedBy: dto.UserID, CreatedBy: dto.UserID,
@ -107,7 +108,7 @@ func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWith
// Check if loan is now fully paid off // Check if loan is now fully paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID) 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) _ = s.loanRepo.SetPaidOff(loan.ID, true)
} }
@ -127,10 +128,10 @@ func (s *ReceiptService) buildLinkedRecords(
for _, src := range fundingSources { for _, src := range fundingSources {
fs := model.ReceiptFundingSource{ fs := model.ReceiptFundingSource{
ID: uuid.NewString(), ID: uuid.NewString(),
ReceiptID: receipt.ID, ReceiptID: receipt.ID,
SourceType: src.SourceType, SourceType: src.SourceType,
AmountCents: src.Amount, Amount: src.Amount,
} }
if src.SourceType == model.FundingSourceBalance { if src.SourceType == model.FundingSourceBalance {
@ -139,7 +140,7 @@ func (s *ReceiptService) buildLinkedRecords(
SpaceID: spaceID, SpaceID: spaceID,
CreatedBy: userID, CreatedBy: userID,
Description: fmt.Sprintf("Loan payment: %s", description), Description: fmt.Sprintf("Loan payment: %s", description),
AmountCents: src.Amount, Amount: src.Amount,
Type: model.ExpenseTypeExpense, Type: model.ExpenseTypeExpense,
Date: date, Date: date,
CreatedAt: now, CreatedAt: now,
@ -151,13 +152,13 @@ func (s *ReceiptService) buildLinkedRecords(
acctID := src.AccountID acctID := src.AccountID
fs.AccountID = &acctID fs.AccountID = &acctID
transfer := &model.AccountTransfer{ transfer := &model.AccountTransfer{
ID: uuid.NewString(), ID: uuid.NewString(),
AccountID: src.AccountID, AccountID: src.AccountID,
AmountCents: src.Amount, Amount: src.Amount,
Direction: model.TransferDirectionWithdrawal, Direction: model.TransferDirectionWithdrawal,
Note: fmt.Sprintf("Loan payment: %s", description), Note: fmt.Sprintf("Loan payment: %s", description),
CreatedBy: userID, CreatedBy: userID,
CreatedAt: now, CreatedAt: now,
} }
accountTransfers = append(accountTransfers, transfer) accountTransfers = append(accountTransfers, transfer)
fs.LinkedTransferID = &transfer.ID fs.LinkedTransferID = &transfer.ID
@ -255,7 +256,7 @@ func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error {
if err != nil { if err != nil {
return nil return nil
} }
if loan.IsPaidOff && totalPaid < loan.OriginalAmountCents { if loan.IsPaidOff && totalPaid.LessThan(loan.OriginalAmount) {
_ = s.loanRepo.SetPaidOff(loan.ID, false) _ = 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) { 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") return nil, fmt.Errorf("amount must be positive")
} }
if len(dto.FundingSources) == 0 { if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required") return nil, fmt.Errorf("at least one funding source is required")
} }
var sum int sum := decimal.Zero
for _, src := range dto.FundingSources { 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") return nil, fmt.Errorf("each funding source amount must be positive")
} }
sum += src.Amount sum = sum.Add(src.Amount)
} }
if sum != dto.TotalAmount { if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, 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) existing, err := s.receiptRepo.GetByID(dto.ID)
@ -290,7 +291,7 @@ func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWith
} }
existing.Description = dto.Description existing.Description = dto.Description
existing.TotalAmountCents = dto.TotalAmount existing.TotalAmount = dto.TotalAmount
existing.Date = dto.Date existing.Date = dto.Date
existing.UpdatedAt = time.Now() existing.UpdatedAt = time.Now()
@ -305,9 +306,9 @@ func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWith
if err == nil { if err == nil {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID) totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID)
if err == nil { if err == nil {
if totalPaid >= loan.OriginalAmountCents && !loan.IsPaidOff { if totalPaid.GreaterThanOrEqual(loan.OriginalAmount) && !loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, true) _ = 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) _ = s.loanRepo.SetPaidOff(loan.ID, false)
} }
} }

View file

@ -8,13 +8,14 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateRecurringExpenseDTO struct { type CreateRecurringExpenseDTO struct {
SpaceID string SpaceID string
UserID string UserID string
Description string Description string
Amount int Amount decimal.Decimal
Type model.ExpenseType Type model.ExpenseType
PaymentMethodID *string PaymentMethodID *string
Frequency model.Frequency Frequency model.Frequency
@ -26,7 +27,7 @@ type CreateRecurringExpenseDTO struct {
type UpdateRecurringExpenseDTO struct { type UpdateRecurringExpenseDTO struct {
ID string ID string
Description string Description string
Amount int Amount decimal.Decimal
Type model.ExpenseType Type model.ExpenseType
PaymentMethodID *string PaymentMethodID *string
Frequency model.Frequency Frequency model.Frequency
@ -55,7 +56,7 @@ func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpe
if dto.Description == "" { if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty") 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") return nil, fmt.Errorf("amount must be positive")
} }
@ -65,7 +66,7 @@ func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpe
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
CreatedBy: dto.UserID, CreatedBy: dto.UserID,
Description: dto.Description, Description: dto.Description,
AmountCents: dto.Amount, Amount: dto.Amount,
Type: dto.Type, Type: dto.Type,
PaymentMethodID: dto.PaymentMethodID, PaymentMethodID: dto.PaymentMethodID,
Frequency: dto.Frequency, Frequency: dto.Frequency,
@ -127,7 +128,7 @@ func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpe
if dto.Description == "" { if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty") 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") return nil, fmt.Errorf("amount must be positive")
} }
@ -137,7 +138,7 @@ func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpe
} }
existing.Description = dto.Description existing.Description = dto.Description
existing.AmountCents = dto.Amount existing.Amount = dto.Amount
existing.Type = dto.Type existing.Type = dto.Type
existing.PaymentMethodID = dto.PaymentMethodID existing.PaymentMethodID = dto.PaymentMethodID
existing.Frequency = dto.Frequency existing.Frequency = dto.Frequency
@ -229,7 +230,7 @@ func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense,
SpaceID: re.SpaceID, SpaceID: re.SpaceID,
CreatedBy: re.CreatedBy, CreatedBy: re.CreatedBy,
Description: re.Description, Description: re.Description,
AmountCents: re.AmountCents, Amount: re.Amount,
Type: re.Type, Type: re.Type,
Date: re.NextOccurrence, Date: re.NextOccurrence,
PaymentMethodID: re.PaymentMethodID, PaymentMethodID: re.PaymentMethodID,

View file

@ -8,6 +8,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
type CreateRecurringReceiptDTO struct { type CreateRecurringReceiptDTO struct {
@ -15,7 +16,7 @@ type CreateRecurringReceiptDTO struct {
SpaceID string SpaceID string
UserID string UserID string
Description string Description string
TotalAmount int TotalAmount decimal.Decimal
Frequency model.Frequency Frequency model.Frequency
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -25,7 +26,7 @@ type CreateRecurringReceiptDTO struct {
type UpdateRecurringReceiptDTO struct { type UpdateRecurringReceiptDTO struct {
ID string ID string
Description string Description string
TotalAmount int TotalAmount decimal.Decimal
Frequency model.Frequency Frequency model.Frequency
StartDate time.Time StartDate time.Time
EndDate *time.Time EndDate *time.Time
@ -57,36 +58,36 @@ func NewRecurringReceiptService(
} }
func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) { 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") return nil, fmt.Errorf("amount must be positive")
} }
if len(dto.FundingSources) == 0 { if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required") return nil, fmt.Errorf("at least one funding source is required")
} }
var sum int sum := decimal.Zero
for _, src := range dto.FundingSources { 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") return nil, fmt.Errorf("funding source amounts must equal total amount")
} }
now := time.Now() now := time.Now()
rr := &model.RecurringReceipt{ rr := &model.RecurringReceipt{
ID: uuid.NewString(), ID: uuid.NewString(),
LoanID: dto.LoanID, LoanID: dto.LoanID,
SpaceID: dto.SpaceID, SpaceID: dto.SpaceID,
Description: dto.Description, Description: dto.Description,
TotalAmountCents: dto.TotalAmount, TotalAmount: dto.TotalAmount,
Frequency: dto.Frequency, Frequency: dto.Frequency,
StartDate: dto.StartDate, StartDate: dto.StartDate,
EndDate: dto.EndDate, EndDate: dto.EndDate,
NextOccurrence: dto.StartDate, NextOccurrence: dto.StartDate,
IsActive: true, IsActive: true,
CreatedBy: dto.UserID, CreatedBy: dto.UserID,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources)) sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
@ -95,7 +96,7 @@ func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringRece
ID: uuid.NewString(), ID: uuid.NewString(),
RecurringReceiptID: rr.ID, RecurringReceiptID: rr.ID,
SourceType: src.SourceType, SourceType: src.SourceType,
AmountCents: src.Amount, Amount: src.Amount,
} }
if src.SourceType == model.FundingSourceAccount { if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID acctID := src.AccountID
@ -142,7 +143,7 @@ func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID
} }
func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) { 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") return nil, fmt.Errorf("amount must be positive")
} }
@ -152,7 +153,7 @@ func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringRece
} }
existing.Description = dto.Description existing.Description = dto.Description
existing.TotalAmountCents = dto.TotalAmount existing.TotalAmount = dto.TotalAmount
existing.Frequency = dto.Frequency existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate existing.EndDate = dto.EndDate
@ -168,7 +169,7 @@ func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringRece
ID: uuid.NewString(), ID: uuid.NewString(),
RecurringReceiptID: existing.ID, RecurringReceiptID: existing.ID,
SourceType: src.SourceType, SourceType: src.SourceType,
AmountCents: src.Amount, Amount: src.Amount,
} }
if src.SourceType == model.FundingSourceAccount { if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID acctID := src.AccountID
@ -262,7 +263,7 @@ func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt,
fundingSources[i] = FundingSourceDTO{ fundingSources[i] = FundingSourceDTO{
SourceType: src.SourceType, SourceType: src.SourceType,
AccountID: accountID, AccountID: accountID,
Amount: src.AmountCents, Amount: src.Amount,
} }
} }
@ -272,7 +273,7 @@ func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt,
SpaceID: rr.SpaceID, SpaceID: rr.SpaceID,
UserID: rr.CreatedBy, UserID: rr.CreatedBy,
Description: rr.Description, Description: rr.Description,
TotalAmount: rr.TotalAmountCents, TotalAmount: rr.TotalAmount,
Date: rr.NextOccurrence, Date: rr.NextOccurrence,
FundingSources: fundingSources, FundingSources: fundingSources,
RecurringReceiptID: &rrID, RecurringReceiptID: &rrID,

View file

@ -73,7 +73,7 @@ func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (*
TopExpenses: topWithTags, TopExpenses: topWithTags,
TotalIncome: totalIncome, TotalIncome: totalIncome,
TotalExpenses: totalExpenses, TotalExpenses: totalExpenses,
NetBalance: totalIncome - totalExpenses, NetBalance: totalIncome.Sub(totalExpenses),
}, nil }, nil
} }

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
) )
// CreateTestUser inserts a user directly into the database. // 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. // 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() t.Helper()
now := time.Now() now := time.Now()
expense := &model.Expense{ expense := &model.Expense{
@ -160,15 +161,15 @@ func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string,
SpaceID: spaceID, SpaceID: spaceID,
CreatedBy: userID, CreatedBy: userID,
Description: desc, Description: desc,
AmountCents: amount, Amount: amount,
Type: typ, Type: typ,
Date: now, Date: now,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
_, err := db.Exec( _, 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)`, `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.AmountCents, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount,
expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt, expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt,
) )
if err != nil { 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. // 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() t.Helper()
transfer := &model.AccountTransfer{ transfer := &model.AccountTransfer{
ID: uuid.NewString(), ID: uuid.NewString(),
AccountID: accountID, AccountID: accountID,
AmountCents: amount, Amount: amount,
Direction: direction, Direction: direction,
Note: "test transfer", Note: "test transfer",
CreatedBy: createdBy, CreatedBy: createdBy,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
_, err := db.Exec( _, 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)`, `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.AmountCents, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt,
) )
if err != nil { if err != nil {
t.Fatalf("CreateTestTransfer: %v", err) t.Fatalf("CreateTestTransfer: %v", err)

View file

@ -14,6 +14,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod" "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/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox" "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
"github.com/shopspring/decimal"
) )
type AddExpenseFormProps struct { type AddExpenseFormProps struct {
@ -215,7 +216,7 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
Name: "amount", Name: "amount",
ID: "edit-amount-" + exp.ID, ID: "edit-amount-" + exp.ID,
Type: "number", Type: "number",
Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0), Value: model.FormatDecimal(exp.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"}, Attributes: templ.Attributes{"step": "0.01", "required": "true"},
}) })
</div> </div>
@ -345,7 +346,7 @@ templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob boo
</div> </div>
} }
templ BalanceCard(spaceID string, balance int, allocated int, oob bool) { templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) {
<div <div
id="balance-card" id="balance-card"
class="border rounded-lg p-4 bg-card text-card-foreground" class="border rounded-lg p-4 bg-card text-card-foreground"
@ -354,11 +355,11 @@ templ BalanceCard(spaceID string, balance int, allocated int, oob bool) {
} }
> >
<h2 class="text-lg font-semibold">Current Balance</h2> <h2 class="text-lg font-semibold">Current Balance</h2>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }> <p class={ "text-3xl font-bold", templ.KV("text-destructive", balance.LessThan(decimal.Zero)) }>
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) } { model.FormatMoney(balance) }
if allocated > 0 { if allocated.GreaterThan(decimal.Zero) {
<span class="text-base font-normal text-muted-foreground"> <span class="text-base font-normal text-muted-foreground">
({ fmt.Sprintf("$%.2f", float64(allocated)/100.0) } in accounts) ({ model.FormatMoney(allocated) } in accounts)
</span> </span>
} }
</p> </p>

View file

@ -11,9 +11,10 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input" "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination" "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) {
<div <div
id="accounts-balance-summary" id="accounts-balance-summary"
class="border rounded-lg p-4 bg-card text-card-foreground" class="border rounded-lg p-4 bg-card text-card-foreground"
@ -25,20 +26,20 @@ templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int,
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div> <div>
<p class="text-sm text-muted-foreground">Total Balance</p> <p class="text-sm text-muted-foreground">Total Balance</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance < 0) }> <p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
{ fmt.Sprintf("$%.2f", float64(totalBalance)/100.0) } { model.FormatMoney(totalBalance) }
</p> </p>
</div> </div>
<div> <div>
<p class="text-sm text-muted-foreground">Allocated</p> <p class="text-sm text-muted-foreground">Allocated</p>
<p class="text-xl font-bold"> <p class="text-xl font-bold">
{ fmt.Sprintf("$%.2f", float64(totalBalance-availableBalance)/100.0) } { model.FormatMoney(totalBalance.Sub(availableBalance)) }
</p> </p>
</div> </div>
<div> <div>
<p class="text-sm text-muted-foreground">Available</p> <p class="text-sm text-muted-foreground">Available</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance < 0) }> <p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
{ fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) } { model.FormatMoney(availableBalance) }
</p> </p>
</div> </div>
</div> </div>
@ -60,8 +61,8 @@ templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bo
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div> <div>
<h3 class="font-semibold text-lg">{ acct.Name }</h3> <h3 class="font-semibold text-lg">{ acct.Name }</h3>
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.BalanceCents < 0) }> <p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
{ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) } { model.FormatMoney(acct.Balance) }
</p> </p>
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
@ -359,11 +360,11 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
if t.Direction == model.TransferDirectionDeposit { if t.Direction == model.TransferDirectionDeposit {
<span class="font-bold text-green-600 whitespace-nowrap"> <span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) } +{ model.FormatMoney(t.Amount) }
</span> </span>
} else { } else {
<span class="font-bold text-destructive whitespace-nowrap"> <span class="font-bold text-destructive whitespace-nowrap">
-{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) } -{ model.FormatMoney(t.Amount) }
</span> </span>
} }
@button.Button(button.Props{ @button.Button(button.Props{
@ -382,4 +383,3 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
</div> </div>
</div> </div>
} }

View file

@ -73,11 +73,11 @@ templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod,
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
if re.Type == model.ExpenseTypeExpense { if re.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive"> <p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) } - { model.FormatMoney(re.Amount) }
</p> </p>
} else { } else {
<p class="font-bold text-green-500"> <p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) } + { model.FormatMoney(re.Amount) }
</p> </p>
} }
// Toggle pause/resume // Toggle pause/resume
@ -352,7 +352,7 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet
Name: "amount", Name: "amount",
ID: "edit-recurring-amount-" + re.ID, ID: "edit-recurring-amount-" + re.ID,
Type: "number", Type: "number",
Value: fmt.Sprintf("%.2f", float64(re.AmountCents)/100.0), Value: model.FormatDecimal(re.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"}, Attributes: templ.Attributes{"step": "0.01", "required": "true"},
}) })
</div> </div>

View file

@ -6,9 +6,10 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount" "git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
) )
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) { templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance decimal.Decimal, availableBalance decimal.Decimal, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
@layouts.Space("Accounts", space) { @layouts.Space("Accounts", space) {
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">

View file

@ -159,14 +159,14 @@ templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
// Progress bar // Progress bar
<div class="space-y-1"> <div class="space-y-1">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span> <span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</span> <span>of { model.FormatMoney(b.Amount) }</span>
</div> </div>
<div class="w-full bg-muted rounded-full h-2.5"> <div class="w-full bg-muted rounded-full h-2.5">
<div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div> <div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div> </div>
if b.Status == model.BudgetStatusOver { if b.Status == model.BudgetStatusOver {
<p class="text-xs text-destructive font-medium">Over budget by { fmt.Sprintf("$%.2f", float64(b.SpentCents-b.AmountCents)/100.0) }</p> <p class="text-xs text-destructive font-medium">Over budget by { model.FormatMoney(b.Spent.Sub(b.Amount)) }</p>
} }
</div> </div>
</div> </div>
@ -311,7 +311,7 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
Name: "amount", Name: "amount",
ID: "edit-budget-amount-" + b.ID, ID: "edit-budget-amount-" + b.ID,
Type: "number", Type: "number",
Value: fmt.Sprintf("%.2f", float64(b.AmountCents)/100.0), Value: model.FormatDecimal(b.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"}, Attributes: templ.Attributes{"step": "0.01", "required": "true"},
}) })
</div> </div>

View file

@ -3,6 +3,7 @@ package pages
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -14,7 +15,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs" "git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
) )
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) { templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) {
@layouts.Space("Expenses", space) { @layouts.Space("Expenses", space) {
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -121,11 +122,11 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
if exp.Type == model.ExpenseTypeExpense { if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive"> <p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } - { model.FormatMoney(exp.Amount) }
</p> </p>
} else { } else {
<p class="font-bold text-green-500"> <p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } + { model.FormatMoney(exp.Amount) }
</p> </p>
} }
// Edit button // Edit button
@ -186,12 +187,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
</div> </div>
} }
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, tags []*model.Tag, currentPage, totalPages int) { templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages) @ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
@expense.BalanceCard(spaceID, balance, allocated, true) @expense.BalanceCard(spaceID, balance, allocated, true)
} }
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod, tags []*model.Tag) { templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, methods []*model.PaymentMethod, tags []*model.Tag) {
@ExpenseListItem(spaceID, exp, methods, tags) @ExpenseListItem(spaceID, exp, methods, tags)
@expense.BalanceCard(exp.SpaceID, balance, allocated, true) @expense.BalanceCard(exp.SpaceID, balance, allocated, true)
} }

View file

@ -3,6 +3,7 @@ package pages
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -17,7 +18,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
) )
templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance int) { templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
@layouts.Space(loan.Name, space) { @layouts.Space(loan.Name, space) {
<div class="space-y-6"> <div class="space-y-6">
// Loan Summary Card // Loan Summary Card
@ -94,8 +95,8 @@ templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) { templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }} {{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 { if !loan.OriginalAmount.IsZero() {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }} {{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
if progressPct > 100 { if progressPct > 100 {
{{ progressPct = 100 }} {{ progressPct = 100 }}
} }
@ -154,17 +155,17 @@ templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
<div class="grid grid-cols-3 gap-4 text-center"> <div class="grid grid-cols-3 gap-4 text-center">
<div> <div>
<p class="text-sm text-muted-foreground">Original</p> <p class="text-sm text-muted-foreground">Original</p>
<p class="text-lg font-semibold">{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }</p> <p class="text-lg font-semibold">{ model.FormatMoney(loan.OriginalAmount) }</p>
</div> </div>
<div> <div>
<p class="text-sm text-muted-foreground">Paid</p> <p class="text-sm text-muted-foreground">Paid</p>
<p class="text-lg font-semibold text-green-600">{ fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</p> <p class="text-lg font-semibold text-green-600">{ model.FormatMoney(loan.TotalPaid) }</p>
</div> </div>
<div> <div>
<p class="text-sm text-muted-foreground">Remaining</p> <p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold"> <p class="text-lg font-semibold">
if loan.RemainingCents > 0 { if loan.Remaining.GreaterThan(decimal.Zero) {
{ fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) } { model.FormatMoney(loan.Remaining) }
} else { } else {
$0.00 $0.00
} }
@ -244,7 +245,7 @@ templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesA
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start"> <div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(receipt.TotalAmountCents)/100.0) }</span> <span class="font-medium">{ model.FormatMoney(receipt.TotalAmount) }</span>
<span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span> <span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span>
if receipt.RecurringReceiptID != nil { if receipt.RecurringReceiptID != nil {
@icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"}) @icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"})
@ -257,11 +258,11 @@ templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesA
for _, src := range receipt.Sources { for _, src := range receipt.Sources {
if src.SourceType == "balance" { if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) { @badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) } { fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
} }
} else { } else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) { @badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ fmt.Sprintf("%s $%.2f", src.AccountName, float64(src.AmountCents)/100.0) } { fmt.Sprintf("%s %s", src.AccountName, model.FormatMoney(src.Amount)) }
} }
} }
} }
@ -304,7 +305,7 @@ templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWit
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@icon.Repeat(icon.Props{Class: "size-4"}) @icon.Repeat(icon.Props{Class: "size-4"})
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(rr.TotalAmountCents)/100.0) }</span> <span class="font-medium">{ model.FormatMoney(rr.TotalAmount) }</span>
<span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span> <span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span>
if !rr.IsActive { if !rr.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
@ -322,12 +323,12 @@ templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWit
for _, src := range rr.Sources { for _, src := range rr.Sources {
if src.SourceType == "balance" { if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) { @badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) } { fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
} }
} else { } else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) { @badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
if src.AccountID != nil { if src.AccountID != nil {
{ fmt.Sprintf("Account $%.2f", float64(src.AmountCents)/100.0) } { fmt.Sprintf("Account %s", model.FormatMoney(src.Amount)) }
} }
} }
} }
@ -379,7 +380,7 @@ templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWit
</div> </div>
} }
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) { templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form <form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) } hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
hx-swap="none" hx-swap="none"
@ -423,7 +424,7 @@ templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWit
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label> <label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Available balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) } Available balance: { model.FormatMoney(availableBalance) }
</p> </p>
<div id="funding-sources" class="space-y-2"> <div id="funding-sources" class="space-y-2">
<div class="flex gap-2 items-center source-row"> <div class="flex gap-2 items-center source-row">
@ -431,7 +432,7 @@ templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWit
<option value="balance">General Balance</option> <option value="balance">General Balance</option>
for _, acct := range accounts { for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }> <option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) }) { acct.Name } ({ model.FormatMoney(acct.Balance) })
</option> </option>
} }
</select> </select>
@ -483,7 +484,7 @@ templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWit
</script> </script>
} }
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) { templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form <form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) } hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
hx-swap="none" hx-swap="none"
@ -547,7 +548,7 @@ templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyA
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label> <label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Current balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) } Current balance: { model.FormatMoney(availableBalance) }
</p> </p>
<div id="recurring-funding-sources" class="space-y-2"> <div id="recurring-funding-sources" class="space-y-2">
<div class="flex gap-2 items-center recurring-source-row"> <div class="flex gap-2 items-center recurring-source-row">
@ -555,7 +556,7 @@ templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyA
<option value="balance">General Balance</option> <option value="balance">General Balance</option>
for _, acct := range accounts { for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }> <option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) }) { acct.Name } ({ model.FormatMoney(acct.Balance) })
</option> </option>
} }
</select> </select>

View file

@ -3,6 +3,7 @@ package pages
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
@ -185,8 +186,8 @@ templ LoansListContent(spaceID string, loans []*model.LoanWithPaymentSummary, cu
templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) { templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }} {{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 { if !loan.OriginalAmount.IsZero() {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }} {{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
if progressPct > 100 { if progressPct > 100 {
{{ progressPct = 100 }} {{ progressPct = 100 }}
} }
@ -205,7 +206,7 @@ templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
} }
</div> </div>
@card.Description() { @card.Description() {
{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) } { model.FormatMoney(loan.OriginalAmount) }
if loan.InterestRateBps > 0 { if loan.InterestRateBps > 0 {
{ fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) } { fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) }
} }
@ -218,9 +219,9 @@ templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
Class: "h-2", Class: "h-2",
}) })
<div class="flex justify-between text-sm text-muted-foreground mt-2"> <div class="flex justify-between text-sm text-muted-foreground mt-2">
<span>Paid: { fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</span> <span>Paid: { model.FormatMoney(loan.TotalPaid) }</span>
if loan.RemainingCents > 0 { if loan.Remaining.GreaterThan(decimal.Zero) {
<span>Left: { fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }</span> <span>Left: { model.FormatMoney(loan.Remaining) }</span>
} else { } else {
<span class="text-green-600">Fully paid</span> <span class="text-green-600">Fully paid</span>
} }

View file

@ -3,6 +3,7 @@ package pages
import ( import (
"fmt" "fmt"
"sort" "sort"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -14,8 +15,8 @@ import (
type OverviewData struct { type OverviewData struct {
Space *model.Space Space *model.Space
Balance int Balance decimal.Decimal
Allocated int Allocated decimal.Decimal
Report *model.SpendingReport Report *model.SpendingReport
Budgets []*model.BudgetWithSpent Budgets []*model.BudgetWithSpent
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
@ -143,12 +144,12 @@ templ overviewBalanceCard(data OverviewData) {
<h3 class="font-semibold mb-3">Current Balance</h3> <h3 class="font-semibold mb-3">Current Balance</h3>
@dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods) @dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods)
</div> </div>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance < 0) }> <p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance.IsNegative()) }>
{ fmt.Sprintf("$%.2f", float64(data.Balance)/100.0) } { model.FormatMoney(data.Balance) }
</p> </p>
if data.Allocated > 0 { if data.Allocated.GreaterThan(decimal.Zero) {
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
{ fmt.Sprintf("$%.2f", float64(data.Allocated)/100.0) } in accounts { model.FormatMoney(data.Allocated) } in accounts
</p> </p>
} }
</div> </div>
@ -161,17 +162,17 @@ templ overviewSpendingCard(data OverviewData) {
<div class="space-y-1"> <div class="space-y-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-green-500 font-medium">Income</span> <span class="text-sm text-green-500 font-medium">Income</span>
<span class="text-sm font-bold text-green-500">{ fmt.Sprintf("$%.2f", float64(data.Report.TotalIncome)/100.0) }</span> <span class="text-sm font-bold text-green-500">{ model.FormatMoney(data.Report.TotalIncome) }</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-destructive font-medium">Expenses</span> <span class="text-sm text-destructive font-medium">Expenses</span>
<span class="text-sm font-bold text-destructive">{ fmt.Sprintf("$%.2f", float64(data.Report.TotalExpenses)/100.0) }</span> <span class="text-sm font-bold text-destructive">{ model.FormatMoney(data.Report.TotalExpenses) }</span>
</div> </div>
<hr class="border-border"/> <hr class="border-border"/>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm font-medium">Net</span> <span class="text-sm font-medium">Net</span>
<span class={ "text-sm font-bold", templ.KV("text-green-500", data.Report.NetBalance >= 0), templ.KV("text-destructive", data.Report.NetBalance < 0) }> <span class={ "text-sm font-bold", templ.KV("text-green-500", !data.Report.NetBalance.IsNegative()), templ.KV("text-destructive", data.Report.NetBalance.IsNegative()) }>
{ fmt.Sprintf("$%.2f", float64(data.Report.NetBalance)/100.0) } { model.FormatMoney(data.Report.NetBalance) }
</span> </span>
</div> </div>
</div> </div>
@ -191,7 +192,7 @@ templ overviewSpendingByCategoryChart(data OverviewData) {
tagColors := make([]string, len(data.Report.ByTag)) tagColors := make([]string, len(data.Report.ByTag))
for i, t := range data.Report.ByTag { for i, t := range data.Report.ByTag {
tagLabels[i] = t.TagName tagLabels[i] = t.TagName
tagData[i] = float64(t.TotalAmount) / 100.0 tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = overviewChartColor(i, t.TagColor) tagColors[i] = overviewChartColor(i, t.TagColor)
} }
}} }}
@ -224,7 +225,7 @@ templ overviewSpendingOverTimeChart(data OverviewData) {
var timeData []float64 var timeData []float64
for _, d := range data.Report.DailySpending { for _, d := range data.Report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02")) timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, float64(d.TotalCents)/100.0) timeData = append(timeData, d.Total.InexactFloat64())
} }
}} }}
@chart.Chart(chart.Props{ @chart.Chart(chart.Props{
@ -276,8 +277,8 @@ templ overviewBudgetsCard(data OverviewData) {
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span> <span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
</div> </div>
<div class="flex justify-between text-xs text-muted-foreground"> <div class="flex justify-between text-xs text-muted-foreground">
<span>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span> <span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</span> <span>of { model.FormatMoney(b.Amount) }</span>
</div> </div>
<div class="w-full bg-muted rounded-full h-2"> <div class="w-full bg-muted rounded-full h-2">
<div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div> <div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
@ -309,11 +310,11 @@ templ overviewRecurringCard(data OverviewData) {
</div> </div>
if r.Type == model.ExpenseTypeExpense { if r.Type == model.ExpenseTypeExpense {
<p class="text-sm font-bold text-destructive shrink-0"> <p class="text-sm font-bold text-destructive shrink-0">
{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) } { model.FormatMoney(r.Amount) }
</p> </p>
} else { } else {
<p class="text-sm font-bold text-green-500 shrink-0"> <p class="text-sm font-bold text-green-500 shrink-0">
+{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) } +{ model.FormatMoney(r.Amount) }
</p> </p>
} }
</div> </div>
@ -348,7 +349,7 @@ templ overviewTopExpensesCard(data OverviewData) {
} }
</div> </div>
<p class="text-sm font-bold text-destructive shrink-0"> <p class="text-sm font-bold text-destructive shrink-0">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } { model.FormatMoney(exp.Amount) }
</p> </p>
</div> </div>
} }

View file

@ -9,6 +9,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart" "git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
) )
var defaultChartColors = []string{ var defaultChartColors = []string{
@ -72,17 +73,17 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
<div class="space-y-1"> <div class="space-y-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-green-500 font-medium">Income</span> <span class="text-green-500 font-medium">Income</span>
<span class="font-bold text-green-500">{ fmt.Sprintf("$%.2f", float64(report.TotalIncome)/100.0) }</span> <span class="font-bold text-green-500">{ model.FormatMoney(report.TotalIncome) }</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-destructive font-medium">Expenses</span> <span class="text-destructive font-medium">Expenses</span>
<span class="font-bold text-destructive">{ fmt.Sprintf("$%.2f", float64(report.TotalExpenses)/100.0) }</span> <span class="font-bold text-destructive">{ model.FormatMoney(report.TotalExpenses) }</span>
</div> </div>
<hr class="border-border"/> <hr class="border-border"/>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="font-medium">Net</span> <span class="font-medium">Net</span>
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance >= 0), templ.KV("text-destructive", report.NetBalance < 0) }> <span class={ "font-bold", templ.KV("text-green-500", report.NetBalance.GreaterThanOrEqual(decimal.Zero)), templ.KV("text-destructive", report.NetBalance.LessThan(decimal.Zero)) }>
{ fmt.Sprintf("$%.2f", float64(report.NetBalance)/100.0) } { model.FormatMoney(report.NetBalance) }
</span> </span>
</div> </div>
</div> </div>
@ -97,7 +98,7 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
tagColors := make([]string, len(report.ByTag)) tagColors := make([]string, len(report.ByTag))
for i, t := range report.ByTag { for i, t := range report.ByTag {
tagLabels[i] = t.TagName tagLabels[i] = t.TagName
tagData[i] = float64(t.TotalAmount) / 100.0 tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = chartColor(i, t.TagColor) tagColors[i] = chartColor(i, t.TagColor)
} }
}} }}
@ -133,12 +134,12 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
if days <= 31 { if days <= 31 {
for _, d := range report.DailySpending { for _, d := range report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02")) timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, float64(d.TotalCents)/100.0) timeData = append(timeData, d.Total.InexactFloat64())
} }
} else { } else {
for _, m := range report.MonthlySpending { for _, m := range report.MonthlySpending {
timeLabels = append(timeLabels, m.Month) timeLabels = append(timeLabels, m.Month)
timeData = append(timeData, float64(m.TotalCents)/100.0) timeData = append(timeData, m.Total.InexactFloat64())
} }
} }
}} }}
@ -189,7 +190,7 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
} }
</div> </div>
<p class="font-bold text-destructive text-sm shrink-0"> <p class="font-bold text-destructive text-sm shrink-0">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) } { model.FormatMoney(exp.Amount) }
</p> </p>
</div> </div>
} }