feat: investment accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
juancwu 2026-05-22 14:49:57 +00:00
commit 7c24a8302d
25 changed files with 2205 additions and 56 deletions

View file

@ -29,6 +29,12 @@ type AccountRepository interface {
// 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
// SetInvestment toggles the investment flag and subtype for an account.
// subtype is the canonical lowercase string (e.g. "tfsa"); pass nil to clear.
SetInvestment(id string, isInvestment bool, subtype *string) error
// InvestmentAccountsByUserID returns all investment-flagged accounts the
// user owns, across every space the user owns.
InvestmentAccountsByUserID(userID string) ([]*model.Account, error)
}
type accountRepository struct {
@ -40,9 +46,10 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
}
func (r *accountRepository) Create(account *model.Account) error {
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)
query := `INSERT INTO accounts (id, name, space_id, currency, is_investment, investment_subtype, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency,
account.IsInvestment, account.InvestmentSubtype, account.CreatedAt, account.UpdatedAt)
return err
}
@ -78,6 +85,36 @@ func (r *accountRepository) Delete(id string) error {
return err
}
func (r *accountRepository) SetInvestment(id string, isInvestment bool, subtype *string) error {
query := `UPDATE accounts
SET is_investment = $1, investment_subtype = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3;`
res, err := r.db.Exec(query, isInvestment, subtype, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrAccountNotFound
}
return nil
}
func (r *accountRepository) InvestmentAccountsByUserID(userID string) ([]*model.Account, error) {
var accounts []*model.Account
query := `SELECT a.* FROM accounts a
JOIN space_members sm ON sm.space_id = a.space_id
WHERE sm.user_id = $1 AND a.is_investment = TRUE
ORDER BY a.created_at ASC;`
if err := r.db.Select(&accounts, query, userID); err != nil {
return nil, err
}
return accounts, nil
}
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()

View file

@ -0,0 +1,74 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrContributionRoomNotFound = errors.New("contribution room not found")
type InvestmentContributionRoomRepository interface {
Upsert(room *model.InvestmentContributionRoom) error
ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error)
ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error)
Delete(accountID string, year int) error
}
type investmentContributionRoomRepository struct {
db *sqlx.DB
}
func NewInvestmentContributionRoomRepository(db *sqlx.DB) InvestmentContributionRoomRepository {
return &investmentContributionRoomRepository{db: db}
}
func (r *investmentContributionRoomRepository) Upsert(room *model.InvestmentContributionRoom) error {
query := `INSERT INTO investment_contribution_rooms (account_id, year, room_amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, year) DO UPDATE
SET room_amount = EXCLUDED.room_amount,
updated_at = EXCLUDED.updated_at;`
_, err := r.db.Exec(query, room.AccountID, room.Year, room.RoomAmount, room.CreatedAt, room.UpdatedAt)
return err
}
func (r *investmentContributionRoomRepository) ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error) {
room := &model.InvestmentContributionRoom{}
query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;`
err := r.db.Get(room, query, accountID, year)
if err == sql.ErrNoRows {
return nil, ErrContributionRoomNotFound
}
if err != nil {
return nil, err
}
return room, nil
}
func (r *investmentContributionRoomRepository) ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error) {
var rooms []*model.InvestmentContributionRoom
query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 ORDER BY year DESC;`
if err := r.db.Select(&rooms, query, accountID); err != nil {
return nil, err
}
return rooms, nil
}
func (r *investmentContributionRoomRepository) Delete(accountID string, year int) error {
res, err := r.db.Exec(`DELETE FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;`, accountID, year)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrContributionRoomNotFound
}
return nil
}

View file

@ -0,0 +1,89 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrHoldingNotFound = errors.New("investment holding not found")
type InvestmentHoldingRepository interface {
Create(h *model.InvestmentHolding) error
ByID(id string) (*model.InvestmentHolding, error)
ByAccountID(accountID string) ([]*model.InvestmentHolding, error)
Update(id, symbol, displayName string) error
Delete(id string) error
}
type investmentHoldingRepository struct {
db *sqlx.DB
}
func NewInvestmentHoldingRepository(db *sqlx.DB) InvestmentHoldingRepository {
return &investmentHoldingRepository{db: db}
}
func (r *investmentHoldingRepository) Create(h *model.InvestmentHolding) error {
query := `INSERT INTO investment_holdings (id, account_id, symbol, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, h.ID, h.AccountID, h.Symbol, h.DisplayName, h.CreatedAt, h.UpdatedAt)
return err
}
func (r *investmentHoldingRepository) ByID(id string) (*model.InvestmentHolding, error) {
h := &model.InvestmentHolding{}
query := `SELECT * FROM investment_holdings WHERE id = $1;`
err := r.db.Get(h, query, id)
if err == sql.ErrNoRows {
return nil, ErrHoldingNotFound
}
if err != nil {
return nil, err
}
return h, nil
}
func (r *investmentHoldingRepository) ByAccountID(accountID string) ([]*model.InvestmentHolding, error) {
var holdings []*model.InvestmentHolding
query := `SELECT * FROM investment_holdings WHERE account_id = $1 ORDER BY symbol ASC;`
if err := r.db.Select(&holdings, query, accountID); err != nil {
return nil, err
}
return holdings, nil
}
func (r *investmentHoldingRepository) Update(id, symbol, displayName string) error {
query := `UPDATE investment_holdings
SET symbol = $1, display_name = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3;`
res, err := r.db.Exec(query, symbol, displayName, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrHoldingNotFound
}
return nil
}
func (r *investmentHoldingRepository) Delete(id string) error {
res, err := r.db.Exec(`DELETE FROM investment_holdings WHERE id = $1;`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrHoldingNotFound
}
return nil
}

