feat: add currency to accounts #9

Merged
juancwu merged 1 commit from add-currency-to-accounts into main 2026-05-04 04:26:40 +00:00
21 changed files with 627 additions and 63 deletions

View file

@ -59,6 +59,7 @@ func New(cfg *config.Config) (*App, error) {
spaceService.SetAuditLogger(auditLogService) spaceService.SetAuditLogger(auditLogService)
accountService := service.NewAccountService(accountRepository) accountService := service.NewAccountService(accountRepository)
accountService.SetAuditLogger(auditLogService) accountService.SetAuditLogger(auditLogService)
accountService.SetAllocationRepository(allocationRepository)
allocationService := service.NewAllocationService(allocationRepository, accountService) allocationService := service.NewAllocationService(allocationRepository, accountService)
allocationService.SetAuditLogger(auditLogService) allocationService.SetAuditLogger(auditLogService)
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)

View file

@ -34,5 +34,5 @@ CREATE TABLE space_invitations (
-- +goose StatementBegin -- +goose StatementBegin
DROP TABLE space_invitations; DROP TABLE space_invitations;
DROP TABLE space_members; DROP TABLE space_members;
DROP TABLE spaces; DROP TABLE spaces CASCADE;
-- +goose StatementEnd -- +goose StatementEnd

View file

@ -40,5 +40,5 @@ CREATE TABLE transaction_tags (
DROP TABLE transaction_tags; DROP TABLE transaction_tags;
DROP TABLE tags; DROP TABLE tags;
DROP TABLE transactions; DROP TABLE transactions;
DROP TABLE accounts; DROP TABLE accounts CASCADE;
-- +goose StatementEnd -- +goose StatementEnd

View file

@ -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

View file

@ -9,6 +9,7 @@ import (
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "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/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl" "git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
@ -221,6 +222,7 @@ func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request)
ID: a.ID, ID: a.ID,
Name: a.Name, Name: a.Name,
Balance: a.Balance, 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) { func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
nameInput := strings.TrimSpace(r.FormValue("name")) nameInput := strings.TrimSpace(r.FormValue("name"))
currencyInput := currency.Normalize(r.FormValue("currency"))
if currencyInput == "" {
currencyInput = currency.Default
}
formProps := forms.CreateAccountProps{ formProps := forms.CreateAccountProps{
SpaceID: spaceID, SpaceID: spaceID,
Name: nameInput, Name: nameInput,
Currency: currencyInput,
} }
hasErr := false
if nameInput == "" { if nameInput == "" {
formProps.NameErr = "Account name is required." 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)) ui.Render(w, r, forms.CreateAccount(formProps))
return return
} }
@ -285,7 +300,7 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
if user != nil { if user != nil {
actorID = user.ID actorID = user.ID
} }
account, err := h.accountService.CreateAccount(spaceID, nameInput, actorID) account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID)
if err != nil { if err != nil {
slog.Error("failed to create account", "error", err, "space_id", spaceID) slog.Error("failed to create account", "error", err, "space_id", spaceID)
formProps.GeneralErr = "Something went wrong. Please try again." formProps.GeneralErr = "Something went wrong. Please try again."
@ -343,6 +358,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
AccountID: accountID, AccountID: accountID,
AccountName: account.Name, AccountName: account.Name,
AccountBalance: account.Balance, AccountBalance: account.Balance,
AccountCurrency: account.Currency,
RecentTransactions: recent, RecentTransactions: recent,
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent), NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
AllocationSummary: allocSummary, AllocationSummary: allocSummary,
@ -769,11 +785,17 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
SpaceName: space.Name, SpaceName: space.Name,
AccountID: accountID, AccountID: accountID,
AccountName: account.Name, AccountName: account.Name,
AccountCurrency: account.Currency,
UpdateForm: forms.UpdateAccountProps{ UpdateForm: forms.UpdateAccountProps{
SpaceID: spaceID, SpaceID: spaceID,
AccountID: accountID, AccountID: accountID,
Name: account.Name, 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)) 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) { func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID") accountID := r.PathValue("accountID")
@ -1550,6 +1646,7 @@ func (h *spaceHandler) SpaceCreateTransferPage(w http.ResponseWriter, r *http.Re
Form: forms.CreateTransferProps{ Form: forms.CreateTransferProps{
SpaceID: spaceID, SpaceID: spaceID,
SourceAccountID: accountID, SourceAccountID: accountID,
SourceCurrency: account.Currency,
DestAccounts: dests, DestAccounts: dests,
SourceAvailable: allocSummary.Available.StringFixedBank(2), SourceAvailable: allocSummary.Available.StringFixedBank(2),
SourceAllocated: allocSummary.Allocated.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")) titleInput := strings.TrimSpace(r.FormValue("title"))
amountInput := strings.TrimSpace(r.FormValue("amount")) amountInput := strings.TrimSpace(r.FormValue("amount"))
destInput := strings.TrimSpace(r.FormValue("destination")) destInput := strings.TrimSpace(r.FormValue("destination"))
rateInput := strings.TrimSpace(r.FormValue("rate"))
dateInput := strings.TrimSpace(r.FormValue("date")) dateInput := strings.TrimSpace(r.FormValue("date"))
descriptionInput := strings.TrimSpace(r.FormValue("description")) descriptionInput := strings.TrimSpace(r.FormValue("description"))
formProps := forms.CreateTransferProps{ formProps := forms.CreateTransferProps{
SpaceID: spaceID, SpaceID: spaceID,
SourceAccountID: accountID, SourceAccountID: accountID,
SourceCurrency: source.Currency,
DestAccounts: dests, DestAccounts: dests,
Title: titleInput, Title: titleInput,
Amount: amountInput, Amount: amountInput,
DestAccountID: destInput, DestAccountID: destInput,
ConversionRate: rateInput,
Date: dateInput, Date: dateInput,
Description: descriptionInput, Description: descriptionInput,
} }
@ -1627,6 +1727,7 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
} }
} }
var destCurrency string
if destInput == "" { if destInput == "" {
formProps.DestErr = "Choose a destination account." formProps.DestErr = "Choose a destination account."
hasErr = true hasErr = true
@ -1639,6 +1740,27 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
if err != nil || destAcct.SpaceID != spaceID { if err != nil || destAcct.SpaceID != spaceID {
formProps.DestErr = "Destination account not found." formProps.DestErr = "Destination account not found."
hasErr = true 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, DestAccountID: destInput,
Title: titleInput, Title: titleInput,
Amount: amount, Amount: amount,
ConversionRate: rate,
OccurredAt: occurredAt, OccurredAt: occurredAt,
Description: descriptionInput, Description: descriptionInput,
ActorID: actorID, ActorID: actorID,

View file

@ -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
}

View file

@ -11,6 +11,7 @@ type Account struct {
Name string `db:"name"` Name string `db:"name"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
Balance decimal.Decimal `db:"balance"` Balance decimal.Decimal `db:"balance"`
Currency string `db:"currency"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }

View file

@ -14,6 +14,7 @@ const (
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created" SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed"
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created" SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated" SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted" SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"

View file

@ -3,11 +3,21 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx" "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") var ErrAccountNotFound = errors.New("account not found")
type AccountRepository interface { type AccountRepository interface {
@ -16,6 +26,9 @@ type AccountRepository interface {
BySpaceID(spaceID string) ([]*model.Account, error) BySpaceID(spaceID string) ([]*model.Account, error)
Rename(id, name string) error Rename(id, name string) error
Delete(id 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 { type accountRepository struct {
@ -27,9 +40,9 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
} }
func (r *accountRepository) Create(account *model.Account) error { func (r *accountRepository) Create(account *model.Account) error {
query := `INSERT INTO accounts (id, name, space_id, created_at, updated_at) query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5);` VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.CreatedAt, account.UpdatedAt) _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt)
return err return err
} }
@ -64,3 +77,24 @@ func (r *accountRepository) Delete(id string) error {
_, err := r.db.Exec(query, id) _, err := r.db.Exec(query, id)
return err 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
})
}

View file

@ -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("/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.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/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.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.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") g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")

View file

@ -4,15 +4,18 @@ import (
"fmt" "fmt"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/misc/currency"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal"
) )
const DefaultAccountName = "Money Account" const DefaultAccountName = "Money Account"
type AccountService struct { type AccountService struct {
accountRepo repository.AccountRepository accountRepo repository.AccountRepository
allocationRepo repository.AllocationRepository
auditSvc *SpaceAuditLogService auditSvc *SpaceAuditLogService
} }
@ -20,12 +23,18 @@ func NewAccountService(accountRepo repository.AccountRepository) *AccountService
return &AccountService{accountRepo: accountRepo} 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. // SetAuditLogger wires the audit log service after construction.
func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) { func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit 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 == "" { if spaceID == "" {
return nil, fmt.Errorf("space id is required") 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") 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() now := time.Now()
account := &model.Account{ account := &model.Account{
ID: uuid.NewString(), ID: uuid.NewString(),
Name: name, Name: name,
SpaceID: spaceID, SpaceID: spaceID,
Currency: code,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@ -51,6 +69,7 @@ func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Ac
Metadata: map[string]any{ Metadata: map[string]any{
"account_id": account.ID, "account_id": account.ID,
"account_name": account.Name, "account_name": account.Name,
"currency": account.Currency,
}, },
}) })
return account, nil return account, nil
@ -118,6 +137,73 @@ func (s *AccountService) DeleteAccount(id, actorID string) error {
return nil 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) { func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) {
accounts, err := s.accountRepo.BySpaceID(spaceID) accounts, err := s.accountRepo.BySpaceID(spaceID)
if err != nil { 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) user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") 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) require.NoError(t, err)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0) 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) user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") 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, err)
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID)) require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
require.NoError(t, svc.DeleteAccount(account.ID, 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) 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 { if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error", slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr) "space_id", space.ID, "error", delErr)

View file

@ -189,6 +189,10 @@ type TransferInput struct {
DestAccountID string DestAccountID string
Title string Title string
Amount decimal.Decimal Amount decimal.Decimal
// 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 OccurredAt time.Time
Description string Description string
ActorID string ActorID string
@ -235,6 +239,18 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
return nil, fmt.Errorf("failed to load destination account: %w", 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() now := time.Now()
var description *string var description *string
if d := strings.TrimSpace(input.Description); d != "" { if d := strings.TrimSpace(input.Description); d != "" {
@ -254,7 +270,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
} }
deposit := &model.Transaction{ deposit := &model.Transaction{
ID: uuid.NewString(), ID: uuid.NewString(),
Value: input.Amount, Value: destAmount,
Type: model.TransactionTypeDeposit, Type: model.TransactionTypeDeposit,
AccountID: dest.ID, AccountID: dest.ID,
Title: title, Title: title,
@ -265,7 +281,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
} }
sourceNewBalance := source.Balance.Sub(input.Amount) 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 { if err := s.transactionRepo.TransferAtomic(withdrawal, deposit, sourceNewBalance, destNewBalance); err != nil {
return nil, fmt.Errorf("failed to record transfer: %w", err) 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_pair_id": deposit.ID,
"transfer_other_acct": deposit.AccountID, "transfer_other_acct": deposit.AccountID,
"transfer_other_name": dest.Name, "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{ s.auditSvc.Record(TransactionRecordOptions{
@ -302,6 +322,10 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
"transfer_pair_id": withdrawal.ID, "transfer_pair_id": withdrawal.ID,
"transfer_other_acct": withdrawal.AccountID, "transfer_other_acct": withdrawal.AccountID,
"transfer_other_name": source.Name, "transfer_other_name": source.Name,
"source_currency": source.Currency,
"dest_currency": dest.Currency,
"conversion_rate": rate.String(),
"source_amount": input.Amount.StringFixedBank(2),
}, },
}) })

