diff --git a/handler/transaction.go b/handler/transaction.go index 089b2a2..8bb27a0 100644 --- a/handler/transaction.go +++ b/handler/transaction.go @@ -3,12 +3,14 @@ package handler import ( "net/http" "spend-sparrow/handler/middleware" + "spend-sparrow/log" "spend-sparrow/service" t "spend-sparrow/template/transaction" "spend-sparrow/types" "spend-sparrow/utils" "github.com/a-h/templ" + "github.com/google/uuid" ) type Transaction interface { @@ -37,9 +39,28 @@ func (h TransactionImpl) Handle(r *http.ServeMux) { r.Handle("GET /transaction", h.handleTransactionPage()) r.Handle("GET /transaction/{id}", h.handleTransactionItemComp()) r.Handle("POST /transaction/{id}", h.handleUpdateTransaction()) + r.Handle("POST /transaction/recalculate", h.handleRecalculate()) r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction()) } +func (h TransactionImpl) handleRecalculate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + err := h.s.RecalculateBalances(user) + if err != nil { + handleError(w, r, err) + return + } + + utils.TriggerToastWithStatus(w, r, "success", "Balances recalculated", http.StatusOK) + } +} + func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) @@ -66,7 +87,9 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { return } - comp := t.Transaction(transactions, accounts, treasureChests) + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + + comp := t.Transaction(transactions, accountMap, treasureChestMap) h.r.RenderLayout(r, w, comp, user) } } @@ -108,7 +131,8 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { if r.URL.Query().Get("edit") == "true" { comp = t.EditTransaction(transaction, accounts, treasureChests) } else { - comp = t.TransactionItem(transaction) + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + comp = t.TransactionItem(transaction, accountMap, treasureChestMap) } h.r.Render(r, w, comp) } @@ -150,7 +174,20 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { } } - comp := t.TransactionItem(transaction) + accounts, err := h.account.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + treasureChests, err := h.treasureChest.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + comp := t.TransactionItem(transaction, accountMap, treasureChestMap) h.r.Render(r, w, comp) } } @@ -172,3 +209,22 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { } } } + +func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) { + accountMap := make(map[uuid.UUID]string, 0) + for _, account := range accounts { + accountMap[account.Id] = account.Name + } + treasureChestMap := make(map[uuid.UUID]string, 0) + root := "" + for _, treasureChest := range treasureChests { + if treasureChest.ParentId == uuid.Nil { + root = treasureChest.Name + " > " + treasureChestMap[treasureChest.Id] = treasureChest.Name + } else { + treasureChestMap[treasureChest.Id] = root + treasureChest.Name + } + } + log.Info("treasureChestMap: %v", treasureChestMap) + return accountMap, treasureChestMap +} diff --git a/migration/005_transaction_add_error.up.sql b/migration/005_transaction_add_error.up.sql new file mode 100644 index 0000000..11fd775 --- /dev/null +++ b/migration/005_transaction_add_error.up.sql @@ -0,0 +1,2 @@ + +ALTER TABLE "transaction" ADD COLUMN error TEXT; diff --git a/service/transaction.go b/service/transaction.go index 5baeb23..69b60d7 100644 --- a/service/transaction.go +++ b/service/transaction.go @@ -31,6 +31,8 @@ type Transaction interface { Get(user *types.User, id string) (*types.Transaction, error) GetAll(user *types.User) ([]*types.Transaction, error) Delete(user *types.User, id string) error + + RecalculateBalances(user *types.User) error } type TransactionImpl struct { @@ -62,13 +64,24 @@ func (s TransactionImpl) Add(user *types.User, transactionInput types.Transactio } r, err := s.db.NamedExec(` - INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, note, created_at, created_by) - VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, :note, :created_at, :created_by)`, transaction) + INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, note, error, created_at, created_by) + VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, :note, :error, :created_at, :created_by)`, transaction) err = db.TransformAndLogDbError("transaction Insert", r, err) if err != nil { return nil, err } + if transaction.AccountId != nil { + r, err = s.db.Exec(` + UPDATE account + SET current_balance = current_balance + ? + WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) + err = db.TransformAndLogDbError("transaction UpdateAccount", r, err) + if err != nil { + return nil, err + } + } + return transaction, nil } @@ -93,11 +106,33 @@ func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) return nil, types.ErrInternal } + if transaction.AccountId != nil { + r, err := s.db.Exec(` + UPDATE account + SET current_balance = current_balance - ? + WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) + err = db.TransformAndLogDbError("transaction UpdateAccount", r, err) + if err != nil { + return nil, err + } + } + transaction, err = s.validateAndEnrichTransaction(transaction, user.Id, input) if err != nil { return nil, err } + if transaction.AccountId != nil { + r, err := s.db.Exec(` + UPDATE account + SET current_balance = current_balance + ? + WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) + err = db.TransformAndLogDbError("transaction UpdateAccount", r, err) + if err != nil { + return nil, err + } + } + r, err := s.db.NamedExec(` UPDATE "transaction" SET @@ -106,6 +141,7 @@ func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) value = :value, timestamp = :timestamp, note = :note, + error = :error, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id @@ -149,7 +185,7 @@ func (s TransactionImpl) GetAll(user *types.User) ([]*types.Transaction, error) return nil, ErrUnauthorized } transactions := make([]*types.Transaction, 0) - err := s.db.Select(&transactions, `SELECT * FROM "transaction" WHERE user_id = ? ORDER BY timestamp`, user.Id) + err := s.db.Select(&transactions, `SELECT * FROM "transaction" WHERE user_id = ? ORDER BY timestamp DESC`, user.Id) err = db.TransformAndLogDbError("transaction GetAll", nil, err) if err != nil { return nil, err @@ -169,7 +205,17 @@ func (s TransactionImpl) Delete(user *types.User, id string) error { return fmt.Errorf("could not parse Id: %w", ErrBadRequest) } - r, err := s.db.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id) + r, err := s.db.Exec(` + UPDATE account + SET current_balance = current_balance - (SELECT value FROM "transaction" WHERE id = ? AND user_id = ?) + WHERE id = (SELECT account_id FROM "transaction" WHERE id = ? AND user_id = ?) + `, uuid, user.Id, uuid, user.Id) + err = db.TransformAndLogDbError("transaction Delete", r, err) + if err != nil { + return err + } + + r, err = s.db.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id) err = db.TransformAndLogDbError("transaction Delete", r, err) if err != nil { return err @@ -178,7 +224,76 @@ func (s TransactionImpl) Delete(user *types.User, id string) error { return nil } -func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { +func (s TransactionImpl) RecalculateBalances(user *types.User) error { + transactionMetric.WithLabelValues("recalculate").Inc() + if user == nil { + return ErrUnauthorized + } + + r, err := s.db.Exec(` + UPDATE account + SET current_balance = 0 + WHERE user_id = ?`, user.Id) + err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) + if err != nil && err != db.ErrNotFound { + return err + } + + r, err = s.db.Exec(` + UPDATE treasure_chest + SET current_balance = 0 + WHERE user_id = ?`, user.Id) + err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) + if err != nil && err != db.ErrNotFound { + return err + } + + rows, err := s.db.Queryx(`SELECT account_id, treasure_chest_id, value FROM "transaction" WHERE user_id = ?`, user.Id) + err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) + if err != nil && err != db.ErrNotFound { + return err + } + defer func() { + err := rows.Close() + if err != nil { + log.Error("transaction RecalculateBalances: %v", err) + } + }() + + transaction := &types.Transaction{} + for rows.Next() { + err = rows.StructScan(transaction) + err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) + if err != nil { + return err + } + + if transaction.AccountId != nil { + r, err = s.db.Exec(` + UPDATE account + SET current_balance = current_balance + ? + WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) + err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) + if err != nil { + return err + } + } + if transaction.TreasureChestId != nil { + r, err = s.db.Exec(` + UPDATE treasure_chest + SET current_balance = current_balance + ? + WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) + err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) + if err != nil { + return err + } + } + } + + return nil +} + +func (s TransactionImpl) validateAndEnrichTransaction(oldTransaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { var ( id uuid.UUID @@ -193,7 +308,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac rowCount int ) - if transaction == nil { + if oldTransaction == nil { id, err = s.random.UUID() if err != nil { return nil, types.ErrInternal @@ -201,9 +316,9 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac createdAt = s.clock.Now() createdBy = userId } else { - id = transaction.Id - createdAt = transaction.CreatedAt - createdBy = transaction.CreatedBy + id = oldTransaction.Id + createdAt = oldTransaction.CreatedAt + createdBy = oldTransaction.CreatedBy time := s.clock.Now() updatedAt = &time updatedBy = userId @@ -274,7 +389,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac } } - return &types.Transaction{ + transaction := types.Transaction{ Id: id, UserId: userId, @@ -288,5 +403,21 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac CreatedBy: createdBy, UpdatedAt: updatedAt, UpdatedBy: &updatedBy, - }, nil + } + error := getErrors(transaction) + if error != "" { + transaction.Error = &error + } + return &transaction, nil +} + +func getErrors(transaction types.Transaction) string { + if transaction.Value < 0 { + // panic("unimplemented") + } else if transaction.Value > 0 { + // panic("unimplemented") + } else { + return "\"value\" needs to be positive or negative" + } + return "" } diff --git a/template/account/account.templ b/template/account/account.templ index 5d9c595..2f6a7d1 100644 --- a/template/account/account.templ +++ b/template/account/account.templ @@ -13,7 +13,7 @@ templ Account(accounts []*types.Account) { class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center" > @svg.Plus() -

New Account

+

New Account

for _, account := range accounts { @@ -80,7 +80,11 @@ templ AccountItem(account *types.Account) {

{ account.Name }

-

{ displayBalance(account.CurrentBalance) }

+ if account.CurrentBalance < 0 { +

{ displayBalance(account.CurrentBalance) }

+ } else { +

{ displayBalance(account.CurrentBalance) }

+ } -