package service import ( "errors" "fmt" "spend-sparrow/db" "spend-sparrow/log" "spend-sparrow/types" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( transactionMetric = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "spendsparrow_transaction_total", Help: "The total of transaction operations", }, []string{"operation"}, ) ) type Transaction interface { Add(user *types.User, transaction types.Transaction) (*types.Transaction, error) Update(user *types.User, transaction types.Transaction) (*types.Transaction, error) Get(user *types.User, id string) (*types.Transaction, error) GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) Delete(user *types.User, id string) error RecalculateBalances(user *types.User) error } type TransactionImpl struct { db *sqlx.DB clock Clock random Random } func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction { return TransactionImpl{ db: db, clock: clock, random: random, } } func (s TransactionImpl) Add(user *types.User, transactionInput types.Transaction) (*types.Transaction, error) { transactionMetric.WithLabelValues("add").Inc() if user == nil { return nil, ErrUnauthorized } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transaction Add", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() transaction, err := s.validateAndEnrichTransaction(tx, nil, user.Id, transactionInput) if err != nil { return nil, err } r, err := tx.NamedExec(` INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, party, description, error, created_at, created_by) VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, :party, :description, :error, :created_at, :created_by)`, transaction) err = db.TransformAndLogDbError("transaction Insert", r, err) if err != nil { return nil, err } if transaction.Error == nil && transaction.AccountId != nil { r, err = tx.Exec(` UPDATE account SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError("transaction Add", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err = tx.Exec(` UPDATE treasure_chest SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError("transaction Add", r, err) if err != nil { return nil, err } } err = tx.Commit() err = db.TransformAndLogDbError("transaction Add", nil, err) if err != nil { return nil, err } return transaction, nil } func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*types.Transaction, error) { transactionMetric.WithLabelValues("update").Inc() if user == nil { return nil, ErrUnauthorized } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transaction Update", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() transaction := &types.Transaction{} err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id) err = db.TransformAndLogDbError("transaction Update", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest) } return nil, types.ErrInternal } if transaction.Error == nil && transaction.AccountId != nil { r, err := tx.Exec(` UPDATE account SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError("transaction Update", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.Exec(` UPDATE treasure_chest SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError("transaction Update", r, err) if err != nil { return nil, err } } transaction, err = s.validateAndEnrichTransaction(tx, transaction, user.Id, input) if err != nil { return nil, err } if transaction.Error == nil && transaction.AccountId != nil { r, err := tx.Exec(` UPDATE account SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError("transaction Update", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.Exec(` UPDATE treasure_chest SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError("transaction Update", r, err) if err != nil { return nil, err } } r, err := tx.NamedExec(` UPDATE "transaction" SET account_id = :account_id, treasure_chest_id = :treasure_chest_id, value = :value, timestamp = :timestamp, party = :party, description = :description, error = :error, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id AND user_id = :user_id`, transaction) err = db.TransformAndLogDbError("transaction Update", r, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError("transaction Update", nil, err) if err != nil { return nil, err } return transaction, nil } func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) { transactionMetric.WithLabelValues("get").Inc() if user == nil { return nil, ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { log.Error("transaction get: %v", err) return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) } var transaction types.Transaction err = s.db.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError("transaction Get", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest) } return nil, types.ErrInternal } return &transaction, nil } func (s TransactionImpl) GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) { transactionMetric.WithLabelValues("get_all").Inc() if user == nil { return nil, ErrUnauthorized } transactions := make([]*types.Transaction, 0) err := s.db.Select(&transactions, ` SELECT * FROM "transaction" WHERE user_id = ? AND (? = '' OR account_id = ?) AND (? = '' OR treasure_chest_id = ?) AND (? = '' OR (? = "true" AND error IS NOT NULL) OR (? = "false" AND error IS NULL) ) ORDER BY timestamp DESC`, user.Id, filter.AccountId, filter.AccountId, filter.TreasureChestId, filter.TreasureChestId, filter.Error, filter.Error, filter.Error) err = db.TransformAndLogDbError("transaction GetAll", nil, err) if err != nil { return nil, err } return transactions, nil } func (s TransactionImpl) Delete(user *types.User, id string) error { transactionMetric.WithLabelValues("delete").Inc() if user == nil { return ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { log.Error("transaction delete: %v", err) return fmt.Errorf("could not parse Id: %w", ErrBadRequest) } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transaction Delete", nil, err) if err != nil { return nil } defer func() { _ = tx.Rollback() }() var transaction types.Transaction err = tx.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError("transaction Delete", nil, err) if err != nil { return err } if transaction.Error == nil && transaction.AccountId != nil { r, err := tx.Exec(` UPDATE account SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError("transaction Delete", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.Exec(` UPDATE treasure_chest SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError("transaction Delete", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } } r, err := tx.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id) err = db.TransformAndLogDbError("transaction Delete", r, err) if err != nil { return err } err = tx.Commit() err = db.TransformAndLogDbError("transaction Delete", nil, err) if err != nil { return err } return nil } func (s TransactionImpl) RecalculateBalances(user *types.User) error { transactionMetric.WithLabelValues("recalculate").Inc() if user == nil { return ErrUnauthorized } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) if err != nil { return err } defer func() { _ = tx.Rollback() }() r, err := tx.Exec(` UPDATE account SET current_balance = 0 WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } r, err = tx.Exec(` UPDATE treasure_chest SET current_balance = 0 WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } rows, err := tx.Queryx(` SELECT * FROM "transaction" WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } defer func() { err := rows.Close() if err != nil { log.Error("transaction RecalculateBalances: %v", err) } }() var transaction types.Transaction for rows.Next() { err = rows.StructScan(&transaction) err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) if err != nil { return err } s.updateErrors(&transaction) r, err = tx.Exec(` UPDATE "transaction" SET error = ? WHERE user_id = ? AND id = ?`, transaction.Error, user.Id, transaction.Id) err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err) if err != nil { return err } if transaction.Error != nil { continue } if transaction.AccountId != nil { r, err = tx.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 = tx.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 } } } err = tx.Commit() err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err) if err != nil { return err } return nil } func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) { var ( id uuid.UUID createdAt time.Time createdBy uuid.UUID updatedAt *time.Time updatedBy uuid.UUID err error rowCount int ) if oldTransaction == nil { id, err = s.random.UUID() if err != nil { return nil, types.ErrInternal } createdAt = s.clock.Now() createdBy = userId } else { id = oldTransaction.Id createdAt = oldTransaction.CreatedAt createdBy = oldTransaction.CreatedBy time := s.clock.Now() updatedAt = &time updatedBy = userId } if input.AccountId != nil { err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId) err = db.TransformAndLogDbError("transaction validate", nil, err) if err != nil { return nil, err } if rowCount == 0 { log.Error("transaction validate: %v", err) return nil, fmt.Errorf("account not found: %w", ErrBadRequest) } } if input.TreasureChestId != nil { var treasureChest types.TreasureChest err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId) err = db.TransformAndLogDbError("transaction validate", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest) } return nil, err } if treasureChest.ParentId == nil { return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest) } } if input.Party != "" { err = validateString(input.Party, "party") if err != nil { return nil, err } } if input.Description != "" { err = validateString(input.Description, "description") if err != nil { return nil, err } } transaction := types.Transaction{ Id: id, UserId: userId, AccountId: input.AccountId, TreasureChestId: input.TreasureChestId, Value: input.Value, Timestamp: input.Timestamp, Party: input.Party, Description: input.Description, Error: nil, CreatedAt: createdAt, CreatedBy: createdBy, UpdatedAt: updatedAt, UpdatedBy: &updatedBy, } s.updateErrors(&transaction) return &transaction, nil } func (s TransactionImpl) updateErrors(transaction *types.Transaction) { errorStr := "" switch { case transaction.Value < 0: if transaction.TreasureChestId == nil { errorStr = "no treasure chest specified" } case transaction.Value > 0: if transaction.AccountId == nil && transaction.TreasureChestId == nil { errorStr = "either an account or a treasure chest needs to be specified" } else if transaction.AccountId != nil && transaction.TreasureChestId != nil { errorStr = "positive amounts can only be applied to either an account or a treasure chest" } default: errorStr = "\"value\" needs to be specified" } if errorStr == "" { transaction.Error = nil } else { transaction.Error = &errorStr } }