feat(transaction-recurring): #100 implement editing of recurring transactions based on treasure chests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m2s

This commit was merged in pull request #121.
This commit is contained in:
2025-05-20 23:18:16 +02:00
parent b7d216a982
commit 2ba5ddd9f2
18 changed files with 948 additions and 59 deletions

View File

@@ -17,14 +17,12 @@ type Account interface {
type AccountImpl struct { type AccountImpl struct {
s service.Account s service.Account
a service.Auth
r *Render r *Render
} }
func NewAccount(s service.Account, a service.Auth, r *Render) Account { func NewAccount(s service.Account, r *Render) Account {
return AccountImpl{ return AccountImpl{
s: s, s: s,
a: a,
r: r, r: r,
} }
} }

View File

@@ -3,7 +3,6 @@ package handler
import ( import (
"net/http" "net/http"
"spend-sparrow/handler/middleware" "spend-sparrow/handler/middleware"
"spend-sparrow/service"
"spend-sparrow/template" "spend-sparrow/template"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -14,13 +13,11 @@ type Index interface {
} }
type IndexImpl struct { type IndexImpl struct {
service service.Auth
render *Render render *Render
} }
func NewIndex(service service.Auth, render *Render) Index { func NewIndex(render *Render) Index {
return IndexImpl{ return IndexImpl{
service: service,
render: render, render: render,
} }
} }

View File

@@ -20,16 +20,14 @@ type TransactionImpl struct {
s service.Transaction s service.Transaction
account service.Account account service.Account
treasureChest service.TreasureChest treasureChest service.TreasureChest
a service.Auth
r *Render r *Render
} }
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, a service.Auth, r *Render) Transaction { func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction {
return TransactionImpl{ return TransactionImpl{
s: s, s: s,
account: account, account: account,
treasureChest: treasureChest, treasureChest: treasureChest,
a: a,
r: r, r: r,
} }
} }

View File

@@ -0,0 +1,131 @@
package handler
import (
"net/http"
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
t "spend-sparrow/template/transaction_recurring"
"spend-sparrow/types"
"spend-sparrow/utils"
)
type TransactionRecurring interface {
Handle(router *http.ServeMux)
}
type TransactionRecurringImpl struct {
s service.TransactionRecurring
r *Render
}
func NewTransactionRecurring(s service.TransactionRecurring, r *Render) TransactionRecurring {
return TransactionRecurringImpl{
s: s,
r: r,
}
}
func (h TransactionRecurringImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction-recurring", h.handleTransactionRecurringItemComp())
r.Handle("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring())
r.Handle("DELETE /transaction-recurring/{id}", h.handleDeleteTransactionRecurring())
}
func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() 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.URL.Query().Get("id")
accountId := r.URL.Query().Get("account-id")
treasureChestId := r.URL.Query().Get("treasure-chest-id")
h.renderItems(w, r, user, id, accountId, treasureChestId)
}
}
func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
input := types.TransactionRecurringInput{
Id: r.PathValue("id"),
IntervalMonths: r.FormValue("interval-months"),
Active: r.FormValue("active"),
Party: r.FormValue("party"),
Description: r.FormValue("description"),
AccountId: r.FormValue("account-id"),
TreasureChestId: r.FormValue("treasure-chest-id"),
Value: r.FormValue("value"),
}
if input.Id == "new" {
_, err := h.s.Add(user, input)
if err != nil {
handleError(w, r, err)
return
}
} else {
_, err := h.s.Update(user, input)
if err != nil {
handleError(w, r, err)
return
}
}
h.renderItems(w, r, user, "", input.AccountId, input.TreasureChestId)
}
}
func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() 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")
accountId := r.URL.Query().Get("account-id")
treasureChestId := r.URL.Query().Get("treasure-chest-id")
err := h.s.Delete(user, id)
if err != nil {
handleError(w, r, err)
return
}
h.renderItems(w, r, user, "", accountId, treasureChestId)
}
}
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *types.User, id, accountId, treasureChestId string) {
var transactionsRecurring []*types.TransactionRecurring
var err error
if accountId == "" && treasureChestId == "" {
utils.TriggerToastWithStatus(w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
}
if accountId != "" {
transactionsRecurring, err = h.s.GetAllByAccount(user, accountId)
if err != nil {
handleError(w, r, err)
return
}
} else {
transactionsRecurring, err = h.s.GetAllByTreasureChest(user, treasureChestId)
if err != nil {
handleError(w, r, err)
return
}
}
comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
h.r.Render(r, w, comp)
}

View File

@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"spend-sparrow/handler/middleware" "spend-sparrow/handler/middleware"
"spend-sparrow/service" "spend-sparrow/service"
tr "spend-sparrow/template/transaction_recurring"
t "spend-sparrow/template/treasurechest" t "spend-sparrow/template/treasurechest"
"spend-sparrow/types" "spend-sparrow/types"
"spend-sparrow/utils" "spend-sparrow/utils"
@@ -17,14 +18,14 @@ type TreasureChest interface {
type TreasureChestImpl struct { type TreasureChestImpl struct {
s service.TreasureChest s service.TreasureChest
a service.Auth transactionRecurring service.TransactionRecurring
r *Render r *Render
} }
func NewTreasureChest(s service.TreasureChest, a service.Auth, r *Render) TreasureChest { func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *Render) TreasureChest {
return TreasureChestImpl{ return TreasureChestImpl{
s: s, s: s,
a: a, transactionRecurring: transactionRecurring,
r: r, r: r,
} }
} }
@@ -71,7 +72,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditTreasureChest(nil, treasureChests) comp := t.EditTreasureChest(nil, treasureChests, nil)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
@@ -82,9 +83,16 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return return
} }
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(user, treasureChest.Id.String())
if err != nil {
handleError(w, r, err)
return
}
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditTreasureChest(treasureChest, treasureChests) comp = t.EditTreasureChest(treasureChest, treasureChests, transactionsRec)
} else { } else {
comp = t.TreasureChestItem(treasureChest) comp = t.TreasureChestItem(treasureChest)
} }

