Merge pull request 'feat: add currency to accounts' (#9) from add-currency-to-accounts into main
Reviewed-on: #9
This commit is contained in:
commit
f0a309ea20
21 changed files with 627 additions and 63 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
51
internal/misc/currency/currency.go
Normal file
51
internal/misc/currency/currency.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
117
internal/ui/forms/change_account_currency.templ
Normal file
117
internal/ui/forms/change_account_currency.templ
Normal 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>
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}) {
|
||||
|
|
|
|||
|
|
@ -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"}) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue