feat: investment accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s
This commit is contained in:
parent
f444a074bc
commit
7c24a8302d
25 changed files with 2205 additions and 56 deletions
|
|
@ -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()
|
||||
|
|
|
|||
74
internal/repository/investment_contribution_room.go
Normal file
74
internal/repository/investment_contribution_room.go
Normal 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
|
||||
}
|
||||
|
||||
89
internal/repository/investment_holding.go
Normal file
89
internal/repository/investment_holding.go
Normal 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
|
||||
}
|
||||
91
internal/repository/investment_trade.go
Normal file
91
internal/repository/investment_trade.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue