From 3dc9f8ec6f78425d979c303d1046a29d2c759194 Mon Sep 17 00:00:00 2001 From: Tim Wundenberg Date: Fri, 16 May 2025 17:09:13 +0200 Subject: [PATCH] feat(transaction): #80 add errors to transactions --- handler/transaction.go | 43 +++- migration/005_transaction_add_error.up.sql | 2 + service/transaction.go | 249 +++++++++++---------- template/svg/default.templ | 12 + template/transaction/transaction.templ | 95 +++++--- types/transaction.go | 2 + 6 files changed, 254 insertions(+), 149 deletions(-) create mode 100644 migration/005_transaction_add_error.up.sql diff --git a/handler/transaction.go b/handler/transaction.go index f0714f8..8bb27a0 100644 --- a/handler/transaction.go +++ b/handler/transaction.go @@ -3,12 +3,14 @@ package handler import ( "net/http" "spend-sparrow/handler/middleware" + "spend-sparrow/log" "spend-sparrow/service" t "spend-sparrow/template/transaction" "spend-sparrow/types" "spend-sparrow/utils" "github.com/a-h/templ" + "github.com/google/uuid" ) type Transaction interface { @@ -85,7 +87,9 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { return } - comp := t.Transaction(transactions, accounts, treasureChests) + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + + comp := t.Transaction(transactions, accountMap, treasureChestMap) h.r.RenderLayout(r, w, comp, user) } } @@ -127,7 +131,8 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { if r.URL.Query().Get("edit") == "true" { comp = t.EditTransaction(transaction, accounts, treasureChests) } else { - comp = t.TransactionItem(transaction) + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + comp = t.TransactionItem(transaction, accountMap, treasureChestMap) } h.r.Render(r, w, comp) } @@ -169,7 +174,20 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { } } - comp := t.TransactionItem(transaction) + accounts, err := h.account.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + treasureChests, err := h.treasureChest.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) + comp := t.TransactionItem(transaction, accountMap, treasureChestMap) h.r.Render(r, w, comp) } } @@ -191,3 +209,22 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { } } } + +func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) { + accountMap := make(map[uuid.UUID]string, 0) + for _, account := range accounts { + accountMap[account.Id] = account.Name + } + treasureChestMap := make(map[uuid.UUID]string, 0) + root := "" + for _, treasureChest := range treasureChests { + if treasureChest.ParentId == uuid.Nil { + root = treasureChest.Name + " > " + treasureChestMap[treasureChest.Id] = treasureChest.Name + } else { + treasureChestMap[treasureChest.Id] = root + treasureChest.Name + } + } + log.Info("treasureChestMap: %v", treasureChestMap) + return accountMap, treasureChestMap +} diff --git a/migration/005_transaction_add_error.up.sql b/migration/005_transaction_add_error.up.sql new file mode 100644 index 0000000..11fd775 --- /dev/null +++ b/migration/005_transaction_add_error.up.sql @@ -0,0 +1,2 @@ + +ALTER TABLE "transaction" ADD COLUMN error TEXT; diff --git a/service/transaction.go b/service/transaction.go index 3a4b5de..69b60d7 100644 --- a/service/transaction.go +++ b/service/transaction.go @@ -64,8 +64,8 @@ func (s TransactionImpl) Add(user *types.User, transactionInput types.Transactio } r, err := s.db.NamedExec(` - INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, note, created_at, created_by) - VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, :note, :created_at, :created_by)`, transaction) + INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp, note, error, created_at, created_by) + VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, :note, :error, :created_at, :created_by)`, transaction) err = db.TransformAndLogDbError("transaction Insert", r, err) if err != nil { return nil, err @@ -141,6 +141,7 @@ func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) value = :value, timestamp = :timestamp, note = :note, + error = :error, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id @@ -184,7 +185,7 @@ func (s TransactionImpl) GetAll(user *types.User) ([]*types.Transaction, error) return nil, ErrUnauthorized } transactions := make([]*types.Transaction, 0) - err := s.db.Select(&transactions, `SELECT * FROM "transaction" WHERE user_id = ? ORDER BY timestamp`, user.Id) + err := s.db.Select(&transactions, `SELECT * FROM "transaction" WHERE user_id = ? ORDER BY timestamp DESC`, user.Id) err = db.TransformAndLogDbError("transaction GetAll", nil, err) if err != nil { return nil, err @@ -223,119 +224,6 @@ func (s TransactionImpl) Delete(user *types.User, id string) error { return nil } -func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { - - var ( - id uuid.UUID - accountUuid *uuid.UUID - treasureChestUuid *uuid.UUID - createdAt time.Time - createdBy uuid.UUID - updatedAt *time.Time - updatedBy uuid.UUID - - err error - rowCount int - ) - - if transaction == nil { - id, err = s.random.UUID() - if err != nil { - return nil, types.ErrInternal - } - createdAt = s.clock.Now() - createdBy = userId - } else { - id = transaction.Id - createdAt = transaction.CreatedAt - createdBy = transaction.CreatedBy - time := s.clock.Now() - updatedAt = &time - updatedBy = userId - } - - if input.AccountId != "" { - temp, err := uuid.Parse(input.AccountId) - if err != nil { - log.Error("transaction validate: %v", err) - return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) - } - accountUuid = &temp - err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId) - err = db.TransformAndLogDbError("transaction validate", nil, err) - if err != nil { - return nil, err - } - if rowCount == 0 { - log.Error("transaction validate: %v", err) - return nil, fmt.Errorf("account not found: %w", ErrBadRequest) - } - - } - - if input.TreasureChestId != "" { - 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 - err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId) - err = db.TransformAndLogDbError("transaction validate", nil, err) - if err != nil { - return nil, err - } - if rowCount == 0 { - log.Error("transaction validate: %v", err) - return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest) - } - } - - 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 * 100) - - timestampTime, err := time.Parse("2006-01-02T15:04", input.Timestamp) - if err != nil { - log.Error("transaction validate: %v", err) - return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest) - } - - if input.TimezoneOffsetMinutes != "" { - timezoneOffsetMinutes, err := strconv.Atoi(input.TimezoneOffsetMinutes) - if err != nil { - return nil, fmt.Errorf("could not parse timezone offset: %w", ErrBadRequest) - } - timestampTime = timestampTime.Add(time.Duration(-1*timezoneOffsetMinutes) * time.Minute) - } - - if input.Note != "" { - err = validateString(input.Note, "note") - if err != nil { - return nil, err - } - } - - return &types.Transaction{ - Id: id, - UserId: userId, - - AccountId: accountUuid, - TreasureChestId: treasureChestUuid, - Value: valueInt, - Timestamp: timestampTime, - Note: input.Note, - - CreatedAt: createdAt, - CreatedBy: createdBy, - UpdatedAt: updatedAt, - UpdatedBy: &updatedBy, - }, nil -} - func (s TransactionImpl) RecalculateBalances(user *types.User) error { transactionMetric.WithLabelValues("recalculate").Inc() if user == nil { @@ -404,3 +292,132 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error { return nil } + +func (s TransactionImpl) validateAndEnrichTransaction(oldTransaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) { + + var ( + id uuid.UUID + accountUuid *uuid.UUID + treasureChestUuid *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() + 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 != "" { + temp, err := uuid.Parse(input.AccountId) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) + } + accountUuid = &temp + err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId) + err = db.TransformAndLogDbError("transaction validate", nil, err) + if err != nil { + return nil, err + } + if rowCount == 0 { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("account not found: %w", ErrBadRequest) + } + + } + + if input.TreasureChestId != "" { + 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 + err = s.db.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId) + err = db.TransformAndLogDbError("transaction validate", nil, err) + if err != nil { + return nil, err + } + if rowCount == 0 { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest) + } + } + + 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 * 100) + + timestampTime, err := time.Parse("2006-01-02T15:04", input.Timestamp) + if err != nil { + log.Error("transaction validate: %v", err) + return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest) + } + + if input.TimezoneOffsetMinutes != "" { + timezoneOffsetMinutes, err := strconv.Atoi(input.TimezoneOffsetMinutes) + if err != nil { + return nil, fmt.Errorf("could not parse timezone offset: %w", ErrBadRequest) + } + timestampTime = timestampTime.Add(time.Duration(-1*timezoneOffsetMinutes) * time.Minute) + } + + if input.Note != "" { + err = validateString(input.Note, "note") + if err != nil { + return nil, err + } + } + + transaction := types.Transaction{ + Id: id, + UserId: userId, + + AccountId: accountUuid, + TreasureChestId: treasureChestUuid, + Value: valueInt, + Timestamp: timestampTime, + Note: input.Note, + + CreatedAt: createdAt, + CreatedBy: createdBy, + UpdatedAt: updatedAt, + UpdatedBy: &updatedBy, + } + error := getErrors(transaction) + if error != "" { + transaction.Error = &error + } + return &transaction, nil +} + +func getErrors(transaction types.Transaction) string { + if transaction.Value < 0 { + // panic("unimplemented") + } else if transaction.Value > 0 { + // panic("unimplemented") + } else { + return "\"value\" needs to be positive or negative" + } + return "" +} diff --git a/template/svg/default.templ b/template/svg/default.templ index b008873..587dfe3 100644 --- a/template/svg/default.templ +++ b/template/svg/default.templ @@ -35,3 +35,15 @@ templ Cancel() { } + +templ Info() { + + + + + + + + + +} diff --git a/template/transaction/transaction.templ b/template/transaction/transaction.templ index d3b4138..59965a5 100644 --- a/template/transaction/transaction.templ +++ b/template/transaction/transaction.templ @@ -6,7 +6,8 @@ import "spend-sparrow/template/svg" import "spend-sparrow/types" import "github.com/google/uuid" -templ Transaction(transactions []*types.Transaction, accounts []*types.Account, treasureChests []*types.TreasureChest) { +templ Transaction(transactions []*types.Transaction, accounts, treasureChests map[uuid.UUID]string) { + {{ }}
-
+
for _, transaction := range transactions { - @TransactionItem(transaction) + @TransactionItem(transaction, accounts, treasureChests) }
@@ -137,34 +138,65 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
} -templ TransactionItem(transaction *types.Transaction) { -
-
-

{ transaction.Timestamp.String() }

-

{ displayBalance(transaction.Value)+" €" }

- - +templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) { + {{ + background := "bg-gray-50" + if transaction.Error != nil { + background = "bg-yellow-50" + } + }} +
+

{ transaction.Timestamp.String() }

+
+ if transaction.Error != nil { + @svg.Info() + }
+
+

+ if transaction.AccountId != nil { + { accounts[*transaction.AccountId] } + } else { +   + } +

+

+ if transaction.TreasureChestId != nil { + { treasureChests[*transaction.TreasureChestId] } + } else { +   + } +

+
+

{ displayBalance(transaction.Value)+" €" }

+ +
} @@ -173,3 +205,6 @@ func displayBalance(balance int64) string { euros := float64(balance) / 100 return fmt.Sprintf("%.2f", euros) } + +func calculateReferences() { +} diff --git a/types/transaction.go b/types/transaction.go index 5ccbb1b..07e48c5 100644 --- a/types/transaction.go +++ b/types/transaction.go @@ -26,6 +26,8 @@ type Transaction struct { // The value of the transacion. Negative for outgoing and positive for incoming transactions. Value int64 + // If an error is present, then the transaction is not valid and should not be used for calculations. + Error *string CreatedAt time.Time `db:"created_at"` CreatedBy uuid.UUID `db:"created_by"` UpdatedAt *time.Time `db:"updated_at"`