Merge pull request 'feat: add currency to accounts' (#9) from add-currency-to-accounts into main

Reviewed-on: #9
This commit is contained in:
Juan Wu 2026-05-04 04:26:40 +00:00
commit f0a309ea20
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)
accountService := service.NewAccountService(accountRepository)
accountService.SetAuditLogger(auditLogService)
accountService.SetAllocationRepository(allocationRepository)
allocationService := service.NewAllocationService(allocationRepository, accountService)
allocationService.SetAuditLogger(auditLogService)
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)

View file

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

View file

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

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"
"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,

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"`
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"`
}

View file

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

View file

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

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("/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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
<div>
<p class="font-semibold">{ info.Name }</p>
<p class="text-xs text-muted-foreground">
${ info.Balance.StringFixedBank(2) }
${ info.Balance.StringFixedBank(2) } { info.Currency }
</p>
</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
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
}
<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 {
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"
>
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
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>
}
@ -167,6 +186,57 @@ templ CreateTransfer(props CreateTransferProps) {
}
}
</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.Label(form.LabelProps{For: "description"}) {
Description

View file

@ -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 }
<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() {
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
<p class="text-sm text-muted-foreground">Account Balance</p>
<h1 class={ utils.TwMerge(balanceTextClasses...) }>
${ 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"}) {

View file

@ -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) {
</p>
</div>
@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"}) {