feat(transaction-recurring): #100 generate transactions
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m7s

This commit was merged in pull request #136.
This commit is contained in:
2025-05-29 00:00:19 +02:00
parent 1e7f2878ba
commit 76da3ca703
7 changed files with 183 additions and 87 deletions

View File

@@ -12,6 +12,15 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type migrationLogger struct{}
func (l migrationLogger) Printf(format string, v ...interface{}) {
log.Info(format, v...)
}
func (l migrationLogger) Verbose() bool {
return false
}
func RunMigrations(db *sqlx.DB, pathPrefix string) error { func RunMigrations(db *sqlx.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{}) driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil { if err != nil {
@@ -28,13 +37,12 @@ func RunMigrations(db *sqlx.DB, pathPrefix string) error {
return types.ErrInternal return types.ErrInternal
} }
err = m.Up() m.Log = migrationLogger{}
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) { if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
log.Error("Could not run migrations: %v", err) log.Error("Could not run migrations: %v", err)
return types.ErrInternal return types.ErrInternal
} }
}
return nil return nil
} }

View File

@@ -0,0 +1,22 @@
package middleware
import (
"net/http"
"spend-sparrow/service"
)
func GenerateRecurringTransactions(transactionRecurring service.TransactionRecurring) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r)
if user == nil || r.Method != http.MethodGet {
next.ServeHTTP(w, r)
return
}
_ = transactionRecurring.GenerateTransactions(user)
next.ServeHTTP(w, r)
})
}
}

View File