View File

@@ -56,7 +56,7 @@ input:focus {
transition: all 150ms linear; transition: all 150ms linear;
@apply px-3 py-2 text-lg; @apply px-3 py-2 text-lg;
} }
.input:has(input:focus) { .input:has(input:focus), .input:focus {
border-color: var(--color-gray-400); border-color: var(--color-gray-400);
box-shadow: 0 0 0 2px var(--color-gray-200); box-shadow: 0 0 0 2px var(--color-gray-200);
} }

11
main.go
View File

@@ -116,19 +116,22 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
accountService := service.NewAccount(d, randomService, clockService, serverSettings) accountService := service.NewAccount(d, randomService, clockService, serverSettings)
treasureChestService := service.NewTreasureChest(d, randomService, clockService, serverSettings) treasureChestService := service.NewTreasureChest(d, randomService, clockService, serverSettings)
transactionService := service.NewTransaction(d, randomService, clockService, serverSettings) transactionService := service.NewTransaction(d, randomService, clockService, serverSettings)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, serverSettings)
render := handler.NewRender() render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render) indexHandler := handler.NewIndex(render)
authHandler := handler.NewAuth(authService, render) authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, authService, render) accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, authService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, authService, render) transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
indexHandler.Handle(router) indexHandler.Handle(router)
accountHandler.Handle(router) accountHandler.Handle(router)
treasureChestHandler.Handle(router) treasureChestHandler.Handle(router)
authHandler.Handle(router) authHandler.Handle(router)
transactionHandler.Handle(router) transactionHandler.Handle(router)
transactionRecurringHandler.Handle(router)
// Serve static files (CSS, JS and images) // Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))

View File

@@ -1803,7 +1803,6 @@ func TestIntegrationAccount(t *testing.T) {
":": 400, ":": 400,
"*": 400, "*": 400,
"|": 400, "|": 400,
"\"": 400,
"Account": 200, "Account": 200,
} }

View File

@@ -0,0 +1,21 @@
CREATE TABLE "transaction_recurring" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
interval_months INTEGER NOT NULL,
last_execution DATETIME,
active INTEGER NOT NULL,
party TEXT,
description TEXT,
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;

View File

