feat(transaction): #80 calculate account balances #84

Merged
tim merged 2 commits from 80-calculate-current-account-balance into prod 2025-05-16 15:14:34 +00:00
10 changed files with 306 additions and 52 deletions

View File

@@ -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 {
@@ -37,9 +39,28 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction", h.handleTransactionPage())
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
r.Handle("POST /transaction/recalculate", h.handleRecalculate())
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
}
func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
err := h.s.RecalculateBalances(user)
if err != nil {
handleError(w, r, err)
return
}
utils.TriggerToastWithStatus(w, r, "success", "Balances recalculated", http.StatusOK)
}
}
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
@@ -66,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)
}
}
@@ -108,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)
}
@@ -150,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)
}
}
@@ -172,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
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE "transaction" ADD COLUMN error TEXT;

View File

@@ -31,6 +31,8 @@ type Transaction interface {
Get(user *types.User, id string) (*types.Transaction, error)
GetAll(user *types.User) ([]*types.Transaction, error)
Delete(user *types.User, id string) error
RecalculateBalances(user *types.User) error
}
type TransactionImpl struct {
@@ -62,13 +64,24 @@ 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
}
if transaction.AccountId != nil {
r, err = s.db.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction UpdateAccount", r, err)
if err != nil {
return nil, err
}
}
return transaction, nil
}
@@ -93,11 +106,33 @@ func (s TransactionImpl) Update(user *types.User, input types.TransactionInput)
return nil, types.ErrInternal
}
if transaction.AccountId != nil {
r, err := s.db.Exec(`
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction UpdateAccount", r, err)
if err != nil {
return nil, err
}
}
transaction, err = s.validateAndEnrichTransaction(transaction, user.Id, input)
if err != nil {
return nil, err
}
if transaction.AccountId != nil {
r, err := s.db.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction UpdateAccount", r, err)
if err != nil {
return nil, err
}
}
r, err := s.db.NamedExec(`
UPDATE "transaction"
SET
@@ -106,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
@@ -149,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
@@ -169,7 +205,17 @@ func (s TransactionImpl) Delete(user *types.User, id string) error {
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
r, err := s.db.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
r, err := s.db.Exec(`
UPDATE account
SET current_balance = current_balance - (SELECT value FROM "transaction" WHERE id = ? AND user_id = ?)
WHERE id = (SELECT account_id FROM "transaction" WHERE id = ? AND user_id = ?)
`, uuid, user.Id, uuid, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
if err != nil {
return err
}
r, err = s.db.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
if err != nil {
return err
@@ -178,7 +224,76 @@ 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) {
func (s TransactionImpl) RecalculateBalances(user *types.User) error {
transactionMetric.WithLabelValues("recalculate").Inc()
if user == nil {
return ErrUnauthorized
}
r, err := s.db.Exec(`
UPDATE account
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
r, err = s.db.Exec(`
UPDATE treasure_chest
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil && err != db.ErrNotFound {
return err
}
rows, err := s.db.Queryx(`SELECT account_id, treasure_chest_id, value FROM "transaction" WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil && err != db.ErrNotFound {
return err
}
defer func() {
err := rows.Close()
if err != nil {
log.Error("transaction RecalculateBalances: %v", err)
}
}()
transaction := &types.Transaction{}
for rows.Next() {
err = rows.StructScan(transaction)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
if transaction.AccountId != nil {
r, err = s.db.Exec(`
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil {
return err
}
}
if transaction.TreasureChestId != nil {
r, err = s.db.Exec(`
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
if err != nil {
return err
}
}
}
return nil
}
func (s TransactionImpl) validateAndEnrichTransaction(oldTransaction *types.Transaction, userId uuid.UUID, input types.TransactionInput) (*types.Transaction, error) {
var (
id uuid.UUID
@@ -193,7 +308,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac
rowCount int
)
if transaction == nil {
if oldTransaction == nil {
id, err = s.random.UUID()
if err != nil {
return nil, types.ErrInternal
@@ -201,9 +316,9 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac
createdAt = s.clock.Now()
createdBy = userId
} else {
id = transaction.Id
createdAt = transaction.CreatedAt
createdBy = transaction.CreatedBy
id = oldTransaction.Id
createdAt = oldTransaction.CreatedAt
createdBy = oldTransaction.CreatedBy
time := s.clock.Now()
updatedAt = &time
updatedBy = userId
@@ -274,7 +389,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac
}
}
return &types.Transaction{
transaction := types.Transaction{
Id: id,
UserId: userId,
@@ -288,5 +403,21 @@ func (s TransactionImpl) validateAndEnrichTransaction(transaction *types.Transac
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}, nil
}
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 ""
}

View File

@@ -13,7 +13,7 @@ templ Account(accounts []*types.Account) {
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
>
@svg.Plus()
<p class="">New Account</p>
<p>New Account</p>
</button>
<div id="account-items" class="my-6 flex flex-col items-center">
for _, account := range accounts {
@@ -80,7 +80,11 @@ templ AccountItem(account *types.Account) {
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<div class="text-xl flex justify-end gap-4">
<p class="mr-auto">{ account.Name }</p>
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p>
if account.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ displayBalance(account.CurrentBalance) }</p>
} else {
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p>
}
<button
hx-get={ "/account/" + account.Id.String() + "?edit=true" }
hx-target="closest #account"

View File

@@ -11,16 +11,24 @@ templ UserComp(user string) {
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
</svg>
</button>
<div class="absolute hidden group-has-hover:block w-full">
<div class="absolute hidden group-has-hover:block w-full z-2">
<ul class="w-fit float-right mr-4 p-3 border-2 border-gray-200 rounded-lg bg-white shadow-lg">
<li class="mb-1">
<a class="button w-full px-1 button-neglect block" hx-post="/api/auth/signout" hx-target="#user-info">Sign Out</a>
</li>
<li class="mb-1">
<li class="mb-4">
<a class="button w-full px-1 button-neglect block" href="/auth/change-password">Change Password</a>
</li>
<li class="mb-4">
<button
hx-post="/transaction/recalculate"
hx-swap="none"
type="button"
class="button text-left w-full px-1 button-neglect block mt-4"
>Recalculate</button>
</li>
<li>
<a class="button w-full px-1 button-neglect text-gray-400 mt-4 block" href="/auth/delete-account" class="">
<a class="button w-full px-1 button-neglect text-gray-400 block" href="/auth/delete-account">
Delete
Account
</a>

View File

@@ -1,7 +1,7 @@
package template
templ Dashboard() {
<div class="">
<div>
<h1 class="text-8xl">
Dashboard
</h1>

View File

@@ -35,3 +35,15 @@ templ Cancel() {
<path fill="currentColor" d="m654 501l346 346l-154 154l-346-346l-346 346L0 847l346-346L0 155L154 1l346 346L846 1l154 154z"></path>
</svg>
}
templ Info() {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="h-6 w-6 text-blue-700">
<mask id="ipSInfo0">
<g fill="none">
<path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="4" d="M24 44a19.937 19.937 0 0 0 14.142-5.858A19.937 19.937 0 0 0 44 24a19.938 19.938 0 0 0-5.858-14.142A19.937 19.937 0 0 0 24 4A19.938 19.938 0 0 0 9.858 9.858A19.938 19.938 0 0 0 4 24a19.937 19.937 0 0 0 5.858 14.142A19.938 19.938 0 0 0 24 44Z"></path>
<path fill="#000" fill-rule="evenodd" d="M24 11a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5Z" clip-rule="evenodd"></path><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M24.5 34V20h-2M21 34h7"></path>
</g>
</mask>
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSInfo0)"></path>
</svg>
}

View File

@@ -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) {
{{ }}
<div class="max-w-6xl mt-10 mx-auto">
<button
hx-get="/transaction/new"
@@ -15,11 +16,11 @@ templ Transaction(transactions []*types.Transaction, accounts []*types.Account,
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
>
@svg.Plus()
<p class="">New Transaction</p>
<p>New Transaction</p>
</button>
<div id="transaction-items" class="my-6 flex flex-col items-center">
<div id="transaction-items" class="my-6">
for _, transaction := range transactions {
@TransactionItem(transaction)
@TransactionItem(transaction, accounts, treasureChests)
}
</div>
</div>
@@ -137,34 +138,65 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
</div>
}
templ TransactionItem(transaction *types.Transaction) {
<div id="transaction" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<div class="text-xl flex justify-end gap-4">
<p class="mr-auto datetime">{ transaction.Timestamp.String() }</p>
<p class="mr-20 text-green-700">{ displayBalance(transaction.Value)+" €" }</p>
<button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/transaction/" + transaction.Id.String() }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) {
{{
background := "bg-gray-50"
if transaction.Error != nil {
background = "bg-yellow-50"
}
}}
<div
id="transaction"
class={ "mt-4 border-1 grid grid-cols-[auto_auto_1fr_1fr_auto_auto_auto] gap-4 items-center text-xl border-gray-300 w-full p-4 rounded-lg " + background }
if transaction.Error != nil {
title={ *transaction.Error }
}
>
<p class="mr-auto datetime">{ transaction.Timestamp.String() }</p>
<div class="w-6">
if transaction.Error != nil {
@svg.Info()
}
</div>
<div>
<p class="text-sm text-gray-500">
if transaction.AccountId != nil {
{ accounts[*transaction.AccountId] }
} else {
&nbsp;
}
</p>
<p class="text-sm text-gray-500">
if transaction.TreasureChestId != nil {
{ treasureChests[*transaction.TreasureChestId] }
} else {
&nbsp;
}
</p>
</div>
<p class="mr-20 text-green-700">{ displayBalance(transaction.Value)+" €" }</p>
<button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/transaction/" + transaction.Id.String() }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
}
@@ -173,3 +205,6 @@ func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func calculateReferences() {
}

View File

@@ -104,7 +104,11 @@ templ TreasureChestItem(treasureChest *types.TreasureChest) {
<div id="treasurechest" class={ "border-1 border-gray-300 w-full p-4 bg-gray-50 rounded-lg" + identation }>
<div class="text-xl flex justify-end items-center gap-4">
<p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
if treasureChest.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
} else {
<p class="mr-20 text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
}
<button
hx-get={ "/treasurechest/" + treasureChest.Id.String() + "?edit=true" }
hx-target="closest #treasurechest"

View File

@@ -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"`