package service import ( "fmt" "strconv" "time" "spend-sparrow/db" "spend-sparrow/log" "spend-sparrow/types" "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.TransactionInput) (*types.Transaction, error) Update(user *types.User, transaction types.TransactionInput) (*types.Transaction, error) 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 { db *sqlx.DB clock Clock random Random settings *types.Settings } func NewTransaction(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) Transaction { return TransactionImpl{ db: db, clock: clock, random: random, settings: settings, } } func (s TransactionImpl) Add(user *types.User, transactionInput types.TransactionInput) (*types.Transaction, error) { transactionMetric.WithLabelValues("add").Inc() if user == nil { return nil, ErrUnauthorized } transaction, err := s.validateAndEnrichTransaction(nil, user.Id, transactionInput) if err != nil { return nil, err } 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) 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 } func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) (*types.Transaction, error) { transactionMetric.WithLabelValues("update").Inc() if user == nil { return nil, ErrUnauthorized } uuid, err := uuid.Parse(input.Id) if err != nil { log.Error("transaction update: %v", err) return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) } transaction := &types.Transaction{} err = s.db.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError("transaction Update", nil, err) if err != nil { if err == db.ErrNotFound { return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest) } 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 account_id = :account_id, treasure_chest_id = :treasure_chest_id, value = :value, timestamp = :timestamp, note = :note, 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 } 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 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) ([]*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 = ? ORDER BY timestamp`, user.Id) 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) } 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 } return nil } func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { var ( id uuid.UUID accountUuid *uuid.UUID treasureChestUuid *uuid.UUID createdAt time.Time createdBy uuid.UUID updatedAt *time.Time updatedBy uuid.UUID err error rowCount int ) if transaction == nil { id, err = s.random.UUID() if err != nil { return nil, types.ErrInternal } createdAt = s.clock.Now() createdBy = userId } else { id = transaction.Id createdAt = transaction.CreatedAt createdBy = transaction.CreatedBy time := s.clock.Now() updatedAt = &time updatedBy = userId } if input.AccountId != "" { temp, err := uuid.Parse(input.AccountId) if err != nil { log.Error("transaction validate: %v", err) return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) } accountUuid = &temp err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, 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 != "" { temp, err := uuid.Parse(input.TreasureChestId) if err != nil { log.Error("transaction validate: %v", err) return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) } treasureChestUuid = &temp err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, 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("treasure chest not found: %w", ErrBadRequest) } } valueFloat, err := strconv.ParseFloat(input.Value, 64) if err != nil { log.Error("transaction validate: %v", err) return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest) } valueInt := int64(valueFloat * 100) timestampTime, err := time.Parse("2006-01-02T15:04", input.Timestamp) if err != nil { log.Error("transaction validate: %v", err) return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest) } if input.TimezoneOffsetMinutes != "" { timezoneOffsetMinutes, err := strconv.Atoi(input.TimezoneOffsetMinutes) if err != nil { return nil, fmt.Errorf("could not parse timezone offset: %w", ErrBadRequest) } timestampTime = timestampTime.Add(time.Duration(-1*timezoneOffsetMinutes) * time.Minute) } if input.Note != "" { err = validateString(input.Note, "note") if err != nil { return nil, err } } return &types.Transaction{ Id: id, UserId: userId, AccountId: accountUuid, TreasureChestId: treasureChestUuid, Value: valueInt, Timestamp: timestampTime, Note: input.Note, CreatedAt: createdAt, CreatedBy: createdBy, UpdatedAt: updatedAt, UpdatedBy: &updatedBy, }, nil } 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 }