From dbf272e3f36f663f29f4c0384e40d488c6b1bd44 Mon Sep 17 00:00:00 2001 From: Tim Wundenberg Date: Tue, 13 May 2025 23:02:53 +0200 Subject: [PATCH] feat(transaction): #66 implement transactions --- db/auth.go | 7 + db/error.go | 28 ++ handler/transaction.go | 174 ++++++++++++ input.css | 1 - main.go | 3 + main_test.go | 17 ++ migration/002_account.up.sql | 4 +- migration/003_treasure_chest.up.sql | 2 +- migration/004_transaction.up.sql | 17 ++ service/account.go | 4 +- service/default.go | 6 +- service/transaction.go | 292 ++++++++++++++++++++ service/treasure_chest.go | 30 +- static/js/time.js | 27 ++ template/layout.templ | 1 + template/transaction/default.go | 1 + template/transaction/transaction.templ | 175 ++++++++++++ template/treasurechest/treasure_chest.templ | 36 +-- types/transaction.go | 26 +- 19 files changed, 794 insertions(+), 57 deletions(-) create mode 100644 handler/transaction.go create mode 100644 migration/004_transaction.up.sql create mode 100644 service/transaction.go create mode 100644 static/js/time.js create mode 100644 template/transaction/default.go create mode 100644 template/transaction/transaction.templ diff --git a/db/auth.go b/db/auth.go index ab2106d..8923c38 100644 --- a/db/auth.go +++ b/db/auth.go @@ -170,6 +170,13 @@ func (db AuthSqlite) DeleteUser(userId uuid.UUID) error { return types.ErrInternal } + _, err = tx.Exec("DELETE FROM \"transaction\" WHERE user_id = ?", userId) + if err != nil { + _ = tx.Rollback() + log.Error("Could not delete user: %v", err) + return types.ErrInternal + } + err = tx.Commit() if err != nil { log.Error("Could not commit transaction: %v", err) diff --git a/db/error.go b/db/error.go index d427c11..f83ba68 100644 --- a/db/error.go +++ b/db/error.go @@ -1,10 +1,38 @@ package db import ( + "database/sql" "errors" + "spend-sparrow/log" + "spend-sparrow/types" ) var ( ErrNotFound = errors.New("the value does not exist") ErrAlreadyExists = errors.New("row already exists") ) + +func TransformAndLogDbError(module string, r sql.Result, err error) error { + if err != nil { + if err == sql.ErrNoRows { + return ErrNotFound + } + log.Error("%v: %v", module, err) + return types.ErrInternal + } + + if r != nil { + rows, err := r.RowsAffected() + if err != nil { + log.Error("%v: %v", module, err) + return types.ErrInternal + } + + if rows == 0 { + log.Info("%v: not found", module) + return ErrNotFound + } + } + + return nil +} diff --git a/handler/transaction.go b/handler/transaction.go new file mode 100644 index 0000000..089b2a2 --- /dev/null +++ b/handler/transaction.go @@ -0,0 +1,174 @@ +package handler + +import ( + "net/http" + "spend-sparrow/handler/middleware" + "spend-sparrow/service" + t "spend-sparrow/template/transaction" + "spend-sparrow/types" + "spend-sparrow/utils" + + "github.com/a-h/templ" +) + +type Transaction interface { + Handle(router *http.ServeMux) +} + +type TransactionImpl struct { + s service.Transaction + account service.Account + treasureChest service.TreasureChest + a service.Auth + r *Render +} + +func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, a service.Auth, r *Render) Transaction { + return TransactionImpl{ + s: s, + account: account, + treasureChest: treasureChest, + a: a, + r: r, + } +} + +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("DELETE /transaction/{id}", h.handleDeleteTransaction()) +} + +func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + transactions, err := h.s.GetAll(user) + if err != nil { + handleError(w, r, err) + return + } + + 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 + } + + comp := t.Transaction(transactions, accounts, treasureChests) + h.r.RenderLayout(r, w, comp, user) + } +} + +func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + 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 + } + + id := r.PathValue("id") + if id == "new" { + comp := t.EditTransaction(nil, accounts, treasureChests) + h.r.Render(r, w, comp) + return + } + + transaction, err := h.s.Get(user, id) + if err != nil { + handleError(w, r, err) + return + } + + var comp templ.Component + if r.URL.Query().Get("edit") == "true" { + comp = t.EditTransaction(transaction, accounts, treasureChests) + } else { + comp = t.TransactionItem(transaction) + } + h.r.Render(r, w, comp) + } +} + +func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + var ( + transaction *types.Transaction + err error + ) + input := types.TransactionInput{ + Id: r.PathValue("id"), + AccountId: r.FormValue("account-id"), + TreasureChestId: r.FormValue("treasure-chest-id"), + Value: r.FormValue("value"), + Timestamp: r.FormValue("timestamp"), + TimezoneOffsetMinutes: r.FormValue("timezone-offset"), + Note: r.FormValue("note"), + } + + if input.Id == "new" { + transaction, err = h.s.Add(user, input) + if err != nil { + handleError(w, r, err) + return + } + } else { + transaction, err = h.s.Update(user, input) + if err != nil { + handleError(w, r, err) + return + } + } + + comp := t.TransactionItem(transaction) + h.r.Render(r, w, comp) + } +} + +func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + utils.DoRedirect(w, r, "/auth/signin") + return + } + + id := r.PathValue("id") + + err := h.s.Delete(user, id) + if err != nil { + handleError(w, r, err) + return + } + } +} diff --git a/input.css b/input.css index 2812f81..6c4abce 100644 --- a/input.css +++ b/input.css @@ -24,7 +24,6 @@ input:focus { --font-shippori: "Shippori Mincho", sans-serif; } - /* Button */ .button { transition: all 150ms linear; diff --git a/main.go b/main.go index e938e00..69f4c2a 100644 --- a/main.go +++ b/main.go @@ -117,17 +117,20 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler { authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings) accountService := service.NewAccount(accountDb, randomService, clockService, serverSettings) treasureChestService := service.NewTreasureChest(treasureChestDb, randomService, clockService, serverSettings) + transactionService := service.NewTransaction(d, randomService, clockService, serverSettings) render := handler.NewRender() indexHandler := handler.NewIndex(authService, render) authHandler := handler.NewAuth(authService, render) accountHandler := handler.NewAccount(accountService, authService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, authService, render) + transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, authService, render) indexHandler.Handle(router) accountHandler.Handle(router) treasureChestHandler.Handle(router) authHandler.Handle(router) + transactionHandler.Handle(router) // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) diff --git a/main_test.go b/main_test.go index 3dcbb38..83919c8 100644 --- a/main_test.go +++ b/main_test.go @@ -910,6 +910,20 @@ func TestIntegrationAuth(t *testing.T) { assert.Nil(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) + formData = url.Values{ + "timestamp": {"2006-01-02T15:04"}, + "value": {"100.00"}, + "csrf-token": {csrfToken}, + } + req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/transaction/new", strings.NewReader(formData.Encode())) + assert.Nil(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", "id="+sessionId) + req.Header.Set("HX-Request", "true") + resp, err = httpClient.Do(req) + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + formData = url.Values{ "password": {"password"}, "csrf-token": {csrfToken}, @@ -940,6 +954,9 @@ func TestIntegrationAuth(t *testing.T) { err = db.QueryRow("SELECT COUNT(*) FROM treasure_chest WHERE user_id = ?", userId).Scan(&rows) assert.Nil(t, err) assert.Equal(t, 0, rows) + err = db.QueryRow("SELECT COUNT(*) FROM \"transaction\" WHERE user_id = ?", userId).Scan(&rows) + assert.Nil(t, err) + assert.Equal(t, 0, rows) }) }) diff --git a/migration/002_account.up.sql b/migration/002_account.up.sql index 11a4ec5..e18122f 100644 --- a/migration/002_account.up.sql +++ b/migration/002_account.up.sql @@ -5,9 +5,9 @@ CREATE TABLE account ( name TEXT NOT NULL, - current_balance int64 NOT NULL, + current_balance INTEGER NOT NULL, last_transaction DATETIME, - oink_balance int64 NOT NULL, + oink_balance INTEGER NOT NULL, created_at DATETIME NOT NULL, created_by TEXT NOT NULL, diff --git a/migration/003_treasure_chest.up.sql b/migration/003_treasure_chest.up.sql index b836d9a..dfc0a84 100644 --- a/migration/003_treasure_chest.up.sql +++ b/migration/003_treasure_chest.up.sql @@ -6,7 +6,7 @@ CREATE TABLE treasure_chest ( name TEXT NOT NULL, - current_balance int64 NOT NULL, + current_balance INTEGER NOT NULL, created_at DATETIME NOT NULL, created_by TEXT NOT NULL, diff --git a/migration/004_transaction.up.sql b/migration/004_transaction.up.sql new file mode 100644 index 0000000..8cdb993 --- /dev/null +++ b/migration/004_transaction.up.sql @@ -0,0 +1,17 @@ + +CREATE TABLE "transaction" ( + id TEXT NOT NULL UNIQUE PRIMARY KEY, + user_id TEXT NOT NULL, + + timestamp DATETIME NOT NULL, + note TEXT NOT NULL, + + account_id TEXT, + treasure_chest_id TEXT, + value INTEGER NOT NULL, + + created_at DATETIME NOT NULL, + created_by TEXT NOT NULL, + updated_at DATETIME, + updated_by TEXT +) WITHOUT ROWID; diff --git a/service/account.go b/service/account.go index 8ed5f62..1b1d53f 100644 --- a/service/account.go +++ b/service/account.go @@ -61,7 +61,7 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error) return nil, types.ErrInternal } - err = validateString(name) + err = validateString(name, "name") if err != nil { return nil, err } @@ -100,7 +100,7 @@ func (s AccountImpl) Update(user *types.User, id string, name string) (*types.Ac if user == nil { return nil, ErrUnauthorized } - err := validateString(name) + err := validateString(name, "name") if err != nil { return nil, err } diff --git a/service/default.go b/service/default.go index 08a765a..ea744e9 100644 --- a/service/default.go +++ b/service/default.go @@ -2,11 +2,11 @@ package service import "fmt" -func validateString(value string) error { +func validateString(value string, fieldName string) error { if value == "" { - return fmt.Errorf("field \"name\" needs to be set: %w", ErrBadRequest) + return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest) } else if !safeInputRegex.MatchString(value) { - return fmt.Errorf("use only letters, dashes and spaces for \"name\": %w", ErrBadRequest) + return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, ErrBadRequest) } else { return nil } diff --git a/service/transaction.go b/service/transaction.go new file mode 100644 index 0000000..5baeb23 --- /dev/null +++ b/service/transaction.go @@ -0,0 +1,292 @@ +package service + +import ( + "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 ( + transactionMetric = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "spendsparrow_transaction_total", + Help: "The total of transaction operations", + }, + []string{"operation"}, + ) +) + +type Transaction interface { + Add(user *types.User, transaction types.TransactionInput) (*types.Transaction, error) + Update(user *types.User, transaction types.TransactionInput) (*types.Transaction, error) + Get(user *types.User, id string) (*types.Transaction, error) + GetAll(user *types.User) ([]*types.Transaction, error) + Delete(user *types.User, id string) error +} + +type TransactionImpl struct { + db *sqlx.DB + clock Clock + random Random + settings *types.Settings +} + +func NewTransaction(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) Transaction { + return TransactionImpl{ + db: db, + clock: clock, + random: random, + settings: settings, + } +} + +func (s TransactionImpl) Add(user *types.User, transactionInput types.TransactionInput) (*types.Transaction, error) { + transactionMetric.WithLabelValues("add").Inc() + + if user == nil { + return nil, ErrUnauthorized + } + + transaction, err := s.validateAndEnrichTransaction(nil, user.Id, transactionInput) + if err != nil { + return nil, err + } + + 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) + err = db.TransformAndLogDbError("transaction Insert", r, err) + if err != nil { + return nil, err + } + + return transaction, nil +} + +func (s TransactionImpl) Update(user *types.User, input types.TransactionInput) (*types.Transaction, error) { + transactionMetric.WithLabelValues("update").Inc() + if user == nil { + 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) + } + + transaction := &types.Transaction{} + err = s.db.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) + err = db.TransformAndLogDbError("transaction Update", nil, err) + if err != nil { + if err == db.ErrNotFound { + return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest) + } + return nil, types.ErrInternal + } + + transaction, err = s.validateAndEnrichTransaction(transaction, user.Id, input) + if err != nil { + return nil, err + } + + r, err := s.db.NamedExec(` + UPDATE "transaction" + SET + account_id = :account_id, + treasure_chest_id = :treasure_chest_id, + value = :value, + timestamp = :timestamp, + note = :note, + updated_at = :updated_at, + updated_by = :updated_by + WHERE id = :id + AND user_id = :user_id`, transaction) + err = db.TransformAndLogDbError("transaction Update", r, err) + if err != nil { + return nil, err + } + + return transaction, nil +} + +func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) { + transactionMetric.WithLabelValues("get").Inc() + + if user == nil { + return nil, ErrUnauthorized + } + uuid, err := uuid.Parse(id) + if err != nil { + log.Error("transaction get: %v", err) + return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) + } + + var transaction types.Transaction + err = s.db.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) + err = db.TransformAndLogDbError("transaction Get", nil, err) + if err != nil { + if 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(user *types.User) ([]*types.Transaction, error) { + transactionMetric.WithLabelValues("get_all").Inc() + if user == nil { + 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 = db.TransformAndLogDbError("transaction GetAll", nil, err) + if err != nil { + return nil, err + } + + return transactions, nil +} + +func (s TransactionImpl) Delete(user *types.User, id string) error { + transactionMetric.WithLabelValues("delete").Inc() + if user == nil { + return ErrUnauthorized + } + uuid, err := uuid.Parse(id) + if err != nil { + log.Error("transaction delete: %v", err) + 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) + err = db.TransformAndLogDbError("transaction Delete", r, err) + if err != nil { + return err + } + + 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 +} diff --git a/service/treasure_chest.go b/service/treasure_chest.go index 527487f..316828b 100644 --- a/service/treasure_chest.go +++ b/service/treasure_chest.go @@ -58,7 +58,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types. return nil, types.ErrInternal } - err = validateString(name) + err = validateString(name, "name") if err != nil { return nil, err } @@ -108,7 +108,7 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string if user == nil { return nil, ErrUnauthorized } - err := validateString(name) + err := validateString(name, "name") if err != nil { return nil, err } @@ -195,7 +195,7 @@ func (s TreasureChestImpl) GetAll(user *types.User) ([]*types.TreasureChest, err return nil, types.ErrInternal } - return treasureChests, nil + return sortTree(treasureChests), nil } func (s TreasureChestImpl) Delete(user *types.User, idStr string) error { @@ -237,3 +237,27 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error { return nil } + +func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest { + + var ( + roots []*types.TreasureChest + result []*types.TreasureChest + ) + children := make(map[uuid.UUID][]*types.TreasureChest) + + for _, node := range nodes { + if node.ParentId == uuid.Nil { + roots = append(roots, node) + } else { + children[node.ParentId] = append(children[node.ParentId], node) + } + } + + for _, root := range roots { + result = append(result, root) + result = append(result, children[root.Id]...) + } + + return result +} diff --git a/static/js/time.js b/static/js/time.js new file mode 100644 index 0000000..fe0a8c6 --- /dev/null +++ b/static/js/time.js @@ -0,0 +1,27 @@ + +htmx.on("htmx:afterSwap", (e) => { + updateTime(e.target); +}); + +document.addEventListener("DOMContentLoaded", () => { + console.log("DOMContentLoaded"); + updateTime(document); +}) + +function updateTime(e) { + const timezoneOffset = - new Date().getTimezoneOffset(); + e.querySelectorAll("#timezone-offset").forEach((el) => { + el.value = timezoneOffset; + }); + document.querySelectorAll(".datetime").forEach((el) => { + if (el.textContent !== "") { + el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : el.textContent; + } else if (el.attributes['value'] !== "") { + const value = el.attributes['value'].value; + const newDate = value.includes("UTC") ? new Date(value) : value; + newDate.setTime(newDate.getTime() + timezoneOffset * 60 * 1000); + el.valueAsDate = newDate; + } + }) +} + diff --git a/template/layout.templ b/template/layout.templ index 8a6192f..b29c9a7 100644 --- a/template/layout.templ +++ b/template/layout.templ @@ -26,6 +26,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str /> + // Header diff --git a/template/transaction/default.go b/template/transaction/default.go new file mode 100644 index 0000000..0619207 --- /dev/null +++ b/template/transaction/default.go @@ -0,0 +1 @@ +package transaction diff --git a/template/transaction/transaction.templ b/template/transaction/transaction.templ new file mode 100644 index 0000000..a554d55 --- /dev/null +++ b/template/transaction/transaction.templ @@ -0,0 +1,175 @@ +package transaction + +import "fmt" +import "time" +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) { +
+ +
+ for _, transaction := range transactions { + @TransactionItem(transaction) + } +
+
+} + +templ EditTransaction(transaction *types.Transaction, accounts []*types.Account, treasureChests []*types.TreasureChest) { + {{ + var ( + timestamp time.Time + value string + + id string + cancelUrl string + ) + note := "" + accountId := "" + treasureChestId := "" + if transaction == nil { + timestamp = time.Now().UTC().Truncate(time.Minute) + + id = "new" + cancelUrl = "/empty" + } else { + timestamp = transaction.Timestamp.UTC().Truncate(time.Minute) + note = transaction.Note + if transaction.AccountId != nil { + accountId = transaction.AccountId.String() + } + if transaction.TreasureChestId != nil { + treasureChestId = transaction.TreasureChestId.String() + } + value = displayBalance(transaction.Value) + + id = transaction.Id.String() + cancelUrl = "/transaction/" + id + } + }} +
+
+
+ + + + + + + + + + + +
+ + +
+
+} + +templ TransactionItem(transaction *types.Transaction) { +
+
+

{ transaction.Timestamp.String() }

+

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

+ + +
+
+} + +func displayBalance(balance int64) string { + + euros := float64(balance) / 100 + return fmt.Sprintf("%.2f", euros) +} diff --git a/template/treasurechest/treasure_chest.templ b/template/treasurechest/treasure_chest.templ index 9ca6762..8bbdcb3 100644 --- a/template/treasurechest/treasure_chest.templ +++ b/template/treasurechest/treasure_chest.templ @@ -19,7 +19,7 @@ templ TreasureChest(treasureChests []*types.TreasureChest) {

New Treasure Chest

- for _, treasureChest := range sortTree(treasureChests) { + for _, treasureChest := range treasureChests { @TreasureChestItem(treasureChest) }
@@ -148,37 +148,3 @@ func displayBalance(balance int64) string { euros := float64(balance) / 100 return fmt.Sprintf("%.2f €", euros) } - -func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest { - - var ( - roots []*types.TreasureChest - result []*types.TreasureChest - ) - children := make(map[uuid.UUID][]*types.TreasureChest) - - for _, node := range nodes { - if node.ParentId == uuid.Nil { - roots = append(roots, node) - } else { - children[node.ParentId] = append(children[node.ParentId], node) - } - } - - for _, root := range roots { - result = append(result, root) - result = append(result, children[root.Id]...) - } - - return result -} - -func compareStrings(a, b string) int { - if a < b { - return -1 - } - if a > b { - return 1 - } - return 0 -} diff --git a/types/transaction.go b/types/transaction.go index 1f22c0f..5ccbb1b 100644 --- a/types/transaction.go +++ b/types/transaction.go @@ -17,21 +17,27 @@ type Transaction struct { Id uuid.UUID UserId uuid.UUID `db:"user_id"` - AccountId uuid.UUID `db:"account_id"` - // nil indicates that the transaction is not yet associated with a piggy bank - TreasureChestId *uuid.UUID `db:"treasure_chest_id"` - - // The internal transaction is amove between e.g. an account and a piggy bank to execute a savings plan - Internal bool - - // The value of the transacion. Negative for outgoing and positive for incoming - Value int64 Timestamp time.Time + Note string - Note string + // account id is only nil, if the transaction is a deposit to a treasure chest + AccountId *uuid.UUID `db:"account_id"` + TreasureChestId *uuid.UUID `db:"treasure_chest_id"` + // The value of the transacion. Negative for outgoing and positive for incoming transactions. + Value int64 CreatedAt time.Time `db:"created_at"` CreatedBy uuid.UUID `db:"created_by"` UpdatedAt *time.Time `db:"updated_at"` UpdatedBy *uuid.UUID `db:"updated_by"` } + +type TransactionInput struct { + Id string + AccountId string + TreasureChestId string + Value string + Timestamp string + TimezoneOffsetMinutes string + Note string +}