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 4m59s
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m59s
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +13,12 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
handler/transaction_recurring.go
Normal file
131
handler/transaction_recurring.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -16,16 +17,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
main.go
@@ -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/"))))
|
||||||
|
|||||||
@@ -1803,7 +1803,6 @@ func TestIntegrationAccount(t *testing.T) {
|
|||||||
":": 400,
|
":": 400,
|
||||||
"*": 400,
|
"*": 400,
|
||||||
"|": 400,
|
"|": 400,
|
||||||
"\"": 400,
|
|
||||||
"Account": 200,
|
"Account": 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
migration/008_recurring_transaction.up.sql
Normal file
21
migration/008_recurring_transaction.up.sql
Normal 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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
468
service/transaction_recurring.go
Normal file
468
service/transaction_recurring.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
1
template/transaction_recurring/default.go
Normal file
1
template/transaction_recurring/default.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package transaction_recurring
|
||||||
208
template/transaction_recurring/transaction_recurring.templ
Normal file
208
template/transaction_recurring/transaction_recurring.templ
Normal 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-[auto_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">
|
||||||
|
if transactionRecurring.Active {
|
||||||
|
Yes
|
||||||
|
} else {
|
||||||
|
No
|
||||||
|
}
|
||||||
|
</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() {
|
||||||
|
}
|
||||||
@@ -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,23 +56,27 @@ 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"
|
||||||
>
|
>
|
||||||
<input
|
<div class="grow grid grid-cols-[auto_1fr] items-center gap-4">
|
||||||
autofocus
|
<label for="name" class="text-sm text-gray-500">Name</label>
|
||||||
name="name"
|
<input
|
||||||
type="text"
|
autofocus
|
||||||
value={ name }
|
name="name"
|
||||||
placeholder="Treasure Chest Name"
|
type="text"
|
||||||
class="bg-white input"
|
value={ name }
|
||||||
/>
|
placeholder="Treasure Chest Name"
|
||||||
<select name="parent-id" class="mr-auto bg-white input">
|
class="bg-white input max-w-96"
|
||||||
<option value="" class="text-gray-500">-</option>
|
/>
|
||||||
for _, parent := range filterNoChildNoSelf(parents, id) {
|
<label for="parent-id" class="text-sm text-gray-500">Parent</label>
|
||||||
<option
|
<select name="parent-id" class="mr-auto bg-white input">
|
||||||
selected?={ parentId == parent.Id }
|
<option value="" class="text-gray-500">-</option>
|
||||||
value={ parent.Id.String() }
|
for _, parent := range filterNoChildNoSelf(parents, id) {
|
||||||
>{ parent.Name }</option>
|
<option
|
||||||
}
|
selected?={ parentId == parent.Id }
|
||||||
</select>
|
value={ parent.Id.String() }
|
||||||
|
>{ parent.Name }</option>
|
||||||
|
}
|
||||||
|
</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="#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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
39
types/transaction_recurring.go
Normal file
39
types/transaction_recurring.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user