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)
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"
@ -221,6 +222,7 @@ func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request)
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"))
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,
@ -769,11 +785,17 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
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

@ -14,6 +14,7 @@ const (
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
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,15 +4,18 @@ 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
allocationRepo repository.AllocationRepository
auditSvc *SpaceAuditLogService
}
@ -20,12 +23,18 @@ 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,6 +189,10 @@ type TransferInput struct {
DestAccountID string
Title string
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
Description 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)
}
// 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

@ -10,6 +10,7 @@ type AccountCardInfo struct {
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"
@ -10,8 +11,10 @@ type CreateAccountProps struct {
SpaceID string
Name string
Currency string
NameErr string
CurrencyErr string
GeneralErr string
}
@ -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.
@ -26,12 +27,14 @@ type CreateTransferProps struct {
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,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/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"
@ -17,6 +18,7 @@ type SpaceAccountPageProps struct {
AccountID string
AccountName string
AccountBalance decimal.Decimal
AccountCurrency string
RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary
@ -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

@ -13,7 +13,9 @@ type SpaceAccountSettingsPageProps struct {
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"}) {