Files
spend-sparrow/service/transaction_recurring.go
Tim Wundenberg 5d1aa93d18
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled
feat(transaction-recurring): #100 generate transactions
2025-05-29 00:01:14 +02:00

522 lines
15 KiB
Go

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
GenerateTransactions(user *types.User) error
}
type TransactionRecurringImpl struct {
db *sqlx.DB
clock Clock
random Random
transaction Transaction
}
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transaction Transaction) TransactionRecurring {
return TransactionRecurringImpl{
db: db,
clock: clock,
random: random,
transaction: transaction,
}
}
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,
next_execution, party, description, account_id, treasure_chest_id, value, created_at, created_by)
VALUES (:id, :user_id, :interval_months,
:next_execution, :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,
next_execution = :next_execution,
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) GenerateTransactions(user *types.User) error {
now := s.clock.Now()
recurringTransactions := make([]*types.TransactionRecurring, 0)
err := s.db.Select(&recurringTransactions, `
SELECT * FROM transaction_recurring WHERE user_id = ? AND next_execution <= ?`,
user.Id, now)
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
for _, transactionRecurring := range recurringTransactions {
transaction := types.Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, err = s.transaction.Add(user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := s.db.Exec(`UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", r, 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)
}
var nextExecution *time.Time = nil
if input.NextExecution != "" {
t, err := time.Parse("2006-01-02", input.NextExecution)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
}
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
nextExecution = &t
}
transactionRecurring := types.TransactionRecurring{
Id: id,
UserId: userId,
IntervalMonths: intervalMonths,
NextExecution: nextExecution,
Party: input.Party,
Description: input.Description,
AccountId: accountUuid,
TreasureChestId: treasureChestUuid,
Value: valueInt,
CreatedAt: createdAt,
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}
return &transactionRecurring, nil
}