feat: add currency to accounts

This commit is contained in:
juancwu 2026-05-04 04:24:08 +00:00
commit ca0fec563e
21 changed files with 627 additions and 63 deletions

View file

@ -4,28 +4,37 @@ import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/misc/currency"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
const DefaultAccountName = "Money Account"
type AccountService struct {
accountRepo repository.AccountRepository
auditSvc *SpaceAuditLogService
accountRepo repository.AccountRepository
allocationRepo repository.AllocationRepository
auditSvc *SpaceAuditLogService
}
func NewAccountService(accountRepo repository.AccountRepository) *AccountService {
return &AccountService{accountRepo: accountRepo}
}
// SetAllocationRepository wires the allocation repository after construction.
// Required for currency conversion to rewrite allocation amounts atomically.
func (s *AccountService) SetAllocationRepository(repo repository.AllocationRepository) {
s.allocationRepo = repo
}
// SetAuditLogger wires the audit log service after construction.
func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit
}
func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Account, error) {
func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) {
if spaceID == "" {
return nil, fmt.Errorf("space id is required")
}
@ -33,11 +42,20 @@ func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Ac
return nil, fmt.Errorf("account name cannot be empty")
}
code := currency.Normalize(currencyCode)
if code == "" {
code = currency.Default
}
if !currency.IsValid(code) {
return nil, fmt.Errorf("unsupported currency code: %s", currencyCode)
}
now := time.Now()
account := &model.Account{
ID: uuid.NewString(),
Name: name,
SpaceID: spaceID,
Currency: code,
CreatedAt: now,
UpdatedAt: now,
}
@ -51,6 +69,7 @@ func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Ac
Metadata: map[string]any{
"account_id": account.ID,
"account_name": account.Name,
"currency": account.Currency,
},
})
return account, nil
@ -118,6 +137,73 @@ func (s *AccountService) DeleteAccount(id, actorID string) error {
return nil
}
// ChangeCurrency converts the account's currency. Every value held in the old
// currency (account balance, allocation amounts and targets) is multiplied by
// rate and rounded to 2 decimals. The whole change is applied in a single SQL
// transaction so the account never appears in a half-converted state.
//
// rate is "1 oldCurrency = rate newCurrency". Same-currency changes are
// rejected; callers should treat rate as required and positive.
func (s *AccountService) ChangeCurrency(accountID, newCurrencyCode string, rate decimal.Decimal, actorID string) error {
if accountID == "" {
return fmt.Errorf("account id is required")
}
code := currency.Normalize(newCurrencyCode)
if !currency.IsValid(code) {
return fmt.Errorf("unsupported currency code: %s", newCurrencyCode)
}
if !rate.IsPositive() {
return fmt.Errorf("conversion rate must be greater than zero")
}
account, err := s.accountRepo.ByID(accountID)
if err != nil {
return fmt.Errorf("failed to load account: %w", err)
}
if account.Currency == code {
return fmt.Errorf("account is already in %s", code)
}
allocations, err := s.allocationRepo.ByAccountID(accountID)
if err != nil {
return fmt.Errorf("failed to load allocations: %w", err)
}
newBalance := account.Balance.Mul(rate).Round(2)
conversions := make([]repository.AllocationConversion, 0, len(allocations))
for _, a := range allocations {
c := repository.AllocationConversion{
ID: a.ID,
Amount: a.Amount.Mul(rate).Round(2),
}
if a.TargetAmount != nil {
t := a.TargetAmount.Mul(rate).Round(2)
c.TargetAmount = &t
}
conversions = append(conversions, c)
}
if err := s.accountRepo.ChangeCurrency(accountID, code, newBalance, conversions); err != nil {
return fmt.Errorf("failed to change currency: %w", err)
}
s.auditSvc.Record(RecordOptions{
SpaceID: account.SpaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountCurrencyChanged,
Metadata: map[string]any{
"account_id": accountID,
"account_name": account.Name,
"old_currency": account.Currency,
"new_currency": code,
"conversion_rate": rate.String(),
"old_balance": account.Balance.StringFixedBank(2),
"new_balance": newBalance.StringFixedBank(2),
},
})
return nil
}
func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) {
accounts, err := s.accountRepo.BySpaceID(spaceID)
if err != nil {

View file

@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "Checking", user.ID)
account, err := svc.CreateAccount(space.ID, "Checking", "CAD", user.ID)
require.NoError(t, err)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
@ -104,7 +104,7 @@ func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "x", user.ID)
account, err := svc.CreateAccount(space.ID, "x", "", user.ID)
require.NoError(t, err)
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))

View file

@ -363,7 +363,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return fmt.Errorf("failed to create onboarding space: %w", err)
}
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, userID); err != nil {
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, "", userID); err != nil {
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr)

View file

@ -189,9 +189,13 @@ type TransferInput struct {
DestAccountID string
Title string
Amount decimal.Decimal
OccurredAt time.Time
Description string
ActorID string
// ConversionRate is the rate that converts one unit of the source currency
// into the destination currency. Required when source and destination
// accounts have different currencies; ignored otherwise. Must be positive.
ConversionRate decimal.Decimal
OccurredAt time.Time
Description string
ActorID string
}
// TransferResult is what the service returns after a successful transfer — both
@ -235,6 +239,18 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
return nil, fmt.Errorf("failed to load destination account: %w", err)
}
// Cross-currency transfers require a conversion rate; same-currency
// transfers ignore it (or, for symmetry, accept rate=1).
destAmount := input.Amount
rate := decimal.NewFromInt(1)
if source.Currency != dest.Currency {
if !input.ConversionRate.IsPositive() {
return nil, fmt.Errorf("conversion rate is required when transferring between accounts of different currencies")
}
rate = input.ConversionRate
destAmount = input.Amount.Mul(rate).Round(2)
}
now := time.Now()
var description *string
if d := strings.TrimSpace(input.Description); d != "" {
@ -254,7 +270,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
}
deposit := &model.Transaction{
ID: uuid.NewString(),
Value: input.Amount,
Value: destAmount,
Type: model.TransactionTypeDeposit,
AccountID: dest.ID,
Title: title,
@ -265,7 +281,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
}
sourceNewBalance := source.Balance.Sub(input.Amount)
destNewBalance := dest.Balance.Add(input.Amount)
destNewBalance := dest.Balance.Add(destAmount)
if err := s.transactionRepo.TransferAtomic(withdrawal, deposit, sourceNewBalance, destNewBalance); err != nil {
return nil, fmt.Errorf("failed to record transfer: %w", err)
@ -287,6 +303,10 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
"transfer_pair_id": deposit.ID,
"transfer_other_acct": deposit.AccountID,
"transfer_other_name": dest.Name,
"source_currency": source.Currency,
"dest_currency": dest.Currency,
"conversion_rate": rate.String(),
"dest_amount": destAmount.StringFixedBank(2),
},
})
s.auditSvc.Record(TransactionRecordOptions{
@ -302,6 +322,10 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
"transfer_pair_id": withdrawal.ID,
"transfer_other_acct": withdrawal.AccountID,
"transfer_other_name": source.Name,
"source_currency": source.Currency,
"dest_currency": dest.Currency,
"conversion_rate": rate.String(),
"source_amount": input.Amount.StringFixedBank(2),
},
})