@@ -1,12 +1,15 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"spend-sparrow/handler/middleware" "spend-sparrow/handler/middleware"
"spend-sparrow/service" "spend-sparrow/service"
t "spend-sparrow/template/transaction" t "spend-sparrow/template/transaction"
"spend-sparrow/types" "spend-sparrow/types"
"spend-sparrow/utils" "spend-sparrow/utils"
"strconv"
"time"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/google/uuid" "github.com/google/uuid"
@@ -137,20 +140,66 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
} }
var ( var (
transaction *types.Transaction id uuid.UUID
err error err error
) )
input := types.TransactionInput{
Id: r.PathValue("id"), idStr := r.PathValue("id")
AccountId: r.FormValue("account-id"), if idStr != "new" {
TreasureChestId: r.FormValue("treasure-chest-id"), id, err = uuid.Parse(idStr)
Value: r.FormValue("value"), if err != nil {
Timestamp: r.FormValue("timestamp"), handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest))
return
}
}
accountIdStr := r.FormValue("account-id")
var accountId *uuid.UUID
if accountIdStr != "" {
i, err := uuid.Parse(accountIdStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse account id: %w", service.ErrBadRequest))
return
}
accountId = &i
}
treasureChestIdStr := r.FormValue("treasure-chest-id")
var treasureChestId *uuid.UUID
if treasureChestIdStr != "" {
i, err := uuid.Parse(treasureChestIdStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", service.ErrBadRequest))
return
}
treasureChestId = &i
}
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest))
return
}
value := int64(valueF * service.DECIMALS_MULTIPLIER)
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
if err != nil {
handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest))
return
}
input := types.Transaction{
Id: id,
AccountId: accountId,
TreasureChestId: treasureChestId,
Value: value,
Timestamp: timestamp,
Party: r.FormValue("party"), Party: r.FormValue("party"),
Description: r.FormValue("description"), Description: r.FormValue("description"),
} }
if input.Id == "new" { var transaction *types.Transaction
if idStr == "new" {
transaction, err = h.s.Add(user, input) transaction, err = h.s.Add(user, input)
if err != nil { if err != nil {
handleError(w, r, err) handleError(w, r, err)

View File

@@ -126,7 +126,7 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
accountService := service.NewAccount(d, randomService, clockService) accountService := service.NewAccount(d, randomService, clockService)
treasureChestService := service.NewTreasureChest(d, randomService, clockService) treasureChestService := service.NewTreasureChest(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService) transactionService := service.NewTransaction(d, randomService, clockService)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService) transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
render := handler.NewRender() render := handler.NewRender()
indexHandler := handler.NewIndex(render) indexHandler := handler.NewIndex(render)
@@ -148,6 +148,7 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
return middleware.Wrapper( return middleware.Wrapper(
router, router,
middleware.GenerateRecurringTransactions(transactionRecurringService),
middleware.SecurityHeaders(serverSettings), middleware.SecurityHeaders(serverSettings),
middleware.CacheControl, middleware.CacheControl,
middleware.CrossSiteRequestForgery(authService), middleware.CrossSiteRequestForgery(authService),

View File

@@ -3,12 +3,10 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"strconv"
"time"
"spend-sparrow/db" "spend-sparrow/db"
"spend-sparrow/log" "spend-sparrow/log"
"spend-sparrow/types" "spend-sparrow/types"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -27,8 +25,8 @@ var (
) )
type Transaction interface { type Transaction interface {
Add(user *types.User, transaction types.TransactionInput) (*types.Transaction, error) Add(user *types.User, transaction types.Transaction) (*types.Transaction, error)
Update(user *types.User, transaction types.TransactionInput) (*types.Transaction, error) Update(user *types.User, transaction types.Transaction) (*types.Transaction, error)
Get(user *types.User, id string) (*types.Transaction, error) Get(user *types.User, id string) (*types.Transaction, error)
GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
Delete(user *types.User, id string) error Delete(user *types.User, id string) error
@@ -50,7 +48,7 @@ func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction {
} }
} }
func (s TransactionImpl) Add(user *types.User, transactionInput types.TransactionInput) (*types.Transaction, error) { func (s TransactionImpl) Add(user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
transactionMetric.WithLabelValues("add").Inc() transactionMetric.WithLabelValues("add").Inc()
if user == nil { if user == nil {
@@ -112,16 +110,11 @@ func (s TransactionImpl) Add(user *types.User, transactionInput types.Transactio
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) (*types.Transaction, error) { func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*types.Transaction, error) {
transactionMetric.WithLabelValues("update").Inc() transactionMetric.WithLabelValues("update").Inc()
if user == nil { if user == nil {
return nil, ErrUnauthorized 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)
}
tx, err := s.db.Beginx() tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Update", nil, err) err = db.TransformAndLogDbError("transaction Update", nil, err)
@@ -133,7 +126,7 @@ func (s TransactionImpl) Update(user *types.User, input types.TransactionInput)
}() }()
transaction := &types.Transaction{} transaction := &types.Transaction{}
err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
err = db.TransformAndLogDbError("transaction Update", nil, err) err = db.TransformAndLogDbError("transaction Update", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, db.ErrNotFound) {
@@ -440,11 +433,9 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
return nil return nil
} }
func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
var ( var (
id uuid.UUID id uuid.UUID
accountUuid *uuid.UUID
treasureChestUuid *uuid.UUID
createdAt time.Time createdAt time.Time
createdBy uuid.UUID createdBy uuid.UUID
updatedAt *time.Time updatedAt *time.Time
@@ -470,14 +461,8 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
updatedBy = userId updatedBy = userId
} }
if input.AccountId != "" { if input.AccountId != nil {
temp, err := uuid.Parse(input.AccountId) err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
if err != nil {
log.Error("transaction 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("transaction validate", nil, err) err = db.TransformAndLogDbError("transaction validate", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -488,15 +473,9 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
} }
} }
if input.TreasureChestId != "" { if input.TreasureChestId != nil {
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
var treasureChest types.TreasureChest var treasureChest types.TreasureChest
err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId) err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
err = db.TransformAndLogDbError("transaction validate", nil, err) err = db.TransformAndLogDbError("transaction validate", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, db.ErrNotFound) {
@@ -509,19 +488,6 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
} }
} }
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 * DECIMALS_MULTIPLIER)
timestamp, err := time.Parse("2006-01-02", input.Timestamp)
if err != nil {
log.Error("transaction validate: %v", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
}
if input.Party != "" { if input.Party != "" {
err = validateString(input.Party, "party") err = validateString(input.Party, "party")
if err != nil { if err != nil {
@@ -539,10 +505,10 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
Id: id, Id: id,
UserId: userId, UserId: userId,
AccountId: accountUuid, AccountId: input.AccountId,
TreasureChestId: treasureChestUuid, TreasureChestId: input.TreasureChestId,
Value: valueInt, Value: input.Value,
Timestamp: timestamp, Timestamp: input.Timestamp,
Party: input.Party, Party: input.Party,
Description: input.Description, Description: input.Description,
Error: nil, Error: nil,

View File

@@ -33,19 +33,23 @@ type TransactionRecurring interface {
GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error) GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(user *types.User, id string) error Delete(user *types.User, id string) error
GenerateTransactions(user *types.User) error
} }
type TransactionRecurringImpl struct { type TransactionRecurringImpl struct {
db *sqlx.DB db *sqlx.DB
clock Clock clock Clock
random Random random Random
transaction Transaction
} }
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock) TransactionRecurring { func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transaction Transaction) TransactionRecurring {
return TransactionRecurringImpl{ return TransactionRecurringImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
transaction: transaction,
} }
} }
@@ -326,6 +330,62 @@ func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
return nil return nil
} }
func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
if user == nil {
return ErrUnauthorized
}
now := s.clock.Now()
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
recurringTransactions := make([]*types.TransactionRecurring, 0)
err = tx.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 := tx.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
}
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring( func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
tx *sqlx.Tx, tx *sqlx.Tx,
oldTransactionRecurring *types.TransactionRecurring, oldTransactionRecurring *types.TransactionRecurring,

View File

@@ -34,16 +34,6 @@ type Transaction struct {
UpdatedBy *uuid.UUID `db:"updated_by"` UpdatedBy *uuid.UUID `db:"updated_by"`
} }
type TransactionInput struct {
Id string
AccountId string
TreasureChestId string
Value string
Timestamp string
Party string
Description string
}
type TransactionItemsFilter struct { type TransactionItemsFilter struct {
AccountId string AccountId string
TreasureChestId string TreasureChestId string