chore: replace int amount_cents for string storage and decimal pkg

This commit is contained in:
juancwu 2026-03-14 14:43:39 -04:00
commit c8a1eb5b7a
No known key found for this signature in database
45 changed files with 706 additions and 587 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

@ -156,9 +156,9 @@ func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) {
allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
allocated = 0
allocated = decimal.Zero
}
balance -= allocated
balance = balance.Sub(allocated)
// This month's report
now := time.Now()
@ -578,9 +578,9 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = 0
totalAllocated = decimal.Zero
}
balance -= totalAllocated
balance = balance.Sub(totalAllocated)
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
@ -639,12 +639,11 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
@ -717,7 +716,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amountCents,
Amount: amount,
Type: expenseType,
Date: date,
TagIDs: finalTagIDs,
@ -760,9 +759,9 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = 0
totalAllocated = decimal.Zero
}
balance -= totalAllocated
balance = balance.Sub(totalAllocated)
// Return the full paginated list for page 1 so the new expense appears
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, 1)
@ -809,12 +808,11 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
@ -874,7 +872,7 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
ID: expenseID,
SpaceID: spaceID,
Description: description,
Amount: amountCents,
Amount: amount,
Type: expenseType,
Date: date,
TagIDs: finalTagIDs,
@ -904,9 +902,9 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = 0
totalAllocated = decimal.Zero
}
balance -= totalAllocated
balance = balance.Sub(totalAllocated)
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
updatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
@ -935,9 +933,9 @@ func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = 0
totalAllocated = decimal.Zero
}
balance -= totalAllocated
balance = balance.Sub(totalAllocated)
ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
@ -1033,9 +1031,9 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = 0
totalAllocated = decimal.Zero
}
balance -= totalAllocated
balance = balance.Sub(totalAllocated)
ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, false))
}
@ -1361,7 +1359,7 @@ func (h *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
return
}
availableBalance := totalBalance - totalAllocated
availableBalance := totalBalance.Sub(totalAllocated)
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
if err != nil {
@ -1401,7 +1399,7 @@ func (h *SpaceHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
BalanceCents: 0,
Balance: decimal.Zero,
}
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
@ -1445,7 +1443,7 @@ func (h *SpaceHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *updatedAccount,
BalanceCents: balance,
Balance: balance,
}
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
@ -1476,7 +1474,7 @@ func (h *SpaceHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
}
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Account deleted",
Variant: toast.VariantSuccess,
@ -1504,12 +1502,11 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
direction := model.TransferDirection(r.FormValue("direction"))
note := r.FormValue("note")
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) {
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
// Calculate available space balance for deposit validation
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
@ -1524,11 +1521,11 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
availableBalance := totalBalance - totalAllocated
availableBalance := totalBalance.Sub(totalAllocated)
// Validate balance limits before creating transfer
if direction == model.TransferDirectionDeposit && amountCents > availableBalance {
ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0), http.StatusUnprocessableEntity)
if direction == model.TransferDirectionDeposit && amount.GreaterThan(availableBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to %s.", model.FormatMoney(availableBalance)), http.StatusUnprocessableEntity)
return
}
@ -1539,15 +1536,15 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if amountCents > acctBalance {
ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0), http.StatusUnprocessableEntity)
if amount.GreaterThan(acctBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to %s.", model.FormatMoney(acctBalance)), http.StatusUnprocessableEntity)
return
}
}
_, err = h.accountService.CreateTransfer(service.CreateTransferDTO{
AccountID: accountID,
Amount: amountCents,
Amount: amount,
Direction: direction,
Note: note,
CreatedBy: user.ID,
@ -1569,12 +1566,12 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
BalanceCents: accountBalance,
Balance: accountBalance,
}
// Recalculate available balance after transfer
totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID)
newAvailable := totalBalance - totalAllocated
newAvailable := totalBalance.Sub(totalAllocated)
w.Header().Set("HX-Trigger", "transferSuccess")
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
@ -1611,14 +1608,14 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
BalanceCents: accountBalance,
Balance: accountBalance,
}
totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID)
totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID)
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true))
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
@ -1693,12 +1690,11 @@ func (h *SpaceHandler) CreateRecurringDeposit(w http.ResponseWriter, r *http.Req
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) {
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -1719,7 +1715,7 @@ func (h *SpaceHandler) CreateRecurringDeposit(w http.ResponseWriter, r *http.Req
rd, err := h.recurringDepositService.CreateRecurringDeposit(service.CreateRecurringDepositDTO{
SpaceID: spaceID,
AccountID: accountID,
Amount: amountCents,
Amount: amount,
Frequency: model.Frequency(frequencyStr),
StartDate: startDate,
EndDate: endDate,
@ -1780,12 +1776,11 @@ func (h *SpaceHandler) UpdateRecurringDeposit(w http.ResponseWriter, r *http.Req
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) {
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -1806,7 +1801,7 @@ func (h *SpaceHandler) UpdateRecurringDeposit(w http.ResponseWriter, r *http.Req
updated, err := h.recurringDepositService.UpdateRecurringDeposit(service.UpdateRecurringDepositDTO{
ID: recurringDepositID,
AccountID: accountID,
Amount: amountCents,
Amount: amount,
Frequency: model.Frequency(frequencyStr),
StartDate: startDate,
EndDate: endDate,
@ -2082,12 +2077,11 @@ func (h *SpaceHandler) CreateRecurringExpense(w http.ResponseWriter, r *http.Req
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -2156,7 +2150,7 @@ func (h *SpaceHandler) CreateRecurringExpense(w http.ResponseWriter, r *http.Req
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amountCents,
Amount: amount,
Type: expenseType,
PaymentMethodID: paymentMethodID,
Frequency: frequency,
@ -2210,12 +2204,11 @@ func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Req
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -2270,7 +2263,7 @@ func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Req
updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{
ID: recurringID,
Description: description,
Amount: amountCents,
Amount: amount,
Type: model.ExpenseType(typeStr),
PaymentMethodID: paymentMethodID,
Frequency: model.Frequency(frequencyStr),
@ -2464,12 +2457,11 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -2490,7 +2482,7 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
_, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{
SpaceID: spaceID,
TagIDs: tagIDs,
Amount: amountCents,
Amount: amount,
Period: model.BudgetPeriod(periodStr),
StartDate: startDate,
EndDate: endDate,
@ -2544,12 +2536,11 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
amount, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity)
return
}
amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart())
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
@ -2570,7 +2561,7 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
_, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{
ID: budgetID,
TagIDs: tagIDs,
Amount: amountCents,
Amount: amount,
Period: model.BudgetPeriod(periodStr),
StartDate: startDate,
EndDate: endDate,

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,21 +1,26 @@
package model
import "time"
import (
"time"
"github.com/shopspring/decimal"
)
type RecurringDeposit struct {
ID string `db:"id"`
SpaceID string `db:"space_id"`
AccountID string `db:"account_id"`
AmountCents int `db:"amount_cents"`
Frequency Frequency `db:"frequency"`
StartDate time.Time `db:"start_date"`
EndDate *time.Time `db:"end_date"`
NextOccurrence time.Time `db:"next_occurrence"`
IsActive bool `db:"is_active"`
Title string `db:"title"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
AccountID string `db:"account_id"`
Amount decimal.Decimal `db:"amount"`
AmountCents int `db:"amount_cents"` // deprecated: kept for SELECT * compatibility
Frequency Frequency `db:"frequency"`
StartDate time.Time `db:"start_date"`
EndDate *time.Time `db:"end_date"`
NextOccurrence time.Time `db:"next_occurrence"`
IsActive bool `db:"is_active"`
Title string `db:"title"`
CreatedBy string `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type RecurringDepositWithAccount struct {

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
@ -17,7 +18,7 @@ type BudgetRepository interface {
Create(budget *model.Budget, tagIDs []string) error
GetByID(id string) (*model.Budget, error)
GetBySpaceID(spaceID string) ([]*model.Budget, error)
GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error)
GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error)
GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error)
Update(budget *model.Budget, tagIDs []string) error
Delete(id string) error
@ -38,9 +39,9 @@ func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error {
}
defer tx.Rollback()
query := `INSERT INTO budgets (id, space_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`
_, err = tx.Exec(query, budget.ID, budget.SpaceID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
query := `INSERT INTO budgets (id, space_id, amount, period, start_date, end_date, is_active, created_by, created_at, updated_at, amount_cents)
Values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`
_, err = tx.Exec(query, budget.ID, budget.SpaceID, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
if err != nil {
return err
}
@ -72,23 +73,23 @@ func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error)
return budgets, err
}
func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error) {
func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error) {
if len(tagIDs) == 0 {
return 0, nil
return decimal.Zero, nil
}
query, args, err := sqlx.In(`
SELECT COALESCE(SUM(e.amount_cents), 0)
SELECT COALESCE(SUM(CAST(e.amount AS DECIMAL)), 0)
FROM expenses e
WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ?
AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?))
`, spaceID, periodStart, periodEnd, tagIDs)
if err != nil {
return 0, err
return decimal.Zero, err
}
query = r.db.Rebind(query)
var spent int
var spent decimal.Decimal
err = r.db.Get(&spent, query, args...)
return spent, err
}
@ -142,8 +143,8 @@ func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
}
defer tx.Rollback()
query := `UPDATE budgets SET amount_cents = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;`
_, err = tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
query := `UPDATE budgets SET amount = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;`
_, err = tx.Exec(query, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
if err != nil {
return err
}

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
@ -28,7 +29,7 @@ type ExpenseRepository interface {
GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error)
GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error)
GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error)
GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error)
GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error)
}
type expenseRepository struct {
@ -47,9 +48,9 @@ func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, item
defer tx.Rollback()
// Insert Expense
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt)
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`
_, 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)
if err != nil {
return err
}
@ -122,7 +123,7 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti
t.id as tag_id,
t.name as tag_name,
t.color as tag_color,
SUM(e.amount_cents) as total_amount
SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id
JOIN tags t ON et.tag_id = t.id
@ -229,8 +230,8 @@ func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) erro
}
defer tx.Rollback()
query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;`
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID)
query := `UPDATE expenses SET description = $1, amount = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;`
_, err = tx.Exec(query, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID)
if err != nil {
return err
}
@ -261,7 +262,7 @@ func (r *expenseRepository) Delete(id string) error {
func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
var results []*model.DailySpending
query := `
SELECT date, SUM(amount_cents) as total_cents
SELECT date, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY date
@ -276,14 +277,14 @@ func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Tim
var query string
if r.db.DriverName() == "sqlite" {
query = `
SELECT strftime('%Y-%m', date) as month, SUM(amount_cents) as total_cents
SELECT strftime('%Y-%m', date) as month, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC;`
} else {
query = `
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(amount_cents) as total_cents
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY TO_CHAR(date, 'YYYY-MM')
@ -298,37 +299,38 @@ func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, l
query := `
SELECT * FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
ORDER BY amount_cents DESC
ORDER BY CAST(amount AS DECIMAL) DESC
LIMIT $4;
`
err := r.db.Select(&results, query, spaceID, from, to, limit)
return results, err
}
func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (int, int, error) {
func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error) {
type summary struct {
Type string `db:"type"`
Total int `db:"total"`
Type string `db:"type"`
Total decimal.Decimal `db:"total"`
}
var results []summary
query := `
SELECT type, COALESCE(SUM(amount_cents), 0) as total
SELECT type, COALESCE(SUM(CAST(amount AS DECIMAL)), 0) as total
FROM expenses
WHERE space_id = $1 AND date >= $2 AND date <= $3
GROUP BY type;
`
err := r.db.Select(&results, query, spaceID, from, to)
if err != nil {
return 0, 0, err
return decimal.Zero, decimal.Zero, err
}
var income, expenses int
income := decimal.Zero
expenseTotal := decimal.Zero
for _, r := range results {
if r.Type == "topup" {
income = r.Total
} else if r.Type == "expense" {
expenses = r.Total
expenseTotal = r.Total
}
}
return income, expenses, nil
return income, expenseTotal, nil
}

View file

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

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
@ -22,7 +23,7 @@ type LoanRepository interface {
Update(loan *model.Loan) error
Delete(id string) error
SetPaidOff(id string, paidOff bool) error
GetTotalPaidForLoan(loanID string) (int, error)
GetTotalPaidForLoan(loanID string) (decimal.Decimal, error)
GetReceiptCountForLoan(loanID string) (int, error)
}
@ -35,9 +36,9 @@ func NewLoanRepository(db *sqlx.DB) LoanRepository {
}
func (r *loanRepository) Create(loan *model.Loan) error {
query := `INSERT INTO loans (id, space_id, name, description, original_amount_cents, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);`
_, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt)
query := `INSERT INTO loans (id, space_id, name, description, original_amount, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at, original_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 0);`
_, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt)
return err
}
@ -72,8 +73,8 @@ func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) {
}
func (r *loanRepository) Update(loan *model.Loan) error {
query := `UPDATE loans SET name = $1, description = $2, original_amount_cents = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;`
result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmountCents, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID)
query := `UPDATE loans SET name = $1, description = $2, original_amount = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;`
result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID)
if err != nil {
return err
}
@ -94,9 +95,9 @@ func (r *loanRepository) SetPaidOff(id string, paidOff bool) error {
return err
}
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (int, error) {
var total int
err := r.db.Get(&total, `SELECT COALESCE(SUM(total_amount_cents), 0) FROM receipts WHERE loan_id = $1;`, loanID)
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) {
var total decimal.Decimal
err := r.db.Get(&total, `SELECT COALESCE(SUM(CAST(total_amount AS DECIMAL)), 0) FROM receipts WHERE loan_id = $1;`, loanID)
return total, err
}

