554 lines
15 KiB
Go
554 lines
15 KiB
Go
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 *= 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
|
|
}
|
|
}
|