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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -38,9 +38,9 @@ func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository {
func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);`
if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil {
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 0);`
if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil {
return err
}
@ -161,8 +161,8 @@ func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids
func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
if _, err := tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil {
query := `UPDATE recurring_expenses SET description = $1, amount = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
if _, err := tx.Exec(query, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil {
return err
}

View file

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