feat: add currency to accounts
This commit is contained in:
parent
4be5385db7
commit
ca0fec563e
21 changed files with 627 additions and 63 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue