feat: add currency to accounts #9
21 changed files with 627 additions and 63 deletions
|
|
@ -59,6 +59,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
spaceService.SetAuditLogger(auditLogService)
|
spaceService.SetAuditLogger(auditLogService)
|
||||||
accountService := service.NewAccountService(accountRepository)
|
accountService := service.NewAccountService(accountRepository)
|
||||||
accountService.SetAuditLogger(auditLogService)
|
accountService.SetAuditLogger(auditLogService)
|
||||||
|
accountService.SetAllocationRepository(allocationRepository)
|
||||||
allocationService := service.NewAllocationService(allocationRepository, accountService)
|
allocationService := service.NewAllocationService(allocationRepository, accountService)
|
||||||
allocationService.SetAuditLogger(auditLogService)
|
allocationService.SetAuditLogger(auditLogService)
|
||||||
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,5 @@ CREATE TABLE space_invitations (
|
||||||
-- +goose StatementBegin
|
-- +goose StatementBegin
|
||||||
DROP TABLE space_invitations;
|
DROP TABLE space_invitations;
|
||||||
DROP TABLE space_members;
|
DROP TABLE space_members;
|
||||||
DROP TABLE spaces;
|
DROP TABLE spaces CASCADE;
|
||||||
-- +goose StatementEnd
|
-- +goose StatementEnd
|
||||||
|
|
|
||||||
|
|
@ -40,5 +40,5 @@ CREATE TABLE transaction_tags (
|
||||||
DROP TABLE transaction_tags;
|
DROP TABLE transaction_tags;
|
||||||
DROP TABLE tags;
|
DROP TABLE tags;
|
||||||
DROP TABLE transactions;
|
DROP TABLE transactions;
|
||||||
DROP TABLE accounts;
|
DROP TABLE accounts CASCADE;
|
||||||
-- +goose StatementEnd
|
-- +goose StatementEnd
|
||||||
|
|
|
||||||
|
|
@ -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"
|
"time"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/misc/currency"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
|
|
@ -221,6 +222,7 @@ func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request)
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
Balance: a.Balance,
|
Balance: a.Balance,
|
||||||
|
Currency: a.Currency,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,14 +255,27 @@ func (h *spaceHandler) SpaceCreateAccountPage(w http.ResponseWriter, r *http.Req
|
||||||
func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
nameInput := strings.TrimSpace(r.FormValue("name"))
|
nameInput := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
currencyInput := currency.Normalize(r.FormValue("currency"))
|
||||||
|
if currencyInput == "" {
|
||||||
|
currencyInput = currency.Default
|
||||||
|
}
|
||||||
|
|
||||||
formProps := forms.CreateAccountProps{
|
formProps := forms.CreateAccountProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
Name: nameInput,
|
Name: nameInput,
|
||||||
|
Currency: currencyInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasErr := false
|
||||||
if nameInput == "" {
|
if nameInput == "" {
|
||||||
formProps.NameErr = "Account name is required."
|
formProps.NameErr = "Account name is required."
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
|
if !currency.IsValid(currencyInput) {
|
||||||
|
formProps.CurrencyErr = "Choose a supported currency."
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
|
if hasErr {
|
||||||
ui.Render(w, r, forms.CreateAccount(formProps))
|
ui.Render(w, r, forms.CreateAccount(formProps))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +300,7 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
|
||||||
if user != nil {
|
if user != nil {
|
||||||
actorID = user.ID
|
actorID = user.ID
|
||||||
}
|
}
|
||||||
account, err := h.accountService.CreateAccount(spaceID, nameInput, actorID)
|
account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create account", "error", err, "space_id", spaceID)
|
slog.Error("failed to create account", "error", err, "space_id", spaceID)
|
||||||
formProps.GeneralErr = "Something went wrong. Please try again."
|
formProps.GeneralErr = "Something went wrong. Please try again."
|
||||||
|
|
@ -343,6 +358,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
AccountName: account.Name,
|
AccountName: account.Name,
|
||||||
AccountBalance: account.Balance,
|
AccountBalance: account.Balance,
|
||||||
|
AccountCurrency: account.Currency,
|
||||||
RecentTransactions: recent,
|
RecentTransactions: recent,
|
||||||
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
||||||
AllocationSummary: allocSummary,
|
AllocationSummary: allocSummary,
|
||||||
|
|
@ -769,11 +785,17 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
|
||||||
SpaceName: space.Name,
|
SpaceName: space.Name,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
AccountName: account.Name,
|
AccountName: account.Name,
|
||||||
|
AccountCurrency: account.Currency,
|
||||||
UpdateForm: forms.UpdateAccountProps{
|
UpdateForm: forms.UpdateAccountProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: account.Name,
|
Name: account.Name,
|
||||||
},
|
},
|
||||||
|
CurrencyForm: forms.ChangeAccountCurrencyProps{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
AccountID: accountID,
|
||||||
|
CurrentCurrency: account.Currency,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -836,6 +858,80 @@ func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Reques
|
||||||
ui.Render(w, r, forms.UpdateAccount(formProps))
|
ui.Render(w, r, forms.UpdateAccount(formProps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *spaceHandler) HandleChangeAccountCurrency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
accountID := r.PathValue("accountID")
|
||||||
|
|
||||||
|
account, err := h.accountService.GetAccount(accountID)
|
||||||
|
if err != nil || account.SpaceID != spaceID {
|
||||||
|
ui.RenderError(w, r, "Account not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newCurrencyInput := currency.Normalize(r.FormValue("new_currency"))
|
||||||
|
rateInput := strings.TrimSpace(r.FormValue("rate"))
|
||||||
|
|
||||||
|
formProps := forms.ChangeAccountCurrencyProps{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
AccountID: accountID,
|
||||||
|
CurrentCurrency: account.Currency,
|
||||||
|
NewCurrency: newCurrencyInput,
|
||||||
|
ConversionRate: rateInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
hasErr := false
|
||||||
|
if newCurrencyInput == "" {
|
||||||
|
formProps.NewCurrencyErr = "Choose a currency."
|
||||||
|
hasErr = true
|
||||||
|
} else if !currency.IsValid(newCurrencyInput) {
|
||||||
|
formProps.NewCurrencyErr = "Choose a supported currency."
|
||||||
|
hasErr = true
|
||||||
|
} else if newCurrencyInput == account.Currency {
|
||||||
|
formProps.NewCurrencyErr = "Choose a different currency."
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var rate decimal.Decimal
|
||||||
|
if rateInput == "" {
|
||||||
|
formProps.RateErr = "Conversion rate is required."
|
||||||
|
hasErr = true
|
||||||
|
} else {
|
||||||
|
r, err := decimal.NewFromString(rateInput)
|
||||||
|
if err != nil {
|
||||||
|
formProps.RateErr = "Enter a valid rate (e.g. 1.2345)."
|
||||||
|
hasErr = true
|
||||||
|
} else if !r.IsPositive() {
|
||||||
|
formProps.RateErr = "Rate must be greater than zero."
|
||||||
|
hasErr = true
|
||||||
|
} else {
|
||||||
|
rate = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasErr {
|
||||||
|
ui.Render(w, r, forms.ChangeAccountCurrency(formProps))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
actorID := ""
|
||||||
|
if user != nil {
|
||||||
|
actorID = user.ID
|
||||||
|
}
|
||||||
|
if err := h.accountService.ChangeCurrency(accountID, newCurrencyInput, rate, actorID); err != nil {
|
||||||
|
slog.Error("failed to change account currency", "error", err, "account_id", accountID)
|
||||||
|
formProps.GeneralErr = "Something went wrong. Please try again."
|
||||||
|
ui.Render(w, r, forms.ChangeAccountCurrency(formProps))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formProps.CurrentCurrency = newCurrencyInput
|
||||||
|
formProps.NewCurrency = ""
|
||||||
|
formProps.ConversionRate = ""
|
||||||
|
formProps.SuccessMsg = "Currency updated. Balance and allocations were converted."
|
||||||
|
ui.Render(w, r, forms.ChangeAccountCurrency(formProps))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
accountID := r.PathValue("accountID")
|
accountID := r.PathValue("accountID")
|
||||||
|
|
@ -1550,6 +1646,7 @@ func (h *spaceHandler) SpaceCreateTransferPage(w http.ResponseWriter, r *http.Re
|
||||||
Form: forms.CreateTransferProps{
|
Form: forms.CreateTransferProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
SourceAccountID: accountID,
|
SourceAccountID: accountID,
|
||||||
|
SourceCurrency: account.Currency,
|
||||||
DestAccounts: dests,
|
DestAccounts: dests,
|
||||||
SourceAvailable: allocSummary.Available.StringFixedBank(2),
|
SourceAvailable: allocSummary.Available.StringFixedBank(2),
|
||||||
SourceAllocated: allocSummary.Allocated.StringFixedBank(2),
|
SourceAllocated: allocSummary.Allocated.StringFixedBank(2),
|
||||||
|
|
@ -1579,16 +1676,19 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
|
||||||
titleInput := strings.TrimSpace(r.FormValue("title"))
|
titleInput := strings.TrimSpace(r.FormValue("title"))
|
||||||
amountInput := strings.TrimSpace(r.FormValue("amount"))
|
amountInput := strings.TrimSpace(r.FormValue("amount"))
|
||||||
destInput := strings.TrimSpace(r.FormValue("destination"))
|
destInput := strings.TrimSpace(r.FormValue("destination"))
|
||||||
|
rateInput := strings.TrimSpace(r.FormValue("rate"))
|
||||||
dateInput := strings.TrimSpace(r.FormValue("date"))
|
dateInput := strings.TrimSpace(r.FormValue("date"))
|
||||||
descriptionInput := strings.TrimSpace(r.FormValue("description"))
|
descriptionInput := strings.TrimSpace(r.FormValue("description"))
|
||||||
|
|
||||||
formProps := forms.CreateTransferProps{
|
formProps := forms.CreateTransferProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
SourceAccountID: accountID,
|
SourceAccountID: accountID,
|
||||||
|
SourceCurrency: source.Currency,
|
||||||
DestAccounts: dests,
|
DestAccounts: dests,
|
||||||
Title: titleInput,
|
Title: titleInput,
|
||||||
Amount: amountInput,
|
Amount: amountInput,
|
||||||
DestAccountID: destInput,
|
DestAccountID: destInput,
|
||||||
|
ConversionRate: rateInput,
|
||||||
Date: dateInput,
|
Date: dateInput,
|
||||||
Description: descriptionInput,
|
Description: descriptionInput,
|
||||||
}
|
}
|
||||||
|
|
@ -1627,6 +1727,7 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var destCurrency string
|
||||||
if destInput == "" {
|
if destInput == "" {
|
||||||
formProps.DestErr = "Choose a destination account."
|
formProps.DestErr = "Choose a destination account."
|
||||||
hasErr = true
|
hasErr = true
|
||||||
|
|
@ -1639,6 +1740,27 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
|
||||||
if err != nil || destAcct.SpaceID != spaceID {
|
if err != nil || destAcct.SpaceID != spaceID {
|
||||||
formProps.DestErr = "Destination account not found."
|
formProps.DestErr = "Destination account not found."
|
||||||
hasErr = true
|
hasErr = true
|
||||||
|
} else {
|
||||||
|
destCurrency = destAcct.Currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rate decimal.Decimal
|
||||||
|
if destCurrency != "" && destCurrency != source.Currency {
|
||||||
|
if rateInput == "" {
|
||||||
|
formProps.RateErr = "Conversion rate is required for cross-currency transfers."
|
||||||
|
hasErr = true
|
||||||
|
} else {
|
||||||
|
r, err := decimal.NewFromString(rateInput)
|
||||||
|
if err != nil {
|
||||||
|
formProps.RateErr = "Enter a valid rate (e.g. 1.2345)."
|
||||||
|
hasErr = true
|
||||||
|
} else if !r.IsPositive() {
|
||||||
|
formProps.RateErr = "Rate must be greater than zero."
|
||||||
|
hasErr = true
|
||||||
|
} else {
|
||||||
|
rate = r
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1671,6 +1793,7 @@ func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Reque
|
||||||
DestAccountID: destInput,
|
DestAccountID: destInput,
|
||||||
Title: titleInput,
|
Title: titleInput,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
|
ConversionRate: rate,
|
||||||
OccurredAt: occurredAt,
|
OccurredAt: occurredAt,
|
||||||
Description: descriptionInput,
|
Description: descriptionInput,
|
||||||
ActorID: actorID,
|
ActorID: actorID,
|
||||||
|
|
|
||||||
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"`
|
Name string `db:"name"`
|
||||||
SpaceID string `db:"space_id"`
|
SpaceID string `db:"space_id"`
|
||||||
Balance decimal.Decimal `db:"balance"`
|
Balance decimal.Decimal `db:"balance"`
|
||||||
|
Currency string `db:"currency"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const (
|
||||||
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
|
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
|
||||||
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
|
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
|
||||||
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
||||||
|
SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed"
|
||||||
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
|
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
|
||||||
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
|
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
|
||||||
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"
|
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,21 @@ package repository
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AllocationConversion describes the converted amount/target for a single
|
||||||
|
// allocation row when an account's currency is changed.
|
||||||
|
type AllocationConversion struct {
|
||||||
|
ID string
|
||||||
|
Amount decimal.Decimal
|
||||||
|
TargetAmount *decimal.Decimal
|
||||||
|
}
|
||||||
|
|
||||||
var ErrAccountNotFound = errors.New("account not found")
|
var ErrAccountNotFound = errors.New("account not found")
|
||||||
|
|
||||||
type AccountRepository interface {
|
type AccountRepository interface {
|
||||||
|
|
@ -16,6 +26,9 @@ type AccountRepository interface {
|
||||||
BySpaceID(spaceID string) ([]*model.Account, error)
|
BySpaceID(spaceID string) ([]*model.Account, error)
|
||||||
Rename(id, name string) error
|
Rename(id, name string) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
|
// ChangeCurrency atomically updates an account's currency and balance and
|
||||||
|
// rewrites each provided allocation's amount/target in the new currency.
|
||||||
|
ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type accountRepository struct {
|
type accountRepository struct {
|
||||||
|
|
@ -27,9 +40,9 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *accountRepository) Create(account *model.Account) error {
|
func (r *accountRepository) Create(account *model.Account) error {
|
||||||
query := `INSERT INTO accounts (id, name, space_id, created_at, updated_at)
|
query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5);`
|
VALUES ($1, $2, $3, $4, $5, $6);`
|
||||||
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.CreatedAt, account.UpdatedAt)
|
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,3 +77,24 @@ func (r *accountRepository) Delete(id string) error {
|
||||||
_, err := r.db.Exec(query, id)
|
_, err := r.db.Exec(query, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error {
|
||||||
|
return WithTx(r.db, func(tx *sqlx.Tx) error {
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE accounts SET currency = $1, balance = $2, updated_at = $3 WHERE id = $4;`,
|
||||||
|
newCurrency, newBalance, now, accountID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range allocationConversions {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE allocations SET amount = $1, target_amount = $2, updated_at = $3 WHERE id = $4;`,
|
||||||
|
c.Amount, c.TargetAmount, now, c.ID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
g.Get("/transactions/{transactionID}/activity", spaceH.SpaceTransactionActivityPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.activity")
|
g.Get("/transactions/{transactionID}/activity", spaceH.SpaceTransactionActivityPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.activity")
|
||||||
g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings")
|
g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings")
|
||||||
g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename")
|
g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename")
|
||||||
|
g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency")
|
||||||
g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete")
|
g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete")
|
||||||
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
|
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
|
||||||
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
|
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,18 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/misc/currency"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultAccountName = "Money Account"
|
const DefaultAccountName = "Money Account"
|
||||||
|
|
||||||
type AccountService struct {
|
type AccountService struct {
|
||||||
accountRepo repository.AccountRepository
|
accountRepo repository.AccountRepository
|
||||||
|
allocationRepo repository.AllocationRepository
|
||||||
auditSvc *SpaceAuditLogService
|
auditSvc *SpaceAuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,12 +23,18 @@ func NewAccountService(accountRepo repository.AccountRepository) *AccountService
|
||||||
return &AccountService{accountRepo: accountRepo}
|
return &AccountService{accountRepo: accountRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAllocationRepository wires the allocation repository after construction.
|
||||||
|
// Required for currency conversion to rewrite allocation amounts atomically.
|
||||||
|
func (s *AccountService) SetAllocationRepository(repo repository.AllocationRepository) {
|
||||||
|
s.allocationRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
// SetAuditLogger wires the audit log service after construction.
|
// SetAuditLogger wires the audit log service after construction.
|
||||||
func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
|
func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
|
||||||
s.auditSvc = audit
|
s.auditSvc = audit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Account, error) {
|
func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) {
|
||||||
if spaceID == "" {
|
if spaceID == "" {
|
||||||
return nil, fmt.Errorf("space id is required")
|
return nil, fmt.Errorf("space id is required")
|
||||||
}
|
}
|
||||||
|
|
@ -33,11 +42,20 @@ func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Ac
|
||||||
return nil, fmt.Errorf("account name cannot be empty")
|
return nil, fmt.Errorf("account name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code := currency.Normalize(currencyCode)
|
||||||
|
if code == "" {
|
||||||
|
code = currency.Default
|
||||||
|
}
|
||||||
|
if !currency.IsValid(code) {
|
||||||
|
return nil, fmt.Errorf("unsupported currency code: %s", currencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
account := &model.Account{
|
account := &model.Account{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Name: name,
|
Name: name,
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
|
Currency: code,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +69,7 @@ func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Ac
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
"account_id": account.ID,
|
"account_id": account.ID,
|
||||||
"account_name": account.Name,
|
"account_name": account.Name,
|
||||||
|
"currency": account.Currency,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return account, nil
|
return account, nil
|
||||||
|
|
@ -118,6 +137,73 @@ func (s *AccountService) DeleteAccount(id, actorID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangeCurrency converts the account's currency. Every value held in the old
|
||||||
|
// currency (account balance, allocation amounts and targets) is multiplied by
|
||||||
|
// rate and rounded to 2 decimals. The whole change is applied in a single SQL
|
||||||
|
// transaction so the account never appears in a half-converted state.
|
||||||
|
//
|
||||||
|
// rate is "1 oldCurrency = rate newCurrency". Same-currency changes are
|
||||||
|
// rejected; callers should treat rate as required and positive.
|
||||||
|
func (s *AccountService) ChangeCurrency(accountID, newCurrencyCode string, rate decimal.Decimal, actorID string) error {
|
||||||
|
if accountID == "" {
|
||||||
|
return fmt.Errorf("account id is required")
|
||||||
|
}
|
||||||
|
code := currency.Normalize(newCurrencyCode)
|
||||||
|
if !currency.IsValid(code) {
|
||||||
|
return fmt.Errorf("unsupported currency code: %s", newCurrencyCode)
|
||||||
|
}
|
||||||
|
if !rate.IsPositive() {
|
||||||
|
return fmt.Errorf("conversion rate must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := s.accountRepo.ByID(accountID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load account: %w", err)
|
||||||
|
}
|
||||||
|
if account.Currency == code {
|
||||||
|
return fmt.Errorf("account is already in %s", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations, err := s.allocationRepo.ByAccountID(accountID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load allocations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newBalance := account.Balance.Mul(rate).Round(2)
|
||||||
|
conversions := make([]repository.AllocationConversion, 0, len(allocations))
|
||||||
|
for _, a := range allocations {
|
||||||
|
c := repository.AllocationConversion{
|
||||||
|
ID: a.ID,
|
||||||
|
Amount: a.Amount.Mul(rate).Round(2),
|
||||||
|
}
|
||||||
|
if a.TargetAmount != nil {
|
||||||
|
t := a.TargetAmount.Mul(rate).Round(2)
|
||||||
|
c.TargetAmount = &t
|
||||||
|
}
|
||||||
|
conversions = append(conversions, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.accountRepo.ChangeCurrency(accountID, code, newBalance, conversions); err != nil {
|
||||||
|
return fmt.Errorf("failed to change currency: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditSvc.Record(RecordOptions{
|
||||||
|
SpaceID: account.SpaceID,
|
||||||
|
ActorID: actorID,
|
||||||
|
Action: model.SpaceAuditActionAccountCurrencyChanged,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"account_name": account.Name,
|
||||||
|
"old_currency": account.Currency,
|
||||||
|
"new_currency": code,
|
||||||
|
"conversion_rate": rate.String(),
|
||||||
|
"old_balance": account.Balance.StringFixedBank(2),
|
||||||
|
"new_balance": newBalance.StringFixedBank(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) {
|
func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) {
|
||||||
accounts, err := s.accountRepo.BySpaceID(spaceID)
|
accounts, err := s.accountRepo.BySpaceID(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
|
||||||
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
|
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
|
||||||
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
||||||
|
|
||||||
account, err := svc.CreateAccount(space.ID, "Checking", user.ID)
|
account, err := svc.CreateAccount(space.ID, "Checking", "CAD", user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
|
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
|
||||||
|
|
@ -104,7 +104,7 @@ func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) {
|
||||||
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
|
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
|
||||||
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
||||||
|
|
||||||
account, err := svc.CreateAccount(space.ID, "x", user.ID)
|
account, err := svc.CreateAccount(space.ID, "x", "", user.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
|
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
|
||||||
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
|
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
|
||||||
return fmt.Errorf("failed to create onboarding space: %w", err)
|
return fmt.Errorf("failed to create onboarding space: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, userID); err != nil {
|
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, "", userID); err != nil {
|
||||||
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
|
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
|
||||||
slog.Error("failed to roll back space after account creation error",
|
slog.Error("failed to roll back space after account creation error",
|
||||||
"space_id", space.ID, "error", delErr)
|
"space_id", space.ID, "error", delErr)
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,10 @@ type TransferInput struct {
|
||||||
DestAccountID string
|
DestAccountID string
|
||||||
Title string
|
Title string
|
||||||
Amount decimal.Decimal
|
Amount decimal.Decimal
|
||||||
|
// ConversionRate is the rate that converts one unit of the source currency
|
||||||
|
// into the destination currency. Required when source and destination
|
||||||
|
// accounts have different currencies; ignored otherwise. Must be positive.
|
||||||
|
ConversionRate decimal.Decimal
|
||||||
OccurredAt time.Time
|
OccurredAt time.Time
|
||||||
Description string
|
Description string
|
||||||
ActorID string
|
ActorID string
|
||||||
|
|
@ -235,6 +239,18 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
|
||||||
return nil, fmt.Errorf("failed to load destination account: %w", err)
|
return nil, fmt.Errorf("failed to load destination account: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-currency transfers require a conversion rate; same-currency
|
||||||
|
// transfers ignore it (or, for symmetry, accept rate=1).
|
||||||
|
destAmount := input.Amount
|
||||||
|
rate := decimal.NewFromInt(1)
|
||||||
|
if source.Currency != dest.Currency {
|
||||||
|
if !input.ConversionRate.IsPositive() {
|
||||||
|
return nil, fmt.Errorf("conversion rate is required when transferring between accounts of different currencies")
|
||||||
|
}
|
||||||
|
rate = input.ConversionRate
|
||||||
|
destAmount = input.Amount.Mul(rate).Round(2)
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var description *string
|
var description *string
|
||||||
if d := strings.TrimSpace(input.Description); d != "" {
|
if d := strings.TrimSpace(input.Description); d != "" {
|
||||||
|
|
@ -254,7 +270,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
|
||||||
}
|
}
|
||||||
deposit := &model.Transaction{
|
deposit := &model.Transaction{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Value: input.Amount,
|
Value: destAmount,
|
||||||
Type: model.TransactionTypeDeposit,
|
Type: model.TransactionTypeDeposit,
|
||||||
AccountID: dest.ID,
|
AccountID: dest.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|
@ -265,7 +281,7 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceNewBalance := source.Balance.Sub(input.Amount)
|
sourceNewBalance := source.Balance.Sub(input.Amount)
|
||||||
destNewBalance := dest.Balance.Add(input.Amount)
|
destNewBalance := dest.Balance.Add(destAmount)
|
||||||
|
|
||||||
if err := s.transactionRepo.TransferAtomic(withdrawal, deposit, sourceNewBalance, destNewBalance); err != nil {
|
if err := s.transactionRepo.TransferAtomic(withdrawal, deposit, sourceNewBalance, destNewBalance); err != nil {
|
||||||
return nil, fmt.Errorf("failed to record transfer: %w", err)
|
return nil, fmt.Errorf("failed to record transfer: %w", err)
|
||||||
|
|
@ -287,6 +303,10 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
|
||||||
"transfer_pair_id": deposit.ID,
|
"transfer_pair_id": deposit.ID,
|
||||||
"transfer_other_acct": deposit.AccountID,
|
"transfer_other_acct": deposit.AccountID,
|
||||||
"transfer_other_name": dest.Name,
|
"transfer_other_name": dest.Name,
|
||||||
|
"source_currency": source.Currency,
|
||||||
|
"dest_currency": dest.Currency,
|
||||||
|
"conversion_rate": rate.String(),
|
||||||
|
"dest_amount": destAmount.StringFixedBank(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
s.auditSvc.Record(TransactionRecordOptions{
|
s.auditSvc.Record(TransactionRecordOptions{
|
||||||
|
|
@ -302,6 +322,10 @@ func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, err
|
||||||
"transfer_pair_id": withdrawal.ID,
|
"transfer_pair_id": withdrawal.ID,
|
||||||
"transfer_other_acct": withdrawal.AccountID,
|
"transfer_other_acct": withdrawal.AccountID,
|
||||||
"transfer_other_name": source.Name,
|
"transfer_other_name": source.Name,
|
||||||
|
"source_currency": source.Currency,
|
||||||
|
"dest_currency": dest.Currency,
|
||||||
|
"conversion_rate": rate.String(),
|
||||||
|
"source_amount": input.Amount.StringFixedBank(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,13 @@ func CreateTestAccount(t *testing.T, db *sqlx.DB, spaceID, name string) *model.A
|
||||||
Name: name,
|
Name: name,
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
Balance: decimal.Zero,
|
Balance: decimal.Zero,
|
||||||
|
Currency: "CAD",
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
`INSERT INTO accounts (id, name, space_id, balance, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
`INSERT INTO accounts (id, name, space_id, balance, currency, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
account.ID, account.Name, account.SpaceID, account.Balance, account.CreatedAt, account.UpdatedAt,
|
account.ID, account.Name, account.SpaceID, account.Balance, account.Currency, account.CreatedAt, account.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateTestAccount: %v", err)
|
t.Fatalf("CreateTestAccount: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type AccountCardInfo struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Balance decimal.Decimal
|
Balance decimal.Decimal
|
||||||
|
Currency string
|
||||||
}
|
}
|
||||||
|
|
||||||
templ AccountCard(info AccountCardInfo) {
|
templ AccountCard(info AccountCardInfo) {
|
||||||
|
|
@ -22,7 +23,7 @@ templ AccountCard(info AccountCardInfo) {
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">{ info.Name }</p>
|
<p class="font-semibold">{ info.Name }</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
${ info.Balance.StringFixedBank(2) }
|
${ info.Balance.StringFixedBank(2) } { info.Currency }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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
|
package forms
|
||||||
|
|
||||||
|
import "git.juancwu.dev/juancwu/budgit/internal/misc/currency"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
|
|
@ -10,8 +11,10 @@ type CreateAccountProps struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
|
Currency string
|
||||||
|
|
||||||
NameErr string
|
NameErr string
|
||||||
|
CurrencyErr string
|
||||||
GeneralErr string
|
GeneralErr string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,7 +51,35 @@ templ CreateAccount(props CreateAccountProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@form.Description() {
|
@form.Description() {
|
||||||
You can rename the account later. Starts with a $0.00 balance.
|
You can rename the account later. Starts with a 0.00 balance.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{
|
||||||
|
selected := props.Currency
|
||||||
|
if selected == "" {
|
||||||
|
selected = currency.Default
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@form.Item() {
|
||||||
|
@form.Label(form.LabelProps{For: "currency"}) {
|
||||||
|
Currency
|
||||||
|
}
|
||||||
|
<select
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
templ.KV("border-destructive", props.CurrencyErr != ""),
|
||||||
|
templ.KV("border-input", props.CurrencyErr == "") }
|
||||||
|
required
|
||||||
|
>
|
||||||
|
for _, code := range currency.Supported() {
|
||||||
|
<option value={ code } selected?={ selected == code }>{ code }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
if props.CurrencyErr != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ props.CurrencyErr }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
|
||||||
type CreateTransferProps struct {
|
type CreateTransferProps struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
SourceAccountID string
|
SourceAccountID string
|
||||||
|
SourceCurrency string
|
||||||
|
|
||||||
// DestAccounts is the list of other accounts in the same space the user
|
// DestAccounts is the list of other accounts in the same space the user
|
||||||
// can transfer to. Excludes the source account.
|
// can transfer to. Excludes the source account.
|
||||||
|
|
@ -26,12 +27,14 @@ type CreateTransferProps struct {
|
||||||
Title string
|
Title string
|
||||||
Amount string
|
Amount string
|
||||||
DestAccountID string
|
DestAccountID string
|
||||||
|
ConversionRate string
|
||||||
Date string
|
Date string
|
||||||
Description string
|
Description string
|
||||||
|
|
||||||
TitleErr string
|
TitleErr string
|
||||||
AmountErr string
|
AmountErr string
|
||||||
DestErr string
|
DestErr string
|
||||||
|
RateErr string
|
||||||
DateErr string
|
DateErr string
|
||||||
GeneralErr string
|
GeneralErr string
|
||||||
}
|
}
|
||||||
|
|
@ -104,10 +107,26 @@ templ CreateTransfer(props CreateTransferProps) {
|
||||||
templ.KV("border-destructive", props.DestErr != ""),
|
templ.KV("border-destructive", props.DestErr != ""),
|
||||||
templ.KV("border-input", props.DestErr == "") }
|
templ.KV("border-input", props.DestErr == "") }
|
||||||
required
|
required
|
||||||
|
data-source-currency={ props.SourceCurrency }
|
||||||
|
_="on change
|
||||||
|
set opt to my.options[my.selectedIndex]
|
||||||
|
set destCur to opt.getAttribute('data-currency') or ''
|
||||||
|
set srcCur to @data-source-currency of me
|
||||||
|
if destCur is not '' and destCur is not srcCur
|
||||||
|
remove .hidden from #rate-row
|
||||||
|
set #rate-dest-currency.innerText to destCur
|
||||||
|
set #rate-source-currency.innerText to srcCur
|
||||||
|
else
|
||||||
|
add .hidden to #rate-row
|
||||||
|
end"
|
||||||
>
|
>
|
||||||
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
|
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
|
||||||
for _, a := range props.DestAccounts {
|
for _, a := range props.DestAccounts {
|
||||||
<option value={ a.ID } selected?={ props.DestAccountID == a.ID }>{ a.Name }</option>
|
<option
|
||||||
|
value={ a.ID }
|
||||||
|
data-currency={ a.Currency }
|
||||||
|
selected?={ props.DestAccountID == a.ID }
|
||||||
|
>{ a.Name } ({ a.Currency })</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +186,57 @@ templ CreateTransfer(props CreateTransferProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{{
|
||||||
|
selectedDestCurrency := ""
|
||||||
|
for _, a := range props.DestAccounts {
|
||||||
|
if a.ID == props.DestAccountID {
|
||||||
|
selectedDestCurrency = a.Currency
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rateRowHidden := selectedDestCurrency == "" || selectedDestCurrency == props.SourceCurrency
|
||||||
|
rateRowClasses := []string{"space-y-2 rounded-md border p-4 bg-muted/30"}
|
||||||
|
if rateRowHidden {
|
||||||
|
rateRowClasses = append(rateRowClasses, "hidden")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div id="rate-row" class={ utils.TwMerge(rateRowClasses...) }>
|
||||||
|
<p class="text-sm">
|
||||||
|
Source and destination use different currencies. Set the conversion rate.
|
||||||
|
</p>
|
||||||
|
@form.Item() {
|
||||||
|
@form.Label(form.LabelProps{For: "rate"}) {
|
||||||
|
Conversion rate
|
||||||
|
}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">1 <span id="rate-source-currency">{ props.SourceCurrency }</span> =</span>
|
||||||
|
@input.Input(input.Props{
|
||||||
|
ID: "rate",
|
||||||
|
Name: "rate",
|
||||||
|
Type: input.TypeNumber,
|
||||||
|
Placeholder: "1.00",
|
||||||
|
Class: "rounded-sm",
|
||||||
|
Value: props.ConversionRate,
|
||||||
|
HasError: props.RateErr != "",
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"step": "0.000001",
|
||||||
|
"min": "0",
|
||||||
|
"inputmode": "decimal",
|
||||||
|
"autocomplete": "off",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
<span class="text-sm text-muted-foreground" id="rate-dest-currency">{ selectedDestCurrency }</span>
|
||||||
|
</div>
|
||||||
|
if props.RateErr != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ props.RateErr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@form.Description() {
|
||||||
|
The destination account will be credited the converted amount, rounded to 2 decimals.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@form.Item() {
|
@form.Item() {
|
||||||
@form.Label(form.LabelProps{For: "description"}) {
|
@form.Label(form.LabelProps{For: "description"}) {
|
||||||
Description
|
Description
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/service"
|
import "git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||||
|
|
@ -17,6 +18,7 @@ type SpaceAccountPageProps struct {
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountName string
|
AccountName string
|
||||||
AccountBalance decimal.Decimal
|
AccountBalance decimal.Decimal
|
||||||
|
AccountCurrency string
|
||||||
RecentTransactions []*model.Transaction
|
RecentTransactions []*model.Transaction
|
||||||
NonEditableTransactionIDs map[string]bool
|
NonEditableTransactionIDs map[string]bool
|
||||||
AllocationSummary *service.AllocationSummary
|
AllocationSummary *service.AllocationSummary
|
||||||
|
|
@ -39,12 +41,20 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
}) {
|
}) {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
@card.Title() {
|
@card.Title() {
|
||||||
{ props.AccountName }
|
<div class="flex items-center gap-3">
|
||||||
|
<span>{ props.AccountName }</span>
|
||||||
|
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs font-medium"}) {
|
||||||
|
{ props.AccountCurrency }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@card.Content() {
|
@card.Content() {
|
||||||
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
|
<h1 class={ utils.TwMerge(balanceTextClasses...) }>
|
||||||
<p class="text-sm text-muted-foreground">Account Balance</p>
|
${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }
|
||||||
|
<span class="text-xl font-semibold text-muted-foreground ml-2">{ props.AccountCurrency }</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Account Balance ({ props.AccountCurrency })</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
|
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ type SpaceAccountSettingsPageProps struct {
|
||||||
SpaceName string
|
SpaceName string
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountName string
|
AccountName string
|
||||||
|
AccountCurrency string
|
||||||
UpdateForm forms.UpdateAccountProps
|
UpdateForm forms.UpdateAccountProps
|
||||||
|
CurrencyForm forms.ChangeAccountCurrencyProps
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
|
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
|
||||||
|
|
@ -32,6 +34,7 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@forms.UpdateAccount(props.UpdateForm)
|
@forms.UpdateAccount(props.UpdateForm)
|
||||||
|
@forms.ChangeAccountCurrency(props.CurrencyForm)
|
||||||
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
|
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
@card.Title(card.TitleProps{Class: "text-destructive"}) {
|
@card.Title(card.TitleProps{Class: "text-destructive"}) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue