chore: replace int amount_cents for string storage and decimal pkg
This commit is contained in:
parent
13774eec7d
commit
c8a1eb5b7a
45 changed files with 706 additions and 587 deletions
54
internal/db/migrations/00018_add_decimal_amount_columns.sql
Normal file
54
internal/db/migrations/00018_add_decimal_amount_columns.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package model
|
|||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type BudgetPeriod string
|
||||
|
|
@ -24,7 +26,8 @@ const (
|
|||
type Budget struct {
|
||||
ID string `db:"id"`
|
||||
SpaceID string `db:"space_id"`
|
||||
AmountCents int `db:"amount_cents"`
|
||||
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"`
|
||||
|
|
@ -37,7 +40,7 @@ type Budget struct {
|
|||
type BudgetWithSpent struct {
|
||||
Budget
|
||||
Tags []*Tag
|
||||
SpentCents int
|
||||
Spent decimal.Decimal
|
||||
Percentage float64
|
||||
Status BudgetStatus
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type ExpenseType string
|
||||
|
||||
|
|
@ -14,7 +18,8 @@ type Expense struct {
|
|||
SpaceID string `db:"space_id"`
|
||||
CreatedBy string `db:"created_by"`
|
||||
Description string `db:"description"`
|
||||
AmountCents int `db:"amount_cents"`
|
||||
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"`
|
||||
|
|
@ -48,5 +53,5 @@ type TagExpenseSummary struct {
|
|||
TagID string `db:"tag_id"`
|
||||
TagName string `db:"tag_name"`
|
||||
TagColor *string `db:"tag_color"`
|
||||
TotalAmount int `db:"total_amount"`
|
||||
TotalAmount decimal.Decimal `db:"total_amount"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
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"`
|
||||
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"`
|
||||
|
|
@ -19,7 +24,7 @@ type Loan struct {
|
|||
|
||||
type LoanWithPaymentSummary struct {
|
||||
Loan
|
||||
TotalPaidCents int
|
||||
RemainingCents int
|
||||
TotalPaid decimal.Decimal
|
||||
Remaining decimal.Decimal
|
||||
ReceiptCount int
|
||||
}
|
||||
|
|
|
|||
17
internal/model/money.go
Normal file
17
internal/model/money.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type FundingSourceType string
|
||||
|
||||
|
|
@ -14,7 +18,8 @@ type Receipt struct {
|
|||
LoanID string `db:"loan_id"`
|
||||
SpaceID string `db:"space_id"`
|
||||
Description string `db:"description"`
|
||||
TotalAmountCents int `db:"total_amount_cents"`
|
||||
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"`
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
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"`
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type Frequency string
|
||||
|
||||
|
|
@ -17,7 +21,8 @@ type RecurringExpense struct {
|
|||
SpaceID string `db:"space_id"`
|
||||
CreatedBy string `db:"created_by"`
|
||||
Description string `db:"description"`
|
||||
AmountCents int `db:"amount_cents"`
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
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"`
|
||||
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"`
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
Total decimal.Decimal `db:"total"`
|
||||
}
|
||||
|
||||
type MonthlySpending struct {
|
||||
Month string `db:"month"`
|
||||
TotalCents int `db:"total_cents"`
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -98,7 +99,7 @@ func TestMoneyAccountRepository_CreateTransfer(t *testing.T) {
|
|||
transfer := &model.AccountTransfer{
|
||||
ID: uuid.NewString(),
|
||||
AccountID: account.ID,
|
||||
AmountCents: 5000,
|
||||
Amount: decimal.RequireFromString("50.00"),
|
||||
Direction: model.TransferDirectionDeposit,
|
||||
Note: "Initial deposit",
|
||||
CreatedBy: user.ID,
|
||||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error)
|
|||
budget := &model.Budget{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: dto.SpaceID,
|
||||
AmountCents: dto.Amount,
|
||||
Amount: dto.Amount,
|
||||
Period: dto.Period,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,7 +49,7 @@ 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")
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
|
|||
SpaceID: dto.SpaceID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
OriginalAmountCents: dto.OriginalAmount,
|
||||
OriginalAmount: dto.OriginalAmount,
|
||||
InterestRateBps: dto.InterestRateBps,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
|
|
@ -96,8 +97,8 @@ func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSumma
|
|||
|
||||
return &model.LoanWithPaymentSummary{
|
||||
Loan: *loan,
|
||||
TotalPaidCents: totalPaid,
|
||||
RemainingCents: loan.OriginalAmountCents - totalPaid,
|
||||
TotalPaid: totalPaid,
|
||||
Remaining: loan.OriginalAmount.Sub(totalPaid),
|
||||
ReceiptCount: receiptCount,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -155,8 +156,8 @@ func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPay
|
|||
}
|
||||
result[i] = &model.LoanWithPaymentSummary{
|
||||
Loan: *loan,
|
||||
TotalPaidCents: totalPaid,
|
||||
RemainingCents: loan.OriginalAmountCents - totalPaid,
|
||||
TotalPaid: totalPaid,
|
||||
Remaining: loan.OriginalAmount.Sub(totalPaid),
|
||||
ReceiptCount: receiptCount,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +134,7 @@ 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +142,7 @@ func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpa
|
|||
transfer := &model.AccountTransfer{
|
||||
ID: uuid.NewString(),
|
||||
AccountID: dto.AccountID,
|
||||
AmountCents: dto.Amount,
|
||||
Amount: dto.Amount,
|
||||
Direction: dto.Direction,
|
||||
Note: strings.TrimSpace(dto.Note),
|
||||
CreatedBy: dto.CreatedBy,
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ func (s *ReceiptService) buildLinkedRecords(
|
|||
ID: uuid.NewString(),
|
||||
ReceiptID: receipt.ID,
|
||||
SourceType: src.SourceType,
|
||||
AmountCents: src.Amount,
|
||||
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,
|
||||
|
|
@ -153,7 +154,7 @@ func (s *ReceiptService) buildLinkedRecords(
|
|||
transfer := &model.AccountTransfer{
|
||||
ID: uuid.NewString(),
|
||||
AccountID: src.AccountID,
|
||||
AmountCents: src.Amount,
|
||||
Amount: src.Amount,
|
||||
Direction: model.TransferDirectionWithdrawal,
|
||||
Note: fmt.Sprintf("Loan payment: %s", description),
|
||||
CreatedBy: userID,
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,18 +58,18 @@ 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")
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringRece
|
|||
LoanID: dto.LoanID,
|
||||
SpaceID: dto.SpaceID,
|
||||
Description: dto.Description,
|
||||
TotalAmountCents: dto.TotalAmount,
|
||||
TotalAmount: dto.TotalAmount,
|
||||
Frequency: dto.Frequency,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue