feat(transaction): #66 implement transactions
This commit was merged in pull request #72.
This commit is contained in:
@@ -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)
|
||||
|
||||
28
db/error.go
28
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
|
||||
}
|
||||
|
||||
174
handler/transaction.go
Normal file
174
handler/transaction.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ input:focus {
|
||||
--font-shippori: "Shippori Mincho", sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
transition: all 150ms linear;
|
||||
|
||||
3
main.go
3
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/"))))
|
||||
|
||||
17
main_test.go
17
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
migration/004_transaction.up.sql
Normal file
17
migration/004_transaction.up.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
292
service/transaction.go
Normal file
292
service/transaction.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
27
static/js/time.js
Normal file
27
static/js/time.js
Normal file
@@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
|
||||
/>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/toast.js"></script>
|
||||
<script src="/static/js/time.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col" hx-headers='{"csrf-token": "CSRF_TOKEN"}'>
|
||||
// Header
|
||||
|
||||
1
template/transaction/default.go
Normal file
1
template/transaction/default.go
Normal file
@@ -0,0 +1 @@
|
||||
package transaction
|
||||
175
template/transaction/transaction.templ
Normal file
175
template/transaction/transaction.templ
Normal file
@@ -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) {
|
||||
<div class="max-w-6xl mt-10 mx-auto">
|
||||
<button
|
||||
hx-get="/transaction/new"
|
||||
hx-target="#transaction-items"
|
||||
hx-swap="afterbegin"
|
||||
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
|
||||
>
|
||||
@svg.Plus()
|
||||
<p class="">New Transaction</p>
|
||||
</button>
|
||||
<div id="transaction-items" class="my-6 flex flex-col items-center">
|
||||
for _, transaction := range transactions {
|
||||
@TransactionItem(transaction)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}}
|
||||
<div id="transaction" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
|
||||
<form
|
||||
hx-post={ "/transaction/" + id }
|
||||
hx-target="closest #transaction"
|
||||
hx-swap="outerHTML"
|
||||
class="text-xl flex justify-end gap-4 items-center"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_auto] items-center gap-4 mr-auto">
|
||||
<label for="timestamp" class="text-sm text-gray-500">Transaction Date</label>
|
||||
<input
|
||||
autofocus
|
||||
name="timestamp"
|
||||
type="datetime-local"
|
||||
value={ timestamp.String() }
|
||||
class="bg-white input datetime"
|
||||
/>
|
||||
<input class="hidden" id="timezone-offset" name="timezone-offset" type="text"/>
|
||||
<label for="note" class="text-sm text-gray-500">Note</label>
|
||||
<input
|
||||
name="note"
|
||||
type="text"
|
||||
value={ note }
|
||||
class="mr-auto bg-white input"
|
||||
/>
|
||||
<label for="value" class="text-sm text-gray-500">Value (€)</label>
|
||||
<input
|
||||
name="value"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={ value }
|
||||
class="bg-white input"
|
||||
/>
|
||||
<label for="account-id" class="text-sm text-gray-500">Account</label>
|
||||
<select
|
||||
name="account-id"
|
||||
class="bg-white input"
|
||||
>
|
||||
<option value="">-</option>
|
||||
for _, account := range accounts {
|
||||
<option selected?={ account.Id.String() == accountId } value={ account.Id.String() }>{ account.Name }</option>
|
||||
}
|
||||
</select>
|
||||
<label for="treasure-chest-id" class="text-sm text-gray-500">Treasure Chest</label>
|
||||
<select
|
||||
name="treasure-chest-id"
|
||||
class="bg-white input"
|
||||
>
|
||||
<option value="">-</option>
|
||||
for _, treasureChest := range treasureChests {
|
||||
<option selected?={ treasureChest.Id.String() == treasureChestId } value={ treasureChest.Id.String() }>
|
||||
if treasureChest.ParentId != uuid.Nil {
|
||||
{ treasureChest.Name }
|
||||
} else {
|
||||
{ treasureChest.Name }
|
||||
}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="button button-neglect px-1 flex items-center gap-2">
|
||||
@svg.Save()
|
||||
<span>
|
||||
Save
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
hx-get={ cancelUrl }
|
||||
hx-target="closest #transaction"
|
||||
hx-swap="outerHTML"
|
||||
class="button button-neglect px-1 flex items-center gap-2"
|
||||
>
|
||||
@svg.Cancel()
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func displayBalance(balance int64) string {
|
||||
|
||||
euros := float64(balance) / 100
|
||||
return fmt.Sprintf("%.2f", euros)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ templ TreasureChest(treasureChests []*types.TreasureChest) {
|
||||
<p>New Treasure Chest</p>
|
||||
</button>
|
||||
<div id="treasurechest-items" class="my-6 flex flex-col items-center">
|
||||
for _, treasureChest := range sortTree(treasureChests) {
|
||||
for _, treasureChest := range treasureChests {
|
||||
@TreasureChestItem(treasureChest)
|
||||
}
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user