From ca0fec563e0d76441de4e96c1259b3ad9e0b95c6 Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 4 May 2026 04:24:08 +0000 Subject: [PATCH] feat: add currency to accounts --- internal/app/app.go | 1 + ..._create_workspace_collaboration_tables.sql | 2 +- ...003_create_financial_management_tables.sql | 2 +- .../00014_add_currency_to_accounts.sql | 9 ++ internal/handler/space.go | 149 ++++++++++++++++-- internal/misc/currency/currency.go | 51 ++++++ internal/model/financial_management.go | 1 + internal/model/space_audit_log.go | 3 +- internal/repository/account.go | 40 ++++- internal/routes/routes.go | 1 + internal/service/account.go | 92 ++++++++++- internal/service/account_test.go | 4 +- internal/service/auth.go | 2 +- internal/service/transaction.go | 34 +++- internal/testutil/seed.go | 5 +- internal/ui/blocks/account_card.templ | 11 +- .../ui/forms/change_account_currency.templ | 117 ++++++++++++++ internal/ui/forms/create_account.templ | 39 ++++- internal/ui/forms/create_transfer.templ | 88 +++++++++-- internal/ui/pages/space_account.templ | 30 ++-- .../ui/pages/space_account_settings.templ | 13 +- 21 files changed, 629 insertions(+), 65 deletions(-) create mode 100644 internal/db/migrations/00014_add_currency_to_accounts.sql create mode 100644 internal/misc/currency/currency.go create mode 100644 internal/ui/forms/change_account_currency.templ 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 }

diff --git a/internal/ui/forms/change_account_currency.templ b/internal/ui/forms/change_account_currency.templ new file mode 100644 index 0000000..a9c1de3 --- /dev/null +++ b/internal/ui/forms/change_account_currency.templ @@ -0,0 +1,117 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/misc/currency" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + +type ChangeAccountCurrencyProps struct { + SpaceID string + AccountID string + CurrentCurrency string + + NewCurrency string + ConversionRate string + + NewCurrencyErr string + RateErr string + GeneralErr string + SuccessMsg string +} + +templ ChangeAccountCurrency(props ChangeAccountCurrencyProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Currency + } + @card.Description() { + Currently { props.CurrentCurrency }. Changing the currency converts the balance and every allocation at the rate you provide. + } + } + @card.Content(card.ContentProps{Class: "space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + if props.SuccessMsg != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantInfo}) { + { props.SuccessMsg } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "new_currency"}) { + New currency + } + {{ selected := props.NewCurrency }} + + if props.NewCurrencyErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.NewCurrencyErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "rate"}) { + Conversion rate + } +
+ 1 { props.CurrentCurrency } = + @input.Input(input.Props{ + ID: "rate", + Name: "rate", + Type: input.TypeNumber, + Placeholder: "1.000000", + Class: "rounded-sm", + Value: props.ConversionRate, + HasError: props.RateErr != "", + Required: true, + Attributes: templ.Attributes{ + "step": "0.000001", + "min": "0", + "inputmode": "decimal", + "autocomplete": "off", + }, + }) + new currency +
+ if props.RateErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.RateErr } + } + } + @form.Description() { + Balance and allocation amounts are multiplied by this rate and rounded to 2 decimals. + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{Type: button.TypeSubmit}) { + Change currency + } + } + } +
+} diff --git a/internal/ui/forms/create_account.templ b/internal/ui/forms/create_account.templ index 6216e2c..00c2965 100644 --- a/internal/ui/forms/create_account.templ +++ b/internal/ui/forms/create_account.templ @@ -1,5 +1,6 @@ package forms +import "git.juancwu.dev/juancwu/budgit/internal/misc/currency" import "git.juancwu.dev/juancwu/budgit/internal/routeurl" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" @@ -9,10 +10,12 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" type CreateAccountProps struct { SpaceID string - Name string + Name string + Currency string - NameErr string - GeneralErr string + NameErr string + CurrencyErr string + GeneralErr string } templ CreateAccount(props CreateAccountProps) { @@ -48,7 +51,35 @@ templ CreateAccount(props CreateAccountProps) { } } @form.Description() { - You can rename the account later. Starts with a $0.00 balance. + You can rename the account later. Starts with a 0.00 balance. + } + } + {{ + selected := props.Currency + if selected == "" { + selected = currency.Default + } + }} + @form.Item() { + @form.Label(form.LabelProps{For: "currency"}) { + Currency + } + + if props.CurrencyErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.CurrencyErr } + } } } } diff --git a/internal/ui/forms/create_transfer.templ b/internal/ui/forms/create_transfer.templ index 321be79..de81ace 100644 --- a/internal/ui/forms/create_transfer.templ +++ b/internal/ui/forms/create_transfer.templ @@ -12,6 +12,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" type CreateTransferProps struct { SpaceID string SourceAccountID string + SourceCurrency string // DestAccounts is the list of other accounts in the same space the user // can transfer to. Excludes the source account. @@ -19,19 +20,21 @@ type CreateTransferProps struct { // SourceAvailable / SourceAllocated are the source account's unallocated // and allocated cash, formatted as plain decimal strings (e.g. "1234.50"). - SourceAvailable string - SourceAllocated string - SourceOverflow bool + SourceAvailable string + SourceAllocated string + SourceOverflow bool - Title string - Amount string - DestAccountID string - Date string - Description string + Title string + Amount string + DestAccountID string + ConversionRate string + Date string + Description string TitleErr string AmountErr string DestErr string + RateErr string DateErr string GeneralErr string } @@ -104,10 +107,26 @@ templ CreateTransfer(props CreateTransferProps) { templ.KV("border-destructive", props.DestErr != ""), templ.KV("border-input", props.DestErr == "") } required + data-source-currency={ props.SourceCurrency } + _="on change + set opt to my.options[my.selectedIndex] + set destCur to opt.getAttribute('data-currency') or '' + set srcCur to @data-source-currency of me + if destCur is not '' and destCur is not srcCur + remove .hidden from #rate-row + set #rate-dest-currency.innerText to destCur + set #rate-source-currency.innerText to srcCur + else + add .hidden to #rate-row + end" > for _, a := range props.DestAccounts { - + } } @@ -167,6 +186,57 @@ templ CreateTransfer(props CreateTransferProps) { } } + {{ + selectedDestCurrency := "" + for _, a := range props.DestAccounts { + if a.ID == props.DestAccountID { + selectedDestCurrency = a.Currency + break + } + } + rateRowHidden := selectedDestCurrency == "" || selectedDestCurrency == props.SourceCurrency + rateRowClasses := []string{"space-y-2 rounded-md border p-4 bg-muted/30"} + if rateRowHidden { + rateRowClasses = append(rateRowClasses, "hidden") + } + }} +
+

+ Source and destination use different currencies. Set the conversion rate. +

+ @form.Item() { + @form.Label(form.LabelProps{For: "rate"}) { + Conversion rate + } +
+ 1 { props.SourceCurrency } = + @input.Input(input.Props{ + ID: "rate", + Name: "rate", + Type: input.TypeNumber, + Placeholder: "1.00", + Class: "rounded-sm", + Value: props.ConversionRate, + HasError: props.RateErr != "", + Attributes: templ.Attributes{ + "step": "0.000001", + "min": "0", + "inputmode": "decimal", + "autocomplete": "off", + }, + }) + { selectedDestCurrency } +
+ if props.RateErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.RateErr } + } + } + @form.Description() { + The destination account will be credited the converted amount, rounded to 2 decimals. + } + } +
@form.Item() { @form.Label(form.LabelProps{For: "description"}) { Description diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 7c499a1..f80c152 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -6,20 +6,22 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl" import "git.juancwu.dev/juancwu/budgit/internal/service" import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" type SpaceAccountPageProps struct { - SpaceID string - SpaceName string - AccountID string - AccountName string - AccountBalance decimal.Decimal - RecentTransactions []*model.Transaction + SpaceID string + SpaceName string + AccountID string + AccountName string + AccountBalance decimal.Decimal + AccountCurrency string + RecentTransactions []*model.Transaction NonEditableTransactionIDs map[string]bool - AllocationSummary *service.AllocationSummary + AllocationSummary *service.AllocationSummary } templ SpaceAccountPage(props SpaceAccountPageProps) { @@ -39,12 +41,20 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { }) { @card.Header() { @card.Title() { - { props.AccountName } +
+ { props.AccountName } + @badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs font-medium"}) { + { props.AccountCurrency } + } +
} } @card.Content() { -

${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }

-

Account Balance

+

+ ${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) } + { props.AccountCurrency } +

+

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"}) { -- 2.43.0