View file

@ -89,12 +89,13 @@ func CreateTestAccount(t *testing.T, db *sqlx.DB, spaceID, name string) *model.A
Name: name, Name: name,
SpaceID: spaceID, SpaceID: spaceID,
Balance: decimal.Zero, Balance: decimal.Zero,
Currency: "CAD",
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO accounts (id, name, space_id, balance, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`, `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.CreatedAt, account.UpdatedAt, account.ID, account.Name, account.SpaceID, account.Balance, account.Currency, account.CreatedAt, account.UpdatedAt,
) )
if err != nil { if err != nil {
t.Fatalf("CreateTestAccount: %v", err) t.Fatalf("CreateTestAccount: %v", err)

View file

@ -10,6 +10,7 @@ type AccountCardInfo struct {
ID string ID string
Name string Name string
Balance decimal.Decimal Balance decimal.Decimal
Currency string
} }
templ AccountCard(info AccountCardInfo) { templ AccountCard(info AccountCardInfo) {
@ -22,7 +23,7 @@ templ AccountCard(info AccountCardInfo) {
<div> <div>
<p class="font-semibold">{ info.Name }</p> <p class="font-semibold">{ info.Name }</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
${ info.Balance.StringFixedBank(2) } ${ info.Balance.StringFixedBank(2) } { info.Currency }
</p> </p>
</div> </div>
</div> </div>

View file

@ -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) {
<form
id="change-currency-form"
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.settings.currency", "spaceID", props.SpaceID, "accountID", props.AccountID) }
hx-swap="outerHTML"
>
@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 }}
<select
id="new_currency"
name="new_currency"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.NewCurrencyErr != ""),
templ.KV("border-input", props.NewCurrencyErr == "") }
required
>
<option value="" selected?={ selected == "" }>Select a currency…</option>
for _, code := range currency.Supported() {
if code != props.CurrentCurrency {
<option value={ code } selected?={ selected == code }>{ code }</option>
}
}
</select>
if props.NewCurrencyErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.NewCurrencyErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "rate"}) {
Conversion rate
}
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 { props.CurrentCurrency } =</span>
@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",
},
})
<span class="text-sm text-muted-foreground">new currency</span>
</div>
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
}
}
}
</form>
}

View file

@ -1,5 +1,6 @@
package forms 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/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" 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/card"
@ -10,8 +11,10 @@ type CreateAccountProps struct {
SpaceID string SpaceID string
Name string Name string
Currency string
NameErr string NameErr string
CurrencyErr string
GeneralErr string GeneralErr string
} }
@ -48,7 +51,35 @@ templ CreateAccount(props CreateAccountProps) {
} }
} }
@form.Description() { @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
}
<select
id="currency"
name="currency"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.CurrencyErr != ""),
templ.KV("border-input", props.CurrencyErr == "") }
required
>
for _, code := range currency.Supported() {
<option value={ code } selected?={ selected == code }>{ code }</option>
}
</select>
if props.CurrencyErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.CurrencyErr }
}
} }
} }
} }

View file

@ -12,6 +12,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type CreateTransferProps struct { type CreateTransferProps struct {
SpaceID string SpaceID string
SourceAccountID string SourceAccountID string
SourceCurrency string
// DestAccounts is the list of other accounts in the same space the user // DestAccounts is the list of other accounts in the same space the user
// can transfer to. Excludes the source account. // can transfer to. Excludes the source account.
@ -26,12 +27,14 @@ type CreateTransferProps struct {
Title string Title string
Amount string Amount string
DestAccountID string DestAccountID string
ConversionRate string
Date string Date string
Description string Description string
TitleErr string TitleErr string
AmountErr string AmountErr string
DestErr string DestErr string
RateErr string
DateErr string DateErr string
GeneralErr string GeneralErr string
} }
@ -104,10 +107,26 @@ templ CreateTransfer(props CreateTransferProps) {
templ.KV("border-destructive", props.DestErr != ""), templ.KV("border-destructive", props.DestErr != ""),
templ.KV("border-input", props.DestErr == "") } templ.KV("border-input", props.DestErr == "") }
required 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"
> >
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option> <option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
for _, a := range props.DestAccounts { for _, a := range props.DestAccounts {
<option value={ a.ID } selected?={ props.DestAccountID == a.ID }>{ a.Name }</option> <option
value={ a.ID }
data-currency={ a.Currency }
selected?={ props.DestAccountID == a.ID }
>{ a.Name } ({ a.Currency })</option>
} }
</select> </select>
} }
@ -167,6 +186,57 @@ templ CreateTransfer(props CreateTransferProps) {
} }
} }
</div> </div>
{{
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")
}
}}
<div id="rate-row" class={ utils.TwMerge(rateRowClasses...) }>
<p class="text-sm">
Source and destination use different currencies. Set the conversion rate.
</p>
@form.Item() {
@form.Label(form.LabelProps{For: "rate"}) {
Conversion rate
}
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 <span id="rate-source-currency">{ props.SourceCurrency }</span> =</span>
@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",
},
})
<span class="text-sm text-muted-foreground" id="rate-dest-currency">{ selectedDestCurrency }</span>
</div>
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.
}
}
</div>
@form.Item() { @form.Item() {
@form.Label(form.LabelProps{For: "description"}) { @form.Label(form.LabelProps{For: "description"}) {
Description Description

View file

@ -6,6 +6,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/service" 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/blocks"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" 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/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" 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/components/icon"
@ -17,6 +18,7 @@ type SpaceAccountPageProps struct {
AccountID string AccountID string
AccountName string AccountName string
AccountBalance decimal.Decimal AccountBalance decimal.Decimal
AccountCurrency string
RecentTransactions []*model.Transaction RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary AllocationSummary *service.AllocationSummary
@ -39,12 +41,20 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}) { }) {
@card.Header() { @card.Header() {
@card.Title() { @card.Title() {
{ props.AccountName } <div class="flex items-center gap-3">
<span>{ props.AccountName }</span>
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs font-medium"}) {
{ props.AccountCurrency }
}
</div>
} }
} }
@card.Content() { @card.Content() {
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1> <h1 class={ utils.TwMerge(balanceTextClasses...) }>
<p class="text-sm text-muted-foreground">Account Balance</p> ${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }
<span class="text-xl font-semibold text-muted-foreground ml-2">{ props.AccountCurrency }</span>
</h1>
<p class="text-sm text-muted-foreground">Account Balance ({ props.AccountCurrency })</p>
} }
} }
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) { @card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {

View file

@ -13,7 +13,9 @@ type SpaceAccountSettingsPageProps struct {
SpaceName string SpaceName string
AccountID string AccountID string
AccountName string AccountName string
AccountCurrency string
UpdateForm forms.UpdateAccountProps UpdateForm forms.UpdateAccountProps
CurrencyForm forms.ChangeAccountCurrencyProps
} }
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
@ -32,6 +34,7 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
</p> </p>
</div> </div>
@forms.UpdateAccount(props.UpdateForm) @forms.UpdateAccount(props.UpdateForm)
@forms.ChangeAccountCurrency(props.CurrencyForm)
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) { @card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
@card.Header() { @card.Header() {
@card.Title(card.TitleProps{Class: "text-destructive"}) { @card.Title(card.TitleProps{Class: "text-destructive"}) {