package service import ( "context" "errors" "fmt" "log/slog" "spend-sparrow/internal/db" "spend-sparrow/internal/types" "strconv" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) const page_size = 25 type Transaction interface { Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error) Update(ctx context.Context, user *types.User, transaction types.Transaction) (*types.Transaction, error) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) Delete(ctx context.Context, user *types.User, id string) error RecalculateBalances(ctx context.Context, 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(ctx context.Context, tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) { if user == nil { return nil, ErrUnauthorized } var err error ownsTransaction := false if tx == nil { ownsTransaction = true tx, err = s.db.BeginTxx(ctx, nil) err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() } transaction, err := s.validateAndEnrichTransaction(ctx, tx, nil, user.Id, transactionInput) if err != nil { return nil, err } r, err := tx.NamedExecContext(ctx, ` 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(ctx, "transaction Insert", r, err) if err != nil { return nil, err } if transaction.Error == nil && transaction.AccountId != nil { r, err = tx.ExecContext(ctx, ` UPDATE account SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Add", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err = tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Add", r, err) if err != nil { return nil, err } } if ownsTransaction { err = tx.Commit() err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err) if err != nil { return nil, err } } return transaction, nil } func (s TransactionImpl) Update(ctx context.Context, user *types.User, input types.Transaction) (*types.Transaction, error) { if user == nil { return nil, ErrUnauthorized } tx, err := s.db.BeginTxx(ctx, nil) err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() transaction := &types.Transaction{} err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id) err = db.TransformAndLogDbError(ctx, "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.ExecContext(ctx, ` UPDATE account SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) if err != nil { return nil, err } } transaction, err = s.validateAndEnrichTransaction(ctx, tx, transaction, user.Id, input) if err != nil { return nil, err } if transaction.Error == nil && transaction.AccountId != nil { r, err := tx.ExecContext(ctx, ` UPDATE account SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) if err != nil { return nil, err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) if err != nil { return nil, err } } r, err := tx.NamedExecContext(ctx, ` 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(ctx, "transaction Update", r, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err) if err != nil { return nil, err } return transaction, nil } func (s TransactionImpl) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) { if user == nil { return nil, ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { slog.ErrorContext(ctx, "transaction get", "err", err) return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) } var transaction types.Transaction err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError(ctx, "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(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) { if user == nil { return nil, ErrUnauthorized } var ( page int64 offset int64 err error ) if filter.Page != "" { page, err = strconv.ParseInt(filter.Page, 10, 64) if err != nil { offset = 0 } else { offset = page - 1 offset = offset * page_size } } transactions := make([]*types.Transaction, 0) err = s.db.SelectContext(ctx, &transactions, ` SELECT * FROM "transaction" WHERE user_id = ? AND ($1 = '' OR account_id = $1) AND ($2 = '' OR treasure_chest_id = $2) AND ($3 = '' OR ($3 = "true" AND error IS NOT NULL) OR ($3 = "false" AND error IS NULL) ) ORDER BY timestamp DESC, created_at DESC LIMIT $4 OFFSET $5 `, user.Id, filter.AccountId, filter.TreasureChestId, filter.Error, page_size, offset) err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err) if err != nil { return nil, err } return transactions, nil } func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string) error { if user == nil { return ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { slog.ErrorContext(ctx, "transaction delete", "err", err) return fmt.Errorf("could not parse Id: %w", ErrBadRequest) } tx, err := s.db.BeginTxx(ctx, nil) err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) if err != nil { return nil } defer func() { _ = tx.Rollback() }() var transaction types.Transaction err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) if err != nil { return err } if transaction.Error == nil && transaction.AccountId != nil { r, err := tx.ExecContext(ctx, ` UPDATE account SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } } if transaction.Error == nil && transaction.TreasureChestId != nil { r, err := tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = current_balance - ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } } r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id) err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) if err != nil { return err } err = tx.Commit() err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) if err != nil { return err } return nil } func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error { if user == nil { return ErrUnauthorized } tx, err := s.db.BeginTxx(ctx, nil) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) if err != nil { return err } defer func() { _ = tx.Rollback() }() r, err := tx.ExecContext(ctx, ` UPDATE account SET current_balance = 0 WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } r, err = tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = 0 WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } rows, err := tx.QueryxContext(ctx, ` SELECT * FROM "transaction" WHERE user_id = ?`, user.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) if err != nil && !errors.Is(err, db.ErrNotFound) { return err } defer func() { err := rows.Close() if err != nil { slog.ErrorContext(ctx, "transaction RecalculateBalances", "err", err) } }() var transaction types.Transaction for rows.Next() { err = rows.StructScan(&transaction) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) if err != nil { return err } s.updateErrors(&transaction) r, err = tx.ExecContext(ctx, ` UPDATE "transaction" SET error = ? WHERE user_id = ? AND id = ?`, transaction.Error, user.Id, transaction.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) if err != nil { return err } if transaction.Error != nil { continue } if transaction.AccountId != nil { r, err = tx.ExecContext(ctx, ` UPDATE account SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) if err != nil { return err } } if transaction.TreasureChestId != nil { r, err = tx.ExecContext(ctx, ` UPDATE treasure_chest SET current_balance = current_balance + ? WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) if err != nil { return err } } } err = tx.Commit() err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) if err != nil { return err } return nil } func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, 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(ctx) 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.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId) err = db.TransformAndLogDbError(ctx, "transaction validate", nil, err) if err != nil { return nil, err } if rowCount == 0 { slog.ErrorContext(ctx, "transaction validate", "err", err) return nil, fmt.Errorf("account not found: %w", ErrBadRequest) } } if input.TreasureChestId != nil { var treasureChest types.TreasureChest err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId) err = db.TransformAndLogDbError(ctx, "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(t *types.Transaction) { errorStr := "" switch { case (t.AccountId != nil && t.TreasureChestId != nil && t.Value > 0) || (t.AccountId == nil && t.TreasureChestId == nil): errorStr = "either an account or a treasure chest needs to be specified" case t.Value == 0: errorStr = "\"value\" needs to be specified" } if errorStr == "" { t.Error = nil } else { t.Error = &errorStr } }