View file

@ -0,0 +1,91 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var ErrTradeNotFound = errors.New("investment trade not found")
type InvestmentTradeRepository interface {
Create(t *model.InvestmentTrade) error
ByID(id string) (*model.InvestmentTrade, error)
ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error)
Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error
Delete(id string) error
}
type investmentTradeRepository struct {
db *sqlx.DB
}
func NewInvestmentTradeRepository(db *sqlx.DB) InvestmentTradeRepository {
return &investmentTradeRepository{db: db}
}
func (r *investmentTradeRepository) Create(t *model.InvestmentTrade) error {
query := `INSERT INTO investment_trades (id, holding_id, type, quantity, price_per_unit, fees, occurred_at, notes, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);`
_, err := r.db.Exec(query, t.ID, t.HoldingID, t.Type, t.Quantity, t.PricePerUnit, t.Fees, t.OccurredAt, t.Notes, t.CreatedAt)
return err
}
func (r *investmentTradeRepository) ByID(id string) (*model.InvestmentTrade, error) {
t := &model.InvestmentTrade{}
query := `SELECT * FROM investment_trades WHERE id = $1;`
err := r.db.Get(t, query, id)
if err == sql.ErrNoRows {
return nil, ErrTradeNotFound
}
if err != nil {
return nil, err
}
return t, nil
}
func (r *investmentTradeRepository) ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error) {
var trades []*model.InvestmentTrade
query := `SELECT * FROM investment_trades WHERE holding_id = $1 ORDER BY occurred_at ASC, created_at ASC;`
if err := r.db.Select(&trades, query, holdingID); err != nil {
return nil, err
}
return trades, nil
}
func (r *investmentTradeRepository) Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error {
query := `UPDATE investment_trades
SET quantity = $1, price_per_unit = $2, fees = $3, occurred_at = $4, notes = $5
WHERE id = $6;`
res, err := r.db.Exec(query, quantity, pricePerUnit, fees, occurredAt, notes, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrTradeNotFound
}
return nil
}
func (r *investmentTradeRepository) Delete(id string) error {
res, err := r.db.Exec(`DELETE FROM investment_trades WHERE id = $1;`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrTradeNotFound
}
return nil
}

View file

@ -22,6 +22,12 @@ type TransactionRepository interface {
TransferIDsIn(ids []string) (map[string]bool, error)
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
CountByAccount(accountID string) (int, error)
// SumByAccountYearType totals transaction values for an account, year,
// and type (deposit or withdrawal). Returns zero when no rows match.
SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error)
// SumLifetimeByAccountType totals transaction values for an account over
// its full history, restricted to one type.
SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error)
}
type transactionRepository struct {
@ -304,3 +310,25 @@ func (r *transactionRepository) CountByAccount(accountID string) (int, error) {
}
return count, nil
}
func (r *transactionRepository) SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error) {
var sum decimal.Decimal
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
WHERE account_id = $1
AND type = $2
AND EXTRACT(YEAR FROM occurred_at) = $3;`
if err := r.db.Get(&sum, query, accountID, txType, year); err != nil {
return decimal.Zero, err
}
return sum, nil
}
func (r *transactionRepository) SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error) {
var sum decimal.Decimal
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
WHERE account_id = $1 AND type = $2;`
if err := r.db.Get(&sum, query, accountID, txType); err != nil {
return decimal.Zero, err
}
return sum, nil
}