budgit/internal/service/money_account.go
juancwu 89c5d76e5e
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s
Merge branch 'fix/calculation-accuracy' into main
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>
2026-03-14 16:48:40 -04:00

191 lines
4.7 KiB
Go

package service
import (
"fmt"
"strings"
"time"
"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 {
SpaceID string
Name string
CreatedBy string
}
type UpdateMoneyAccountDTO struct {
ID string
Name string
}
type CreateTransferDTO struct {
AccountID string
Amount decimal.Decimal
Direction model.TransferDirection
Note string
CreatedBy string
}
type MoneyAccountService struct {
accountRepo repository.MoneyAccountRepository
}
func NewMoneyAccountService(accountRepo repository.MoneyAccountRepository) *MoneyAccountService {
return &MoneyAccountService{
accountRepo: accountRepo,
}
}
func (s *MoneyAccountService) CreateAccount(dto CreateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: name,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.accountRepo.Create(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) GetAccountsForSpace(spaceID string) ([]model.MoneyAccountWithBalance, error) {
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
result := make([]model.MoneyAccountWithBalance, len(accounts))
for i, acct := range accounts {
balance, err := s.accountRepo.GetAccountBalance(acct.ID)
if err != nil {
return nil, err
}
result[i] = model.MoneyAccountWithBalance{
MoneyAccount: *acct,
Balance: balance,
}
}
return result, nil
}
func (s *MoneyAccountService) GetAccount(id string) (*model.MoneyAccount, error) {
return s.accountRepo.GetByID(id)
}
func (s *MoneyAccountService) UpdateAccount(dto UpdateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
account, err := s.accountRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
account.Name = name
err = s.accountRepo.Update(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) DeleteAccount(id string) error {
return s.accountRepo.Delete(id)
}
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")
}
if dto.Direction != model.TransferDirectionDeposit && dto.Direction != model.TransferDirectionWithdrawal {
return nil, fmt.Errorf("invalid transfer direction")
}
if dto.Direction == model.TransferDirectionDeposit {
if dto.Amount.GreaterThan(availableSpaceBalance) {
return nil, fmt.Errorf("insufficient available balance")
}
}
if dto.Direction == model.TransferDirectionWithdrawal {
accountBalance, err := s.accountRepo.GetAccountBalance(dto.AccountID)
if err != nil {
return nil, err
}
if dto.Amount.GreaterThan(accountBalance) {
return nil, fmt.Errorf("insufficient account balance")
}
}
transfer := &model.AccountTransfer{
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)
if err != nil {
return nil, err
}
return transfer, nil
}
func (s *MoneyAccountService) GetTransfersForAccount(accountID string) ([]*model.AccountTransfer, error) {
return s.accountRepo.GetTransfersByAccountID(accountID)
}
func (s *MoneyAccountService) DeleteTransfer(id string) error {
return s.accountRepo.DeleteTransfer(id)
}
func (s *MoneyAccountService) GetAccountBalance(accountID string) (decimal.Decimal, error) {
return s.accountRepo.GetAccountBalance(accountID)
}
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
}
const TransfersPerPage = 25
func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page int) ([]*model.AccountTransferWithAccount, int, error) {
total, err := s.accountRepo.CountTransfersBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, TransfersPerPage)
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
if err != nil {
return nil, 0, err
}
return transfers, totalPages, nil
}