package service import ( "errors" "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 ( transactionRecurringMetric = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "spendsparrow_transaction_recurring_total", Help: "The total of transactionRecurring operations", }, []string{"operation"}, ) ) type TransactionRecurring interface { Add(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error) Update(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error) GetAll(user *types.User) ([]*types.TransactionRecurring, error) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error) Delete(user *types.User, id string) error } type TransactionRecurringImpl struct { db *sqlx.DB clock Clock random Random settings *types.Settings } func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) TransactionRecurring { return TransactionRecurringImpl{ db: db, clock: clock, random: random, settings: settings, } } func (s TransactionRecurringImpl) Add( user *types.User, transactionRecurringInput types.TransactionRecurringInput) (*types.TransactionRecurring, error) { transactionRecurringMetric.WithLabelValues("add").Inc() if user == nil { return nil, ErrUnauthorized } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transactionRecurring Add", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() transactionRecurring, err := s.validateAndEnrichTransactionRecurring(tx, nil, user.Id, transactionRecurringInput) if err != nil { return nil, err } r, err := tx.NamedExec(` INSERT INTO "transaction_recurring" (id, user_id, interval_months, active, party, description, account_id, treasure_chest_id, value, created_at, created_by) VALUES (:id, :user_id, :interval_months, :active, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`, transactionRecurring) err = db.TransformAndLogDbError("transactionRecurring Insert", r, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError("transactionRecurring Add", nil, err) if err != nil { return nil, err } return transactionRecurring, nil } func (s TransactionRecurringImpl) Update( user *types.User, input types.TransactionRecurringInput) (*types.TransactionRecurring, error) { transactionRecurringMetric.WithLabelValues("update").Inc() if user == nil { return nil, ErrUnauthorized } uuid, err := uuid.Parse(input.Id) if err != nil { log.Error("transactionRecurring update: %v", err) return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transactionRecurring Update", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() transactionRecurring := &types.TransactionRecurring{} err = tx.Get(transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError("transactionRecurring Update", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest) } return nil, types.ErrInternal } transactionRecurring, err = s.validateAndEnrichTransactionRecurring(tx, transactionRecurring, user.Id, input) if err != nil { return nil, err } r, err := tx.NamedExec(` UPDATE transaction_recurring SET interval_months = :interval_months, active = :active, party = :party, description = :description, account_id = :account_id, treasure_chest_id = :treasure_chest_id, value = :value, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id AND user_id = :user_id`, transactionRecurring) err = db.TransformAndLogDbError("transactionRecurring Update", r, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError("transactionRecurring Update", nil, err) if err != nil { return nil, err } return transactionRecurring, nil } func (s TransactionRecurringImpl) GetAll(user *types.User) ([]*types.TransactionRecurring, error) { transactionRecurringMetric.WithLabelValues("get_all_by_account").Inc() if user == nil { return nil, ErrUnauthorized } transactionRecurrings := make([]*types.TransactionRecurring, 0) err := s.db.Select(&transactionRecurrings, ` SELECT * FROM transaction_recurring WHERE user_id = ? ORDER BY created_at DESC`, user.Id) err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err) if err != nil { return nil, err } return transactionRecurrings, nil } func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) { transactionRecurringMetric.WithLabelValues("get_all_by_account").Inc() if user == nil { return nil, ErrUnauthorized } accountUuid, err := uuid.Parse(accountId) if err != nil { log.Error("transactionRecurring GetAllByAccount: %v", err) return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() var rowCount int err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id) err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest) } return nil, types.ErrInternal } transactionRecurrings := make([]*types.TransactionRecurring, 0) err = tx.Select(&transactionRecurrings, ` SELECT * FROM transaction_recurring WHERE user_id = ? AND account_id = ? ORDER BY created_at DESC`, user.Id, accountUuid) err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err) if err != nil { return nil, err } return transactionRecurrings, nil } func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error) { transactionRecurringMetric.WithLabelValues("get_all_by_treasurechest").Inc() if user == nil { return nil, ErrUnauthorized } treasureChestUuid, err := uuid.Parse(treasureChestId) if err != nil { log.Error("transactionRecurring GetAllByTreasureChest: %v", err) return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() var rowCount int err = tx.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id) err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest) } return nil, types.ErrInternal } transactionRecurrings := make([]*types.TransactionRecurring, 0) err = tx.Select(&transactionRecurrings, ` SELECT * FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ? ORDER BY created_at DESC`, user.Id, treasureChestUuid) err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err) if err != nil { return nil, err } err = tx.Commit() err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err) if err != nil { return nil, err } return transactionRecurrings, nil } func (s TransactionRecurringImpl) Delete(user *types.User, id string) error { transactionRecurringMetric.WithLabelValues("delete").Inc() if user == nil { return ErrUnauthorized } uuid, err := uuid.Parse(id) if err != nil { log.Error("transactionRecurring delete: %v", err) return fmt.Errorf("could not parse Id: %w", ErrBadRequest) } tx, err := s.db.Beginx() err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err) if err != nil { return nil } defer func() { _ = tx.Rollback() }() var transactionRecurring types.TransactionRecurring err = tx.Get(&transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid) err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err) if err != nil { return err } r, err := tx.Exec("DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id) err = db.TransformAndLogDbError("transactionRecurring Delete", r, err) if err != nil { return err } err = tx.Commit() err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err) if err != nil { return err } return nil } func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring( tx *sqlx.Tx, oldTransactionRecurring *types.TransactionRecurring, userId uuid.UUID, input types.TransactionRecurringInput) (*types.TransactionRecurring, error) { var ( id uuid.UUID accountUuid *uuid.UUID treasureChestUuid *uuid.UUID createdAt time.Time createdBy uuid.UUID updatedAt *time.Time updatedBy uuid.UUID intervalMonths int64 err error rowCount int ) if oldTransactionRecurring == nil { id, err = s.random.UUID() if err != nil { return nil, types.ErrInternal } createdAt = s.clock.Now() createdBy = userId } else { id = oldTransactionRecurring.Id createdAt = oldTransactionRecurring.CreatedAt createdBy = oldTransactionRecurring.CreatedBy time := s.clock.Now() updatedAt = &time updatedBy = userId } hasAccount := false hasTreasureChest := false if input.AccountId != "" { temp, err := uuid.Parse(input.AccountId) if err != nil { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) } accountUuid = &temp err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId) err = db.TransformAndLogDbError("transactionRecurring validate", nil, err) if err != nil { return nil, err } if rowCount == 0 { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("account not found: %w", ErrBadRequest) } hasAccount = true } if input.TreasureChestId != "" { temp, err := uuid.Parse(input.TreasureChestId) if err != nil { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) } treasureChestUuid = &temp var treasureChest types.TreasureChest err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId) err = db.TransformAndLogDbError("transactionRecurring 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) } hasTreasureChest = true } if !hasAccount && !hasTreasureChest { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest) } if hasAccount && hasTreasureChest { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", ErrBadRequest) } valueFloat, err := strconv.ParseFloat(input.Value, 64) if err != nil { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest) } valueInt := int64(valueFloat * DECIMALS_MULTIPLIER) 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 } } intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0) if err != nil { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest) } if intervalMonths < 1 { log.Error("transactionRecurring validate: %v", err) return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest) } active := input.Active == "on" transactionRecurring := types.TransactionRecurring{ Id: id, UserId: userId, IntervalMonths: intervalMonths, Active: active, LastExecution: nil, Party: input.Party, Description: input.Description, AccountId: accountUuid, TreasureChestId: treasureChestUuid, Value: valueInt, CreatedAt: createdAt, CreatedBy: createdBy, UpdatedAt: updatedAt, UpdatedBy: &updatedBy, } return &transactionRecurring, nil }