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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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