diff --git a/internal/app/app.go b/internal/app/app.go index 1014e9a..8a71c38 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -59,6 +59,7 @@ func New(cfg *config.Config) (*App, error) { spaceService.SetAuditLogger(auditLogService) accountService := service.NewAccountService(accountRepository) accountService.SetAuditLogger(auditLogService) + accountService.SetAllocationRepository(allocationRepository) allocationService := service.NewAllocationService(allocationRepository, accountService) allocationService.SetAuditLogger(auditLogService) transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) diff --git a/internal/db/migrations/00002_create_workspace_collaboration_tables.sql b/internal/db/migrations/00002_create_workspace_collaboration_tables.sql index 1a60005..6f4840b 100644 --- a/internal/db/migrations/00002_create_workspace_collaboration_tables.sql +++ b/internal/db/migrations/00002_create_workspace_collaboration_tables.sql @@ -34,5 +34,5 @@ CREATE TABLE space_invitations ( -- +goose StatementBegin DROP TABLE space_invitations; DROP TABLE space_members; -DROP TABLE spaces; +DROP TABLE spaces CASCADE; -- +goose StatementEnd diff --git a/internal/db/migrations/00003_create_financial_management_tables.sql b/internal/db/migrations/00003_create_financial_management_tables.sql index 55a6371..c5d9ce2 100644 --- a/internal/db/migrations/00003_create_financial_management_tables.sql +++ b/internal/db/migrations/00003_create_financial_management_tables.sql @@ -40,5 +40,5 @@ CREATE TABLE transaction_tags ( DROP TABLE transaction_tags; DROP TABLE tags; DROP TABLE transactions; -DROP TABLE accounts; +DROP TABLE accounts CASCADE; -- +goose StatementEnd diff --git a/internal/db/migrations/00014_add_currency_to_accounts.sql b/internal/db/migrations/00014_add_currency_to_accounts.sql new file mode 100644 index 0000000..d581e95 --- /dev/null +++ b/internal/db/migrations/00014_add_currency_to_accounts.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE accounts ADD COLUMN currency TEXT NOT NULL DEFAULT 'CAD'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE accounts DROP COLUMN currency; +-- +goose StatementEnd diff --git a/internal/handler/space.go b/internal/handler/space.go index c030c65..82cc08b 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -9,6 +9,7 @@ import ( "time" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/misc/currency" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/routeurl" "git.juancwu.dev/juancwu/budgit/internal/service" @@ -217,10 +218,11 @@ func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request) accountCards := make([]blocks.AccountCardInfo, 0, len(accounts)) for _, a := range accounts { accountCards = append(accountCards, blocks.AccountCardInfo{ - SpaceID: space.ID, - ID: a.ID, - Name: a.Name, - Balance: a.Balance, + SpaceID: space.ID, + ID: a.ID, + Name: a.Name, + Balance: a.Balance, + Currency: a.Currency, }) } @@ -253,14 +255,27 @@ func (h *spaceHandler) SpaceCreateAccountPage(w http.ResponseWriter, r *http.Req func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") nameInput := strings.TrimSpace(r.FormValue("name")) - - formProps := forms.CreateAccountProps{ - SpaceID: spaceID, - Name: nameInput, + currencyInput := currency.Normalize(r.FormValue("currency")) + if currencyInput == "" { + currencyInput = currency.Default } + formProps := forms.CreateAccountProps{ + SpaceID: spaceID, + Name: nameInput, + Currency: currencyInput, + } + + hasErr := false if nameInput == "" { formProps.NameErr = "Account name is required." + hasErr = true + } + if !currency.IsValid(currencyInput) { + formProps.CurrencyErr = "Choose a supported currency." + hasErr = true + } + if hasErr { ui.Render(w, r, forms.CreateAccount(formProps)) return } @@ -285,7 +300,7 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques if user != nil { actorID = user.ID } - account, err := h.accountService.CreateAccount(spaceID, nameInput, actorID) + account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID) if err != nil { slog.Error("failed to create account", "error", err, "space_id", spaceID) formProps.GeneralErr = "Something went wrong. Please try again." @@ -343,6 +358,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) AccountID: accountID, AccountName: account.Name, AccountBalance: account.Balance, + AccountCurrency: account.Currency, RecentTransactions: recent, NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent), AllocationSummary: allocSummary, @@ -765,15 +781,21 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R } ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{ - SpaceID: spaceID, - SpaceName: space.Name, - AccountID: accountID, - AccountName: account.Name, + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + AccountCurrency: account.Currency, UpdateForm: forms.UpdateAccountProps{ SpaceID: spaceID, AccountID: accountID, Name: account.Name, }, + CurrencyForm: forms.ChangeAccountCurrencyProps{ + SpaceID: spaceID, + AccountID: accountID, + CurrentCurrency: account.Currency, + }, })) } @@ -836,6 +858,80 @@ func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Reques ui.Render(w, r, forms.UpdateAccount(formProps)) } +func (h *spaceHandler) HandleChangeAccountCurrency(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return + } + + newCurrencyInput := currency.Normalize(r.FormValue("new_currency")) + rateInput := strings.TrimSpace(r.FormValue("rate")) + + formProps := forms.ChangeAccountCurrencyProps{ + SpaceID: spaceID, + AccountID: accountID, + CurrentCurrency: account.Currency, + NewCurrency: newCurrencyInput, + ConversionRate: rateInput, + } + + hasErr := false + if newCurrencyInput == "" { + formProps.NewCurrencyErr = "Choose a currency." + hasErr = true + } else if !currency.IsValid(newCurrencyInput) { + formProps.NewCurrencyErr = "Choose a supported currency." + hasErr = true + } else if newCurrencyInput == account.Currency { + formProps.NewCurrencyErr = "Choose a different currency." + hasErr = true + } + + var rate decimal.Decimal + if rateInput == "" { + formProps.RateErr = "Conversion rate is required." + hasErr = true + } else { + r, err := decimal.NewFromString(rateInput) + if err != nil { + formProps.RateErr = "Enter a valid rate (e.g. 1.2345)." + hasErr = true + } else if !r.IsPositive() { + formProps.RateErr = "Rate must be greater than zero." + hasErr = true + } else { + rate = r + } + } + + if hasErr { + ui.Render(w, r, forms.ChangeAccountCurrency(formProps)) + return + } + + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if err := h.accountService.ChangeCurrency(accountID, newCurrencyInput, rate, actorID); err != nil { + slog.Error("failed to change account currency", "error", err, "account_id", accountID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.ChangeAccountCurrency(formProps)) + return + } + + formProps.CurrentCurrency = newCurrencyInput + formProps.NewCurrency = "" + formProps.ConversionRate = "" + formProps.SuccessMsg = "Currency updated. Balance and allocations were converted." + ui.Render(w, r, forms.ChangeAccountCurrency(formProps)) +} + func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") @@ -1550,6 +1646,7 @@ func (h *spaceHandler) SpaceCreateTransferPage(w http.ResponseWriter, r *http.Re Form: forms.CreateTransferProps{ SpaceID: spaceID, SourceAccountID: accountID, + SourceCurrency: account.Currency, DestAccounts: dests, SourceAvailable: allocSummary.Available.StringFixedBank(2), SourceAllocated: allocSummary.Allocated.StringFixedBank(2), @@ -1579,16 +1676,19 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque titleInput := strings.TrimSpace(r.FormValue("title")) amountInput := strings.TrimSpace(r.FormValue("amount")) destInput := strings.TrimSpace(r.FormValue("destination")) + rateInput := strings.TrimSpace(r.FormValue("rate")) dateInput := strings.TrimSpace(r.FormValue("date")) descriptionInput := strings.TrimSpace(r.FormValue("description")) formProps := forms.CreateTransferProps{ SpaceID: spaceID, SourceAccountID: accountID, + SourceCurrency: source.Currency, DestAccounts: dests, Title: titleInput, Amount: amountInput, DestAccountID: destInput, + ConversionRate: rateInput, Date: dateInput, Description: descriptionInput, } @@ -1627,6 +1727,7 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque } } + var destCurrency string if destInput == "" { formProps.DestErr = "Choose a destination account." hasErr = true @@ -1639,6 +1740,27 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque if err != nil || destAcct.SpaceID != spaceID { formProps.DestErr = "Destination account not found." hasErr = true + } else { + destCurrency = destAcct.Currency + } + } + + var rate decimal.Decimal + if destCurrency != "" && destCurrency != source.Currency { + if rateInput == "" { + formProps.RateErr = "Conversion rate is required for cross-currency transfers." + hasErr = true + } else { + r, err := decimal.NewFromString(rateInput) + if err != nil { + formProps.RateErr = "Enter a valid rate (e.g. 1.2345)." + hasErr = true + } else if !r.IsPositive() { + formProps.RateErr = "Rate must be greater than zero." + hasErr = true + } else { + rate = r + } } } @@ -1671,6 +1793,7 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque DestAccountID: destInput, Title: titleInput, Amount: amount, + ConversionRate: rate, OccurredAt: occurredAt, Description: descriptionInput, ActorID: actorID, diff --git a/internal/misc/currency/currency.go b/internal/misc/currency/currency.go new file mode 100644 index 0000000..e4a2072 --- /dev/null +++ b/internal/misc/currency/currency.go @@ -0,0 +1,51 @@ +// Package currency provides ISO 4217 currency code validation and a small +// curated list of supported codes for UI selection. +package currency + +import "strings" + +const Default = "CAD" + +// supported is the curated list of ISO 4217 codes shown in the account creation +// UI. Add codes here as users request them. Validation accepts any 3-letter +// uppercase code in this list. +var supported = []string{ + "CAD", + "USD", + "EUR", + "GBP", + "JPY", + "AUD", + "CHF", + "CNY", + "HKD", + "MXN", + "NZD", + "SGD", + "INR", + "BRL", + "KRW", + "TWD", +} + +func Supported() []string { + out := make([]string, len(supported)) + copy(out, supported) + return out +} + +// Normalize uppercases and trims the input. It does not validate. +func Normalize(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// IsValid reports whether code is a supported ISO 4217 code (case-insensitive). +func IsValid(code string) bool { + c := Normalize(code) + for _, s := range supported { + if s == c { + return true + } + } + return false +} diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index 5f875d2..bb3a961 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -11,6 +11,7 @@ type Account struct { Name string `db:"name"` SpaceID string `db:"space_id"` Balance decimal.Decimal `db:"balance"` + Currency string `db:"currency"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } diff --git a/internal/model/space_audit_log.go b/internal/model/space_audit_log.go index 90b2236..e3a7def 100644 --- a/internal/model/space_audit_log.go +++ b/internal/model/space_audit_log.go @@ -13,7 +13,8 @@ const ( SpaceAuditActionInviteCancelled SpaceAuditAction = "invite.cancelled" SpaceAuditActionAccountCreated SpaceAuditAction = "account.created" SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" - SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" + SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" + SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed" SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created" SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated" SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted" diff --git a/internal/repository/account.go b/internal/repository/account.go index a84b41c..bb1fd68 100644 --- a/internal/repository/account.go +++ b/internal/repository/account.go @@ -3,11 +3,21 @@ package repository import ( "database/sql" "errors" + "time" "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" ) +// AllocationConversion describes the converted amount/target for a single +// allocation row when an account's currency is changed. +type AllocationConversion struct { + ID string + Amount decimal.Decimal + TargetAmount *decimal.Decimal +} + var ErrAccountNotFound = errors.New("account not found") type AccountRepository interface { @@ -16,6 +26,9 @@ type AccountRepository interface { BySpaceID(spaceID string) ([]*model.Account, error) Rename(id, name string) error Delete(id string) error + // ChangeCurrency atomically updates an account's currency and balance and + // rewrites each provided allocation's amount/target in the new currency. + ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error } type accountRepository struct { @@ -27,9 +40,9 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository { } func (r *accountRepository) Create(account *model.Account) error { - query := `INSERT INTO accounts (id, name, space_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5);` - _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.CreatedAt, account.UpdatedAt) + query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6);` + _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt) return err } @@ -64,3 +77,24 @@ func (r *accountRepository) Delete(id string) error { _, err := r.db.Exec(query, id) return err } + +func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + now := time.Now() + if _, err := tx.Exec( + `UPDATE accounts SET currency = $1, balance = $2, updated_at = $3 WHERE id = $4;`, + newCurrency, newBalance, now, accountID, + ); err != nil { + return err + } + for _, c := range allocationConversions { + if _, err := tx.Exec( + `UPDATE allocations SET amount = $1, target_amount = $2, updated_at = $3 WHERE id = $4;`, + c.Amount, c.TargetAmount, now, c.ID, + ); err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b9db49a..1210b4c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -117,6 +117,7 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("/transactions/{transactionID}/activity", spaceH.SpaceTransactionActivityPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.activity") g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings") g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename") + g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency") g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete") g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create") g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create") diff --git a/internal/service/account.go b/internal/service/account.go index a1d9ca9..e18072f 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -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 { diff --git a/internal/service/account_test.go b/internal/service/account_test.go index c9e8c97..1c8e437 100644 --- a/internal/service/account_test.go +++ b/internal/service/account_test.go @@ -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)) diff --git a/internal/service/auth.go b/internal/service/auth.go index 9bd6356..e5711fb 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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) diff --git a/internal/service/transaction.go b/internal/service/transaction.go index 557836f..1bdee9a 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -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), }, }) diff --git a/internal/testutil/seed.go b/internal/testutil/seed.go index fd87e68..4d795c1 100644 --- a/internal/testutil/seed.go +++ b/internal/testutil/seed.go @@ -89,12 +89,13 @@ func CreateTestAccount(t *testing.T, db *sqlx.DB, spaceID, name string) *model.A Name: name, SpaceID: spaceID, Balance: decimal.Zero, + Currency: "CAD", CreatedAt: now, UpdatedAt: now, } _, err := db.Exec( - `INSERT INTO accounts (id, name, space_id, balance, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`, - account.ID, account.Name, account.SpaceID, account.Balance, account.CreatedAt, account.UpdatedAt, + `INSERT INTO accounts (id, name, space_id, balance, currency, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + account.ID, account.Name, account.SpaceID, account.Balance, account.Currency, account.CreatedAt, account.UpdatedAt, ) if err != nil { t.Fatalf("CreateTestAccount: %v", err) diff --git a/internal/ui/blocks/account_card.templ b/internal/ui/blocks/account_card.templ index b0b64b9..60630c8 100644 --- a/internal/ui/blocks/account_card.templ +++ b/internal/ui/blocks/account_card.templ @@ -6,10 +6,11 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" import "git.juancwu.dev/juancwu/budgit/internal/routeurl" type AccountCardInfo struct { - SpaceID string - ID string - Name string - Balance decimal.Decimal + SpaceID string + ID string + Name string + Balance decimal.Decimal + Currency string } templ AccountCard(info AccountCardInfo) { @@ -22,7 +23,7 @@ templ AccountCard(info AccountCardInfo) {
{ info.Name }
- ${ info.Balance.StringFixedBank(2) } + ${ info.Balance.StringFixedBank(2) } { info.Currency }
+ Source and destination use different currencies. Set the conversion rate. +
+ @form.Item() { + @form.Label(form.LabelProps{For: "rate"}) { + Conversion rate + } +Account Balance
+Account Balance ({ props.AccountCurrency })
} } @card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) { diff --git a/internal/ui/pages/space_account_settings.templ b/internal/ui/pages/space_account_settings.templ index c976f3b..eeed24a 100644 --- a/internal/ui/pages/space_account_settings.templ +++ b/internal/ui/pages/space_account_settings.templ @@ -9,11 +9,13 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" type SpaceAccountSettingsPageProps struct { - SpaceID string - SpaceName string - AccountID string - AccountName string - UpdateForm forms.UpdateAccountProps + SpaceID string + SpaceName string + AccountID string + AccountName string + AccountCurrency string + UpdateForm forms.UpdateAccountProps + CurrencyForm forms.ChangeAccountCurrencyProps } templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { @@ -32,6 +34,7 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { @forms.UpdateAccount(props.UpdateForm) + @forms.ChangeAccountCurrency(props.CurrencyForm) @card.Card(card.Props{Class: "rounded-sm border-destructive"}) { @card.Header() { @card.Title(card.TitleProps{Class: "text-destructive"}) {