View file

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

View file

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

View file

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

View file

@ -35,9 +35,9 @@ func NewRecurringDepositRepository(db *sqlx.DB) RecurringDepositRepository {
}
func (r *recurringDepositRepository) Create(rd *model.RecurringDeposit) error {
query := `INSERT INTO recurring_deposits (id, space_id, account_id, amount_cents, frequency, start_date, end_date, next_occurrence, is_active, title, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);`
_, err := r.db.Exec(query, rd.ID, rd.SpaceID, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.IsActive, rd.Title, rd.CreatedBy, rd.CreatedAt, rd.UpdatedAt)
query := `INSERT INTO recurring_deposits (id, space_id, account_id, amount, frequency, start_date, end_date, next_occurrence, is_active, title, created_by, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`
_, err := r.db.Exec(query, rd.ID, rd.SpaceID, rd.AccountID, rd.Amount, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.IsActive, rd.Title, rd.CreatedBy, rd.CreatedAt, rd.UpdatedAt)
return err
}
@ -59,8 +59,8 @@ func (r *recurringDepositRepository) GetBySpaceID(spaceID string) ([]*model.Recu
}
func (r *recurringDepositRepository) Update(rd *model.RecurringDeposit) error {
query := `UPDATE recurring_deposits SET account_id = $1, amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, title = $7, updated_at = $8 WHERE id = $9;`
result, err := r.db.Exec(query, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.Title, rd.UpdatedAt, rd.ID)
query := `UPDATE recurring_deposits SET account_id = $1, amount = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, title = $7, updated_at = $8 WHERE id = $9;`
result, err := r.db.Exec(query, rd.AccountID, rd.Amount, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.Title, rd.UpdatedAt, rd.ID)
if err != nil {
return err
}

View file

@ -43,9 +43,9 @@ func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs [
}
defer tx.Rollback()
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);`
_, 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)
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 0);`
_, 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)
if err != nil {
return err
}
@ -171,8 +171,8 @@ func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs [
}
defer tx.Rollback()
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;`
_, err = tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID)
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;`
_, err = tx.Exec(query, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID)
if err != nil {
return err
}

View file

@ -44,9 +44,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources
defer tx.Rollback()
_, err = tx.Exec(
`INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount_cents, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);`,
rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt,
`INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at, total_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`,
rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt,
)
if err != nil {
return err
@ -54,9 +54,9 @@ func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents)
VALUES ($1, $2, $3, $4, $5);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents,
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err
@ -105,8 +105,8 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources
defer tx.Rollback()
_, err = tx.Exec(
`UPDATE recurring_receipts SET description = $1, total_amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`,
rr.Description, rr.TotalAmountCents, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
`UPDATE recurring_receipts SET description = $1, total_amount = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`,
rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
)
if err != nil {
return err
@ -119,9 +119,9 @@ func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount_cents)
VALUES ($1, $2, $3, $4, $5);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.AmountCents,
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -53,13 +54,13 @@ func TestMoneyAccountService_GetAccountsForSpace(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("50.00"), model.TransferDirectionDeposit, user.ID)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, "Checking", accounts[0].Name)
assert.Equal(t, 5000, accounts[0].BalanceCents)
assert.True(t, decimal.RequireFromString("50.00").Equal(accounts[0].Balance))
})
}
@ -74,14 +75,14 @@ func TestMoneyAccountService_CreateTransfer_Deposit(t *testing.T) {
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 3000,
Amount: decimal.RequireFromString("30.00"),
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
}, 10000)
}, decimal.RequireFromString("100.00"))
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.Equal(t, 3000, transfer.AmountCents)
assert.True(t, decimal.RequireFromString("30.00").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction)
})
}
@ -97,11 +98,11 @@ func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) {
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 5000,
Amount: decimal.RequireFromString("50.00"),
Direction: model.TransferDirectionDeposit,
Note: "Too much",
CreatedBy: user.ID,
}, 1000)
}, decimal.RequireFromString("10.00"))
assert.Error(t, err)
assert.Nil(t, transfer)
})
@ -115,18 +116,18 @@ func TestMoneyAccountService_CreateTransfer_Withdrawal(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("50.00"), model.TransferDirectionDeposit, user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 2000,
Amount: decimal.RequireFromString("20.00"),
Direction: model.TransferDirectionWithdrawal,
Note: "Withdrawal",
CreatedBy: user.ID,
}, 0)
}, decimal.Zero)
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.Equal(t, 2000, transfer.AmountCents)
assert.True(t, decimal.RequireFromString("20.00").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionWithdrawal, transfer.Direction)
})
}
@ -140,14 +141,14 @@ func TestMoneyAccountService_GetTotalAllocatedForSpace(t *testing.T) {
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, 3000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("30.00"), model.TransferDirectionDeposit, user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 2", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 2000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("20.00"), model.TransferDirectionDeposit, user.ID)
total, err := svc.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.Equal(t, 5000, total)
assert.True(t, decimal.RequireFromString("50.00").Equal(total))
})
}
@ -177,7 +178,7 @@ func TestMoneyAccountService_DeleteTransfer(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.00"), model.TransferDirectionDeposit, user.ID)
err := svc.DeleteTransfer(transfer.ID)
require.NoError(t, err)

View file

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

View file

@ -9,12 +9,13 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateRecurringDepositDTO struct {
SpaceID string
AccountID string
Amount int
Amount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
@ -25,7 +26,7 @@ type CreateRecurringDepositDTO struct {
type UpdateRecurringDepositDTO struct {
ID string
AccountID string
Amount int
Amount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
@ -51,7 +52,7 @@ func NewRecurringDepositService(recurringRepo repository.RecurringDepositReposit
}
func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
@ -60,7 +61,7 @@ func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepo
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
AccountID: dto.AccountID,
AmountCents: dto.Amount,
Amount: dto.Amount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
@ -113,7 +114,7 @@ func (s *RecurringDepositService) GetRecurringDepositsWithAccountsForSpace(space
}
func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepositDTO) (*model.RecurringDeposit, error) {
if dto.Amount <= 0 {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
@ -123,7 +124,7 @@ func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepo
}
existing.AccountID = dto.AccountID
existing.AmountCents = dto.Amount
existing.Amount = dto.Amount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
@ -221,16 +222,16 @@ func (s *RecurringDepositService) getLocalNow(spaceID, userID string, now time.T
return now.In(loc)
}
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (decimal.Decimal, error) {
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get space balance: %w", err)
return decimal.Zero, fmt.Errorf("failed to get space balance: %w", err)
}
totalAllocated, err := s.accountRepo.GetTotalAllocatedForSpace(spaceID)
if err != nil {
return 0, fmt.Errorf("failed to get total allocated: %w", err)
return decimal.Zero, fmt.Errorf("failed to get total allocated: %w", err)
}
return totalBalance - totalAllocated, nil
return totalBalance.Sub(totalAllocated), nil
}
func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit, now time.Time) error {
@ -246,12 +247,12 @@ func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit,
return err
}
if availableBalance >= rd.AmountCents {
if availableBalance.GreaterThanOrEqual(rd.Amount) {
rdID := rd.ID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: rd.AccountID,
AmountCents: rd.AmountCents,
Amount: rd.Amount,
Direction: model.TransferDirectionDeposit,
Note: rd.Title,
RecurringDepositID: &rdID,
@ -265,7 +266,7 @@ func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit,
slog.Warn("recurring deposit skipped: insufficient available balance",
"recurring_deposit_id", rd.ID,
"space_id", rd.SpaceID,
"needed", rd.AmountCents,
"needed", rd.Amount,
"available", availableBalance,
)
}

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
// CreateTestUser inserts a user directly into the database.
@ -152,7 +153,7 @@ func CreateTestListItem(t *testing.T, db *sqlx.DB, listID, name, createdBy strin
}
// CreateTestExpense inserts an expense directly into the database.
func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount int, typ model.ExpenseType) *model.Expense {
func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount decimal.Decimal, typ model.ExpenseType) *model.Expense {
t.Helper()
now := time.Now()
expense := &model.Expense{
@ -160,15 +161,15 @@ func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string,
SpaceID: spaceID,
CreatedBy: userID,
Description: desc,
AmountCents: amount,
Amount: amount,
Type: typ,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents,
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, created_at, updated_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0)`,
expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount,
expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt,
)
if err != nil {
@ -200,20 +201,20 @@ func CreateTestMoneyAccount(t *testing.T, db *sqlx.DB, spaceID, name, createdBy
}
// CreateTestTransfer inserts an account transfer directly into the database.
func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount int, direction model.TransferDirection, createdBy string) *model.AccountTransfer {
func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount decimal.Decimal, direction model.TransferDirection, createdBy string) *model.AccountTransfer {
t.Helper()
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: accountID,
AmountCents: amount,
Direction: direction,
Note: "test transfer",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ID: uuid.NewString(),
AccountID: accountID,
Amount: amount,
Direction: direction,
Note: "test transfer",
CreatedBy: createdBy,
CreatedAt: time.Now(),
}
_, err := db.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt,
`INSERT INTO account_transfers (id, account_id, amount, direction, note, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, 0)`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
t.Fatalf("CreateTestTransfer: %v", err)

View file

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

View file

@ -13,6 +13,7 @@ import (
"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/selectbox"
"github.com/shopspring/decimal"
)
func frequencyLabel(f model.Frequency) string {
@ -32,7 +33,7 @@ func frequencyLabel(f model.Frequency) string {
}
}
templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, oob bool) {
templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {
<div
id="accounts-balance-summary"
class="border rounded-lg p-4 bg-card text-card-foreground"
@ -44,20 +45,20 @@ templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int,
<div class="grid grid-cols-3 gap-4">
<div>
<p class="text-sm text-muted-foreground">Total Balance</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance < 0) }>
{ fmt.Sprintf("$%.2f", float64(totalBalance)/100.0) }
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(totalBalance) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Allocated</p>
<p class="text-xl font-bold">
{ fmt.Sprintf("$%.2f", float64(totalBalance-availableBalance)/100.0) }
{ model.FormatMoney(totalBalance.Sub(availableBalance)) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Available</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance < 0) }>
{ fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(availableBalance) }
</p>
</div>
</div>
@ -79,8 +80,8 @@ templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bo
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold text-lg">{ acct.Name }</h3>
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.BalanceCents < 0) }>
{ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) }
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(acct.Balance) }
</p>
</div>
<div class="flex gap-1">
@ -356,7 +357,7 @@ templ RecurringDepositItem(spaceID string, rd *model.RecurringDepositWithAccount
</div>
<div class="flex items-center gap-2">
<span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(rd.AmountCents)/100.0) }
+{ model.FormatMoney(rd.Amount) }
</span>
// Toggle
@button.Button(button.Props{
@ -636,11 +637,11 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
<div class="flex items-center gap-2">
if t.Direction == model.TransferDirectionDeposit {
<span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
+{ model.FormatMoney(t.Amount) }
</span>
} else {
<span class="font-bold text-destructive whitespace-nowrap">
-{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
-{ model.FormatMoney(t.Amount) }
</span>
}
@button.Button(button.Props{
@ -696,7 +697,7 @@ templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAcc
Name: "amount",
ID: "edit-rd-amount-" + rd.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(rd.AmountCents)/100.0),
Value: model.FormatDecimal(rd.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
})
</div>

View file

@ -73,11 +73,11 @@ templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod,
<div class="flex items-center gap-1 shrink-0">
if re.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
- { model.FormatMoney(re.Amount) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
+ { model.FormatMoney(re.Amount) }
</p>
}
// Toggle pause/resume
@ -352,7 +352,7 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet
Name: "amount",
ID: "edit-recurring-amount-" + re.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(re.AmountCents)/100.0),
Value: model.FormatDecimal(re.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</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/moneyaccount"
"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) {
<div class="space-y-4">
<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
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</span>
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<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>
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>
@ -311,7 +311,7 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
Name: "amount",
ID: "edit-budget-amount-" + b.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(b.AmountCents)/100.0),
Value: model.FormatDecimal(b.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>

View file

@ -3,6 +3,7 @@ package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -14,7 +15,7 @@ import (
"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) {
<div class="space-y-4">
<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">
if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
- { model.FormatMoney(exp.Amount) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
+ { model.FormatMoney(exp.Amount) }
</p>
}
// Edit button
@ -186,12 +187,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
</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)
@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)
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
}

View file

@ -3,6 +3,7 @@ package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -17,7 +18,7 @@ import (
"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) {
<div class="space-y-6">
// Loan Summary Card
@ -94,8 +95,8 @@ templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }}
if !loan.OriginalAmount.IsZero() {
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
if 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>
<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>
<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>
<p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold">
if loan.RemainingCents > 0 {
{ fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }
if loan.Remaining.GreaterThan(decimal.Zero) {
{ model.FormatMoney(loan.Remaining) }
} else {
$0.00
}
@ -244,7 +245,7 @@ templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesA
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
<div class="space-y-1">
<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>
if receipt.RecurringReceiptID != nil {
@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 {
if src.SourceType == "balance" {
@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 {
@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="flex items-center gap-2">
@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>
if !rr.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
@ -322,12 +323,12 @@ templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWit
for _, src := range rr.Sources {
if src.SourceType == "balance" {
@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 {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
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>
}
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
hx-swap="none"
@ -423,7 +424,7 @@ templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWit
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Available balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
Available balance: { model.FormatMoney(availableBalance) }
</p>
<div id="funding-sources" class="space-y-2">
<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>
for _, acct := range accounts {
<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>
}
</select>
@ -483,7 +484,7 @@ templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWit
</script>
}
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
hx-swap="none"
@ -547,7 +548,7 @@ templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyA
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Current balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
Current balance: { model.FormatMoney(availableBalance) }
</p>
<div id="recurring-funding-sources" class="space-y-2">
<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>
for _, acct := range accounts {
<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>
}
</select>

View file

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

View file

@ -3,6 +3,7 @@ package pages
import (
"fmt"
"sort"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -14,8 +15,8 @@ import (
type OverviewData struct {
Space *model.Space
Balance int
Allocated int
Balance decimal.Decimal
Allocated decimal.Decimal
Report *model.SpendingReport
Budgets []*model.BudgetWithSpent
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
@ -143,12 +144,12 @@ templ overviewBalanceCard(data OverviewData) {
<h3 class="font-semibold mb-3">Current Balance</h3>
@dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods)
</div>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance < 0) }>
{ fmt.Sprintf("$%.2f", float64(data.Balance)/100.0) }
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance.IsNegative()) }>
{ model.FormatMoney(data.Balance) }
</p>
if data.Allocated > 0 {
if data.Allocated.GreaterThan(decimal.Zero) {
<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>
}
</div>
@ -161,17 +162,17 @@ templ overviewSpendingCard(data OverviewData) {
<div class="space-y-1">
<div class="flex justify-between">
<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 class="flex justify-between">
<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>
<hr class="border-border"/>
<div class="flex justify-between">
<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) }>
{ fmt.Sprintf("$%.2f", float64(data.Report.NetBalance)/100.0) }
<span class={ "text-sm font-bold", templ.KV("text-green-500", !data.Report.NetBalance.IsNegative()), templ.KV("text-destructive", data.Report.NetBalance.IsNegative()) }>
{ model.FormatMoney(data.Report.NetBalance) }
</span>
</div>
</div>
@ -191,7 +192,7 @@ templ overviewSpendingByCategoryChart(data OverviewData) {
tagColors := make([]string, len(data.Report.ByTag))
for i, t := range data.Report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = float64(t.TotalAmount) / 100.0
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = overviewChartColor(i, t.TagColor)
}
}}
@ -224,7 +225,7 @@ templ overviewSpendingOverTimeChart(data OverviewData) {
var timeData []float64
for _, d := range data.Report.DailySpending {
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{
@ -276,8 +277,8 @@ templ overviewBudgetsCard(data OverviewData) {
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</span>
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<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>
@ -309,11 +310,11 @@ templ overviewRecurringCard(data OverviewData) {
</div>
if r.Type == model.ExpenseTypeExpense {
<p class="text-sm font-bold text-destructive shrink-0">
{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) }
{ model.FormatMoney(r.Amount) }
</p>
} else {
<p class="text-sm font-bold text-green-500 shrink-0">
+{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) }
+{ model.FormatMoney(r.Amount) }
</p>
}
</div>
@ -348,7 +349,7 @@ templ overviewTopExpensesCard(data OverviewData) {
}
</div>
<p class="text-sm font-bold text-destructive shrink-0">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
{ model.FormatMoney(exp.Amount) }
</p>
</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/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
)
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="flex justify-between">
<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 class="flex justify-between">
<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>
<hr class="border-border"/>
<div class="flex justify-between">
<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) }>
{ fmt.Sprintf("$%.2f", float64(report.NetBalance)/100.0) }
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance.GreaterThanOrEqual(decimal.Zero)), templ.KV("text-destructive", report.NetBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(report.NetBalance) }
</span>
</div>
</div>
@ -97,7 +98,7 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
tagColors := make([]string, len(report.ByTag))
for i, t := range report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = float64(t.TotalAmount) / 100.0
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = chartColor(i, t.TagColor)
}
}}
@ -133,12 +134,12 @@ templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.T
if days <= 31 {
for _, d := range report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, float64(d.TotalCents)/100.0)
timeData = append(timeData, d.Total.InexactFloat64())
}
} else {
for _, m := range report.MonthlySpending {
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>
<p class="font-bold text-destructive text-sm shrink-0">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
{ model.FormatMoney(exp.Amount) }
</p>
</div>
}