@@ -6,7 +6,7 @@ import (
) )
var ( var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,& -]+$`) safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'" -]+$`)
) )
func validateString(value string, fieldName string) error { func validateString(value string, fieldName string) error {

View File

@@ -390,7 +390,7 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
return err return err
} }
updateErrors(transaction) s.updateErrors(transaction)
r, err = tx.Exec(` r, err = tx.Exec(`
UPDATE "transaction" UPDATE "transaction"
SET error = ? SET error = ?
@@ -551,12 +551,12 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
UpdatedBy: &updatedBy, UpdatedBy: &updatedBy,
} }
updateErrors(&transaction) s.updateErrors(&transaction)
return &transaction, nil return &transaction, nil
} }
func updateErrors(transaction *types.Transaction) { func (s TransactionImpl) updateErrors(transaction *types.Transaction) {
error := "" error := ""
if transaction.Value < 0 { if transaction.Value < 0 {

View File

@@ -0,0 +1,468 @@
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 (
transactionRecurringMetric = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "spendsparrow_transactionRecurring_recurring_total",
Help: "The total of transactionRecurring recurring operations",
},
[]string{"operation"},
)
)
type TransactionRecurring interface {
Add(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
Update(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
Get(user *types.User, id string) (*types.TransactionRecurring, error)
GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(user *types.User, id string) error
}
type TransactionRecurringImpl struct {
db *sqlx.DB
clock Clock
random Random
settings *types.Settings
}
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, settings *types.Settings) TransactionRecurring {
return TransactionRecurringImpl{
db: db,
clock: clock,
random: random,
settings: settings,
}
}
func (s TransactionRecurringImpl) Add(user *types.User, transactionRecurringInput types.TransactionRecurringInput) (*types.TransactionRecurring, error) {
transactionRecurringMetric.WithLabelValues("add").Inc()
if user == nil {
return nil, ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(tx, nil, user.Id, transactionRecurringInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
INSERT INTO "transaction_recurring" (id, user_id, interval_months, active, party, description, account_id, treasure_chest_id, value, created_at, created_by)
VALUES (:id, :user_id, :interval_months, :active, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`, transactionRecurring)
err = db.TransformAndLogDbError("transactionRecurring Insert", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) Update(user *types.User, input types.TransactionRecurringInput) (*types.TransactionRecurring, error) {
transactionRecurringMetric.WithLabelValues("update").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(input.Id)
if err != nil {
log.Error("transactionRecurring update: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
transactionRecurring := &types.TransactionRecurring{}
err = tx.Get(transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(tx, transactionRecurring, user.Id, input)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
UPDATE transaction_recurring
SET
interval_months = :interval_months,
active = :active,
party = :party,
description = :description,
account_id = :account_id,
treasure_chest_id = :treasure_chest_id,
value = :value,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transactionRecurring)
err = db.TransformAndLogDbError("transactionRecurring Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) Get(user *types.User, id string) (*types.TransactionRecurring, error) {
transactionRecurringMetric.WithLabelValues("get").Inc()
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("transactionRecurring get: %v", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var transactionRecurring types.TransactionRecurring
err = s.db.Get(&transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transactionRecurring Get", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", id, ErrBadRequest)
}
return nil, types.ErrInternal
}
return &transactionRecurring, nil
}
func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
transactionRecurringMetric.WithLabelValues("get_all_by_account").Inc()
if user == nil {
return nil, ErrUnauthorized
}
accountUuid, err := uuid.Parse(accountId)
if err != nil {
log.Error("transactionRecurring GetAllByAccount: %v", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
var rowCount int
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.Select(&transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND account_id = ?
ORDER BY created_at DESC`,
user.Id, accountUuid)
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error) {
transactionRecurringMetric.WithLabelValues("get_all_by_treasurechest").Inc()
if user == nil {
return nil, ErrUnauthorized
}
treasureChestUuid, err := uuid.Parse(treasureChestId)
if err != nil {
log.Error("transactionRecurring GetAllByTreasureChest: %v", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
defer func() {
_ = tx.Rollback()
}()
var rowCount int
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest)
}
return nil, types.ErrInternal
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.Select(&transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND treasure_chest_id = ?
ORDER BY created_at DESC`,
user.Id, treasureChestUuid)
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
transactionRecurringMetric.WithLabelValues("delete").Inc()
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
log.Error("transactionRecurring delete: %v", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
if err != nil {
return nil
}
defer func() {
_ = tx.Rollback()
}()
var transactionRecurring types.TransactionRecurring
err = tx.Get(&transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
if err != nil {
return err
}
r, err := tx.Exec("DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("transactionRecurring Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
tx *sqlx.Tx,
oldTransactionRecurring *types.TransactionRecurring,
userId uuid.UUID,
input types.TransactionRecurringInput) (*types.TransactionRecurring, error) {
var (
id uuid.UUID
accountUuid *uuid.UUID
treasureChestUuid *uuid.UUID
createdAt time.Time
createdBy uuid.UUID
updatedAt *time.Time
updatedBy uuid.UUID
intervalMonths int64
err error
rowCount int
)
if oldTransactionRecurring == nil {
id, err = s.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
createdAt = s.clock.Now()
createdBy = userId
} else {
id = oldTransactionRecurring.Id
createdAt = oldTransactionRecurring.CreatedAt
createdBy = oldTransactionRecurring.CreatedBy
time := s.clock.Now()
updatedAt = &time
updatedBy = userId
}
hasAccount := false
hasTreasureChest := false
if input.AccountId != "" {
temp, err := uuid.Parse(input.AccountId)
if err != nil {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
accountUuid = &temp
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
if err != nil {
return nil, err
}
if rowCount == 0 {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
hasAccount = true
}
if input.TreasureChestId != "" {
temp, err := uuid.Parse(input.TreasureChestId)
if err != nil {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
treasureChestUuid = &temp
var treasureChest types.TreasureChest
err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
if err != nil {
if err == db.ErrNotFound {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
}
return nil, err
}
if treasureChest.ParentId == nil {
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest)
}
hasTreasureChest = true
}
if !hasAccount && !hasTreasureChest {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest)
}
if hasAccount && hasTreasureChest {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", ErrBadRequest)
}
valueFloat, err := strconv.ParseFloat(input.Value, 64)
if err != nil {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
}
valueInt := int64(valueFloat * 100)
if input.Party != "" {
err = validateString(input.Party, "party")
if err != nil {
return nil, err
}
}
if input.Description != "" {
err = validateString(input.Description, "description")
if err != nil {
return nil, err
}
}
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
if err != nil {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest)
}
if intervalMonths < 1 {
log.Error("transactionRecurring validate: %v", err)
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest)
}
active := input.Active == "on"
transactionRecurring := types.TransactionRecurring{
Id: id,
UserId: userId,
IntervalMonths: intervalMonths,
Active: active,
Party: input.Party,
Description: input.Description,
AccountId: accountUuid,
TreasureChestId: treasureChestUuid,
Value: valueInt,
CreatedAt: createdAt,
CreatedBy: createdBy,
UpdatedAt: updatedAt,
UpdatedBy: &updatedBy,
}
return &transactionRecurring, nil
}

View File

@@ -1,6 +1,6 @@
htmx.on("htmx:afterSwap", (e) => { htmx.on("htmx:afterSwap", () => {
updateTime(e.target); updateTime();
}); });
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
@@ -8,7 +8,7 @@ document.addEventListener("DOMContentLoaded", () => {
updateTime(document); updateTime(document);
}) })
function updateTime(e) { function updateTime() {
document.querySelectorAll(".datetime").forEach((el) => { document.querySelectorAll(".datetime").forEach((el) => {
if (el.textContent !== "") { if (el.textContent !== "") {
el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent; el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent;

View File

@@ -0,0 +1 @@
package transaction_recurring

View File

@@ -0,0 +1,208 @@
package transaction_recurring
import "fmt"
import "spend-sparrow/template/svg"
import "spend-sparrow/types"
templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurring, editId, accountId, treasureChestId string) {
<!-- Don't use table, because embedded forms are only valid for cells -->
<div id="transaction-recurring" class="max-w-full grid gap-4 mt-10 grid-cols-[max-content_auto_auto_auto_auto_max-content] items-center text-xl">
<span class="text-sm text-gray-500">Active</span>
<span class="text-sm text-gray-500">Party</span>
<span class="text-sm text-gray-500">Description</span>
<span class="text-sm text-gray-500">Interval</span>
<span class="text-sm text-right text-gray-500">Value</span>
<span></span>
if editId == "new" {
@EditTransactionRecurring(nil, accountId, treasureChestId)
}
for _, transaction := range transactionsRecurring {
if transaction.Id.String() == editId {
@EditTransactionRecurring(transaction, accountId, treasureChestId)
} else {
@TransactionRecurringItem(transaction, accountId, treasureChestId)
}
}
</div>
}
templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
<p class="text-gray-600 text-center">
if transactionRecurring.Active {
} else {
}
</p>
<p class="text-gray-600">
if transactionRecurring.Party != "" {
{ transactionRecurring.Party }
} else {
-
}
</p>
<p class="text-gray-600">
if transactionRecurring.Description != "" {
{ transactionRecurring.Description }
} else {
-
}
</p>
<p class="text-gray-500 text-sm">
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
</p>
if transactionRecurring.Value < 0 {
<p class="text-right text-red-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
} else {
<p class="text-right text-green-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
}
<div class="flex gap-2">
<button
hx-get={ "/transaction-recurring?id=" + transactionRecurring.Id.String() + "&account-id=" + accountId + "&treasure-chest-id=" + treasureChestId + "&edit=true" }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/transaction-recurring/" + transactionRecurring.Id.String() + "?account-id=" + accountId + "&treasure-chest-id=" + treasureChestId }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this transaction?"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
}
templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
{{
var (
id string
)
party := ""
description := ""
value := "0.00"
intervalMonths := "1"
active := true
if transactionRecurring == nil {
id = "new"
} else {
intervalMonths = fmt.Sprintf("%d", transactionRecurring.IntervalMonths)
active = transactionRecurring.Active
party = transactionRecurring.Party
description = transactionRecurring.Description
value = displayBalance(transactionRecurring.Value)
id = transactionRecurring.Id.String()
}
}}
<form
id="transaction-recurring-form"
hx-post={ "/transaction-recurring/" + id }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="hidden"
></form>
<input
name="active"
form="transaction-recurring-form"
id="active"
type="checkbox"
checked?={ active }
class="bg-white input"
/>
<input
autofocus
form="transaction-recurring-form"
name="party"
type="text"
value={ party }
size="5"
class="bg-white input"
/>
<input
name="description"
form="transaction-recurring-form"
type="text"
value={ description }
size="10"
class="bg-white input"
/>
<input
name="interval-months"
form="transaction-recurring-form"
type="number"
value={ intervalMonths }
size="1"
class="bg-white input"
/>
<input
name="value"
form="transaction-recurring-form"
step="0.01"
type="number"
size="1"
value={ value }
class="bg-white input"
/>
if accountId != "" {
<input
form="transaction-recurring-form"
type="text"
name="account-id"
class="hidden text-sm text-gray-500"
value={ accountId }
/>
}
if treasureChestId != "" {
<input
form="transaction-recurring-form"
type="text"
name="treasure-chest-id"
class="hidden text-sm text-gray-500"
value={ treasureChestId }
/>
}
<div class="flex gap-2">
<button
form="transaction-recurring-form"
type="submit"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Save()
<span>
Save
</span>
</button>
<button
form="transaction-recurring-form"
hx-get={ "/transaction-recurring?account-id=" + accountId + "&treasure-chest-id=" + treasureChestId }
hx-target="closest #transaction-recurring"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Cancel()
<span>
Cancel
</span>
</button>
</div>
}
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func calculateReferences() {
}

View File

@@ -11,10 +11,10 @@ templ TreasureChest(treasureChests []*types.TreasureChest) {
hx-get="/treasurechest/new" hx-get="/treasurechest/new"
hx-target="#treasurechest-items" hx-target="#treasurechest-items"
hx-swap="afterbegin" hx-swap="afterbegin"
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center" class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
> >
@svg.Plus() @svg.Plus()
<p>New Treasure Chest</p> New Treasure Chest
</button> </button>
<div id="treasurechest-items" class="my-6 flex flex-col"> <div id="treasurechest-items" class="my-6 flex flex-col">
for _, treasureChest := range treasureChests { for _, treasureChest := range treasureChests {
@@ -24,7 +24,7 @@ templ TreasureChest(treasureChests []*types.TreasureChest) {
</div> </div>
} }
templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest) { templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest, transactionsRecurring templ.Component) {
{{ {{
var ( var (
id string id string
@@ -56,14 +56,17 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
hx-swap="outerHTML" hx-swap="outerHTML"
class="text-xl flex justify-end gap-4 items-center" class="text-xl flex justify-end gap-4 items-center"
> >
<div class="grow grid grid-cols-[auto_1fr] items-center gap-4">
<label for="name" class="text-sm text-gray-500">Name</label>
<input <input
autofocus autofocus
name="name" name="name"
type="text" type="text"
value={ name } value={ name }
placeholder="Treasure Chest Name" placeholder="Treasure Chest Name"
class="bg-white input" class="bg-white input max-w-96"
/> />
<label for="parent-id" class="text-sm text-gray-500">Parent</label>
<select name="parent-id" class="mr-auto bg-white input"> <select name="parent-id" class="mr-auto bg-white input">
<option value="" class="text-gray-500">-</option> <option value="" class="text-gray-500">-</option>
for _, parent := range filterNoChildNoSelf(parents, id) { for _, parent := range filterNoChildNoSelf(parents, id) {
@@ -73,6 +76,7 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
>{ parent.Name }</option> >{ parent.Name }</option>
} }
</select> </select>
</div>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2"> <button type="submit" class="button button-neglect px-1 flex items-center gap-2">
@svg.Save() @svg.Save()
<span> <span>
@@ -91,6 +95,22 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
</span> </span>
</button> </button>
</form> </form>
if id != "new" {
<div class="m-10 border-b-gray-400 border-b-1"></div>
<div class="flex">
<h3 class="text-sm text-gray-500">Monthly Transactions</h3>
<button
hx-get={ "/transaction-recurring?id=new&treasure-chest-id=" + id }
hx-target="next #transaction-recurring"
hx-swap="outerHTML"
class="button button-primary ml-auto px-2 flex items-center gap-2"
>
@svg.Plus()
<p>New Monthly Transaction</p>
</button>
</div>
@transactionsRecurring
}
</div> </div>
} }

View File

@@ -18,19 +18,17 @@ type Transaction struct {
UserId uuid.UUID `db:"user_id"` UserId uuid.UUID `db:"user_id"`
Timestamp time.Time Timestamp time.Time
Company string
Party string Party string
Description string Description string
// account id is only nil, if the transaction is a deposit to a treasure chest
AccountId *uuid.UUID `db:"account_id"` AccountId *uuid.UUID `db:"account_id"`
TreasureChestId *uuid.UUID `db:"treasure_chest_id"` TreasureChestId *uuid.UUID `db:"treasure_chest_id"`
// The value of the transacion. Negative for outgoing and positive for incoming transactions.
Value int64 Value int64
// If an error is present, then the transaction is not valid and should not be used for calculations. // If an error is present, then the transaction is not valid and should not be used for calculations.
Error *string Error *string
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
// Either a user_id or a transaction_recurring_id
CreatedBy uuid.UUID `db:"created_by"` CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"` UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"` UpdatedBy *uuid.UUID `db:"updated_by"`

View File

@@ -0,0 +1,39 @@
package types
import (
"time"
"github.com/google/uuid"
)
type TransactionRecurring struct {
Id uuid.UUID
UserId uuid.UUID `db:"user_id"`
IntervalMonths int64 `db:"interval_months"`
LastExecution *time.Time `db:"last_execution"`
Active bool
Party string
Description string
AccountId *uuid.UUID `db:"account_id"`
TreasureChestId *uuid.UUID `db:"treasure_chest_id"`
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 TransactionRecurringInput struct {
Id string
IntervalMonths string
Active string
Party string
Description string
AccountId string
TreasureChestId string
Value string
}