feat: extract into remaining packages
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s

There has been a cyclic dependency.
transaction
	-> treasure_chest
	-> transaction_recurring
	-> transaction

This has been temporarily solved by moving the GenerateTransactions
function into the transaction package. In the future, this function has
to be rewritten to use a proper Service insteas of direct DB access or
replaced with a different system entirely.
This commit is contained in:
2025-12-31 22:24:21 +01:00
parent 6de8d8fb10
commit 1be46780bb
29 changed files with 230 additions and 251 deletions

View File

@@ -4,7 +4,6 @@ import (
"github.com/a-h/templ" "github.com/a-h/templ"
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
) )
type Handler struct { type Handler struct {
@@ -32,7 +31,7 @@ func (h Handler) handleAccountPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -53,7 +52,7 @@ func (h Handler) handleAccountItemComp() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -86,7 +85,7 @@ func (h Handler) handleUpdateAccount() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -121,7 +120,7 @@ func (h Handler) handleDeleteAccount() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }

View File

@@ -1,7 +1,9 @@
package account package account
import "spend-sparrow/internal/template/svg" import (
import "spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
)
templ template(accounts []*Account) { templ template(accounts []*Account) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
@@ -82,9 +84,9 @@ templ accountItem(account *Account) {
<div class="text-xl flex justify-end gap-4"> <div class="text-xl flex justify-end gap-4">
<p class="mr-auto">{ account.Name }</p> <p class="mr-auto">{ account.Name }</p>
if account.CurrentBalance < 0 { if account.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p> <p class="mr-20 text-red-700">{ core.FormatEuros(account.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p> <p class="mr-20 text-green-700">{ core.FormatEuros(account.CurrentBalance) }</p>
} }
<a <a
href={ templ.URL("/transaction?account-id=" + account.Id.String()) } href={ templ.URL("/transaction?account-id=" + account.Id.String()) }

View File

@@ -8,7 +8,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/authentication/template" "spend-sparrow/internal/authentication/template"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
"time" "time"
) )
@@ -62,9 +61,9 @@ func (handler HandlerImpl) handleSignInPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
if !user.EmailVerified { if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} else { } else {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
return return
} }
@@ -79,7 +78,7 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) { user, err := core.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
session := core.GetSession(r) session := core.GetSession(r)
email := r.FormValue("email") email := r.FormValue("email")
password := r.FormValue("password") password := r.FormValue("password")
@@ -97,17 +96,17 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
if err != nil { if err != nil {
if errors.Is(err, ErrInvalidCredentials) { if errors.Is(err, ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
} }
return return
} }
if user.EmailVerified { if user.EmailVerified {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} else { } else {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} }
} }
} }
@@ -120,9 +119,9 @@ func (handler HandlerImpl) handleSignUpPage() http.HandlerFunc {
if user != nil { if user != nil {
if !user.EmailVerified { if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} else { } else {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
return return
} }
@@ -138,12 +137,12 @@ func (handler HandlerImpl) handleSignUpVerifyPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
if user.EmailVerified { if user.EmailVerified {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
return return
} }
@@ -158,7 +157,7 @@ func (handler HandlerImpl) handleVerifyResendComp() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -200,7 +199,7 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
var email = r.FormValue("email") var email = r.FormValue("email")
var password = r.FormValue("password") var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) { _, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
slog.InfoContext(r.Context(), "signing up", "email", email) slog.InfoContext(r.Context(), "signing up", "email", email)
user, err := handler.service.SignUp(r.Context(), email, password) user, err := handler.service.SignUp(r.Context(), email, password)
if err != nil { if err != nil {
@@ -215,19 +214,19 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, core.ErrInternal): case errors.Is(err, core.ErrInternal):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
return return
case errors.Is(err, ErrInvalidEmail): case errors.Is(err, ErrInvalidEmail):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return return
case errors.Is(err, ErrInvalidPassword): case errors.Is(err, ErrInvalidPassword):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest)
return return
} }
// If err is "service.ErrAccountExists", then just continue // If err is "service.ErrAccountExists", then just continue
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
} }
} }
@@ -256,7 +255,7 @@ func (handler HandlerImpl) handleSignOut() http.HandlerFunc {
} }
http.SetCookie(w, &c) http.SetCookie(w, &c)
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
} }
@@ -266,7 +265,7 @@ func (handler HandlerImpl) handleDeleteAccountPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -281,7 +280,7 @@ func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -290,14 +289,14 @@ func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
err := handler.service.DeleteAccount(r.Context(), user, password) err := handler.service.DeleteAccount(r.Context(), user, password)
if err != nil { if err != nil {
if errors.Is(err, ErrInvalidCredentials) { if errors.Is(err, ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} }
return return
} }
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
} }
@@ -310,7 +309,7 @@ func (handler HandlerImpl) handleChangePasswordPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil && !isPasswordReset { if user == nil && !isPasswordReset {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -326,7 +325,7 @@ func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
session := core.GetSession(r) session := core.GetSession(r)
user := core.GetUser(r) user := core.GetUser(r)
if session == nil || user == nil { if session == nil || user == nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
return return
} }
@@ -335,11 +334,11 @@ func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass) err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
return return
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
} }
} }
@@ -349,7 +348,7 @@ func (handler HandlerImpl) handleForgotPasswordPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
return return
} }
@@ -364,19 +363,19 @@ func (handler HandlerImpl) handleForgotPasswordComp() http.HandlerFunc {
email := r.FormValue("email") email := r.FormValue("email")
if email == "" { if email == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
return return
} }
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) { _, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
err := handler.service.SendForgotPasswordMail(r.Context(), email) err := handler.service.SendForgotPasswordMail(r.Context(), email)
return nil, err return nil, err
}) })
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
} }
} }
} }
@@ -388,7 +387,7 @@ func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url")) pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
if err != nil { if err != nil {
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err) slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return return
} }
@@ -397,9 +396,9 @@ func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
err = handler.service.ForgotPassword(r.Context(), token, newPass) err = handler.service.ForgotPassword(r.Context(), token, newPass)
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
} }
} }
} }

View File

@@ -9,7 +9,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
mailTemplate "spend-sparrow/internal/template/mail" mailTemplate "spend-sparrow/internal/template/mail"
"spend-sparrow/internal/types"
"strings" "strings"
"time" "time"
@@ -54,10 +53,10 @@ type ServiceImpl struct {
random core.Random random core.Random
clock core.Clock clock core.Clock
mail core.Mail mail core.Mail
serverSettings *types.Settings serverSettings *core.Settings
} }
func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *types.Settings) *ServiceImpl { func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *core.Settings) *ServiceImpl {
return &ServiceImpl{ return &ServiceImpl{
db: db, db: db,
random: random, random: random,

View File

@@ -6,7 +6,6 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/utils"
"strings" "strings"
) )
@@ -48,17 +47,17 @@ func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, er
func HandleError(w http.ResponseWriter, r *http.Request, err error) { func HandleError(w http.ResponseWriter, r *http.Request, err error) {
switch { switch {
case errors.Is(err, ErrUnauthorized): case errors.Is(err, ErrUnauthorized):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized) TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
return return
case errors.Is(err, ErrBadRequest): case errors.Is(err, ErrBadRequest):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest) TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return return
case errors.Is(err, ErrNotFound): case errors.Is(err, ErrNotFound):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound) TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
return return
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} }
func extractErrorMessage(err error) string { func extractErrorMessage(err error) string {

View File

@@ -1,4 +1,4 @@
package types package core
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package utils package core
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package log package core
import ( import (
"context" "context"

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/smtp" "net/smtp"
"spend-sparrow/internal/types"
) )
type Mail interface { type Mail interface {
@@ -14,10 +13,10 @@ type Mail interface {
} }
type MailImpl struct { type MailImpl struct {
server *types.Settings server *Settings
} }
func NewMail(server *types.Settings) MailImpl { func NewMail(server *Settings) MailImpl {
return MailImpl{server: server} return MailImpl{server: server}
} }

View File

@@ -1,4 +1,4 @@
package types package core
import ( import (
"context" "context"

View File

@@ -5,9 +5,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/dashboard/template"
"spend-sparrow/internal/treasure_chest" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/utils"
"strings" "strings"
"time" "time"
@@ -45,7 +43,7 @@ func (handler HandlerImpl) handleDashboard() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -55,7 +53,7 @@ func (handler HandlerImpl) handleDashboard() http.HandlerFunc {
return return
} }
comp := template.Dashboard(treasureChests) comp := DashboardComp(treasureChests)
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK) handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
} }
} }

View File

@@ -4,9 +4,9 @@ import (
"context" "context"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/transaction"
"spend-sparrow/internal/treasure_chest" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -31,7 +31,7 @@ func (s Service) MainChart(
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
transactions := make([]types.Transaction, 0) transactions := make([]transaction.Transaction, 0)
err := s.db.SelectContext(ctx, &transactions, ` err := s.db.SelectContext(ctx, &transactions, `
SELECT * SELECT *
FROM "transaction" FROM "transaction"
@@ -130,7 +130,7 @@ func (s Service) TreasureChest(
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
transactions := make([]types.Transaction, 0) transactions := make([]transaction.Transaction, 0)
err := s.db.SelectContext(ctx, &transactions, ` err := s.db.SelectContext(ctx, &transactions, `
SELECT * SELECT *
FROM "transaction" FROM "transaction"

View File

@@ -1,8 +1,8 @@
package template package dashboard
import "spend-sparrow/internal/treasure_chest_types" import "spend-sparrow/internal/treasure_chest_types"
templ Dashboard(treasureChests []*treasure_chest_types.TreasureChest) { templ DashboardComp(treasureChests []*treasure_chest_types.TreasureChest) {
<div class="mt-10 h-full"> <div class="mt-10 h-full">
<div id="main-chart" class="h-96 mt-10"></div> <div id="main-chart" class="h-96 mt-10"></div>
<div id="treasure-chests" class="h-96 mt-10"></div> <div id="treasure-chests" class="h-96 mt-10"></div>

View File

@@ -1 +0,0 @@
package template

View File

@@ -13,11 +13,9 @@ import (
"spend-sparrow/internal/dashboard" "spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler" "spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/log" "spend-sparrow/internal/transaction"
"spend-sparrow/internal/service"
"spend-sparrow/internal/transaction_recurring" "spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/types"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -31,7 +29,7 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
otelEnabled := types.IsOtelEnabled(env) otelEnabled := core.IsOtelEnabled(env)
if otelEnabled { if otelEnabled {
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled // use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
otelShutdown, err := setupOTelSDK(context.Background()) otelShutdown, err := setupOTelSDK(context.Background())
@@ -48,13 +46,13 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
cancel() cancel()
}() }()
slog.SetDefault(log.NewLogPropagator()) slog.SetDefault(core.NewLogPropagator())
} }
slog.InfoContext(ctx, "Starting server...") slog.InfoContext(ctx, "Starting server...")
// init server settings // init server settings
serverSettings, err := types.NewSettingsFromEnv(ctx, env) serverSettings, err := core.NewSettingsFromEnv(ctx, env)
if err != nil { if err != nil {
return err return err
} }
@@ -107,7 +105,7 @@ func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
} }
} }
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler { func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *core.Settings) http.Handler {
var router = http.NewServeMux() var router = http.NewServeMux()
authDb := authentication.NewDbSqlite(d) authDb := authentication.NewDbSqlite(d)
@@ -119,8 +117,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings) authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings)
accountService := account.NewServiceImpl(d, randomService, clockService) accountService := account.NewServiceImpl(d, randomService, clockService)
treasureChestService := treasure_chest.NewService(d, randomService, clockService) treasureChestService := treasure_chest.NewService(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService) transactionService := transaction.NewService(d, randomService, clockService)
transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService, transactionService) transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService)
dashboardService := dashboard.NewService(d) dashboardService := dashboard.NewService(d)
render := core.NewRender() render := core.NewRender()
@@ -129,10 +127,10 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authHandler := authentication.NewHandler(authService, render) authHandler := authentication.NewHandler(authService, render)
accountHandler := account.NewHandler(accountService, render) accountHandler := account.NewHandler(accountService, render)
treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render) treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render) transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render) transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render)
go dailyTaskTimer(ctx, transactionRecurringService, authService) go dailyTaskTimer(ctx, transactionService, authService)
indexHandler.Handle(router) indexHandler.Handle(router)
dashboardHandler.Handle(router) dashboardHandler.Handle(router)
@@ -160,8 +158,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
return wrapper return wrapper
} }
func dailyTaskTimer(ctx context.Context, transactionRecurring transaction_recurring.Service, auth authentication.Service) { func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
runDailyTasks(ctx, transactionRecurring, auth) runDailyTasks(ctx, transaction, auth)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
@@ -170,13 +168,13 @@ func dailyTaskTimer(ctx context.Context, transactionRecurring transaction_recurr
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
runDailyTasks(ctx, transactionRecurring, auth) runDailyTasks(ctx, transaction, auth)
} }
} }
} }
func runDailyTasks(ctx context.Context, transactionRecurring transaction_recurring.Service, auth authentication.Service) { func runDailyTasks(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
slog.InfoContext(ctx, "Running daily tasks") slog.InfoContext(ctx, "Running daily tasks")
_ = transactionRecurring.GenerateTransactions(ctx) _ = transaction.GenerateRecurringTransactions(ctx)
_ = auth.CleanupSessionsAndTokens(ctx) _ = auth.CleanupSessionsAndTokens(ctx)
} }

View File

@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
"strings" "strings"
) )
@@ -52,7 +51,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) { if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken) slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
if r.Header.Get("Hx-Request") == "true" { if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest) core.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else { } else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest) http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
} }
@@ -63,7 +62,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
token, err := auth.GetCsrfToken(ctx, session) token, err := auth.GetCsrfToken(ctx, session)
if err != nil { if err != nil {
if r.Header.Get("Hx-Request") == "true" { if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest) core.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest)
} else { } else {
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest) http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
} }

View File

@@ -2,10 +2,10 @@ package middleware
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
) )
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler { func SecurityHeaders(serverSettings *core.Settings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")

View File

@@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/template" "spend-sparrow/internal/template"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -36,7 +35,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
htmx := utils.IsHtmx(r) htmx := core.IsHtmx(r)
var comp templ.Component var comp templ.Component
@@ -46,7 +45,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound status = http.StatusNotFound
} else { } else {
if user != nil { if user != nil {
utils.DoRedirect(w, r, "/dashboard") core.DoRedirect(w, r, "/dashboard")
return return
} else { } else {
comp = template.Index() comp = template.Index()

View File

@@ -1 +0,0 @@
package transaction

View File

@@ -1,4 +1,4 @@
package handler package transaction
import ( import (
"fmt" "fmt"
@@ -6,12 +6,8 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/account" "spend-sparrow/internal/account"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/transaction"
"spend-sparrow/internal/treasure_chest" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"strconv" "strconv"
"time" "time"
@@ -23,19 +19,19 @@ const (
DECIMALS_MULTIPLIER = 100 DECIMALS_MULTIPLIER = 100
) )
type Transaction interface { type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type TransactionImpl struct { type HandlerImpl struct {
s service.Transaction s Service
account account.Service account account.Service
treasureChest treasure_chest.Service treasureChest treasure_chest.Service
r *core.Render r *core.Render
} }
func NewTransaction(s service.Transaction, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Transaction { func NewHandler(s Service, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Handler {
return TransactionImpl{ return HandlerImpl{
s: s, s: s,
account: account, account: account,
treasureChest: treasureChest, treasureChest: treasureChest,
@@ -43,7 +39,7 @@ func NewTransaction(s service.Transaction, account account.Service, treasureChes
} }
} }
func (h TransactionImpl) Handle(r *http.ServeMux) { func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction", h.handleTransactionPage()) r.Handle("GET /transaction", h.handleTransactionPage())
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp()) r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction()) r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
@@ -51,17 +47,17 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction()) r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
} }
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { func (h HandlerImpl) handleTransactionPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
filter := types.TransactionItemsFilter{ filter := TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"), AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"), TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
@@ -88,23 +84,23 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
items := t.TransactionItems(transactions, accountMap, treasureChestMap) items := TransactionItems(transactions, accountMap, treasureChestMap)
if utils.IsHtmx(r) { if core.IsHtmx(r) {
h.r.Render(r, w, items) h.r.Render(r, w, items)
} else { } else {
comp := t.Transaction(items, filter, accounts, treasureChests) comp := TransactionComp(items, filter, accounts, treasureChests)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
} }
func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { func (h HandlerImpl) handleTransactionItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -122,7 +118,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditTransaction(nil, accounts, treasureChests) comp := EditTransaction(nil, accounts, treasureChests)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
@@ -135,22 +131,22 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditTransaction(transaction, accounts, treasureChests) comp = EditTransaction(transaction, accounts, treasureChests)
} else { } else {
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp = t.TransactionItem(transaction, accountMap, treasureChestMap) comp = TransactionItem(transaction, accountMap, treasureChestMap)
} }
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -203,7 +199,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
return return
} }
input := types.Transaction{ input := Transaction{
Id: id, Id: id,
AccountId: accountId, AccountId: accountId,
TreasureChestId: treasureChestId, TreasureChestId: treasureChestId,
@@ -213,7 +209,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
Description: r.FormValue("description"), Description: r.FormValue("description"),
} }
var transaction *types.Transaction var transaction *Transaction
if idStr == "new" { if idStr == "new" {
transaction, err = h.s.Add(r.Context(), nil, user, input) transaction, err = h.s.Add(r.Context(), nil, user, input)
if err != nil { if err != nil {
@@ -241,18 +237,18 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
} }
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp := t.TransactionItem(transaction, accountMap, treasureChestMap) comp := TransactionItem(transaction, accountMap, treasureChestMap)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TransactionImpl) handleRecalculate() http.HandlerFunc { func (h HandlerImpl) handleRecalculate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -262,17 +258,17 @@ func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
return return
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
} }
} }
func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { func (h HandlerImpl) handleDeleteTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -286,7 +282,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
} }
} }
func (h TransactionImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) { func (h HandlerImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
accountMap := make(map[uuid.UUID]string, 0) accountMap := make(map[uuid.UUID]string, 0)
for _, account := range accounts { for _, account := range accounts {
accountMap[account.Id] = account.Name accountMap[account.Id] = account.Name

View File

@@ -1,4 +1,4 @@
package service package transaction
import ( import (
"context" "context"
@@ -7,8 +7,8 @@ import (
"log/slog" "log/slog"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"strconv" "strconv"
"time" "time"
@@ -18,31 +18,32 @@ import (
const page_size = 25 const page_size = 25
type Transaction interface { type Service interface {
Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction types.Transaction) (*types.Transaction, error) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction Transaction) (*Transaction, error)
Update(ctx context.Context, user *auth_types.User, transaction types.Transaction) (*types.Transaction, error) Update(ctx context.Context, user *auth_types.User, transaction Transaction) (*Transaction, error)
Get(ctx context.Context, user *auth_types.User, id string) (*types.Transaction, error) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error)
GetAll(ctx context.Context, user *auth_types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error)
Delete(ctx context.Context, user *auth_types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
RecalculateBalances(ctx context.Context, user *auth_types.User) error RecalculateBalances(ctx context.Context, user *auth_types.User) error
GenerateRecurringTransactions(ctx context.Context) error
} }
type TransactionImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock core.Clock clock core.Clock
random core.Random random core.Random
} }
func NewTransaction(db *sqlx.DB, random core.Random, clock core.Clock) Transaction { func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return TransactionImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
} }
} }
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput Transaction) (*Transaction, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -109,7 +110,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, input types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Transaction) (*Transaction, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -123,7 +124,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
_ = tx.Rollback() _ = tx.Rollback()
}() }()
transaction := &types.Transaction{} transaction := &Transaction{}
err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id) err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil { if err != nil {
@@ -208,7 +209,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id string) (*types.Transaction, error) { func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -218,7 +219,7 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
var transaction types.Transaction var transaction Transaction
err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = core.TransformAndLogDbError(ctx, "transaction Get", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Get", nil, err)
if err != nil { if err != nil {
@@ -231,7 +232,7 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) { func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -251,7 +252,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
} }
} }
transactions := make([]*types.Transaction, 0) transactions := make([]*Transaction, 0)
err = s.db.SelectContext(ctx, &transactions, ` err = s.db.SelectContext(ctx, &transactions, `
SELECT * SELECT *
FROM "transaction" FROM "transaction"
@@ -279,7 +280,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
return transactions, nil return transactions, nil
} }
func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id string) error { func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
if user == nil { if user == nil {
return core.ErrUnauthorized return core.ErrUnauthorized
} }
@@ -298,7 +299,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
_ = tx.Rollback() _ = tx.Rollback()
}() }()
var transaction types.Transaction var transaction Transaction
err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil { if err != nil {
@@ -344,7 +345,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
return nil return nil
} }
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error { func (s ServiceImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
if user == nil { if user == nil {
return core.ErrUnauthorized return core.ErrUnauthorized
} }
@@ -391,7 +392,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
} }
}() }()
var transaction types.Transaction var transaction Transaction
for rows.Next() { for rows.Next() {
err = rows.StructScan(&transaction) err = rows.StructScan(&transaction)
err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
@@ -445,7 +446,63 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
return nil return nil
} }
func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) GenerateRecurringTransactions(ctx context.Context) error {
now := s.clock.Now()
tx, err := s.db.BeginTxx(ctx, nil)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
recurringTransactions := make([]*transaction_recurring.TransactionRecurring, 0)
err = tx.SelectContext(ctx, &recurringTransactions, `
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
now)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
for _, transactionRecurring := range recurringTransactions {
user := &auth_types.User{
Id: transactionRecurring.UserId,
}
transaction := Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, err = s.Add(ctx, tx, user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s ServiceImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *Transaction, userId uuid.UUID, input Transaction) (*Transaction, error) {
var ( var (
id uuid.UUID id uuid.UUID
createdAt time.Time createdAt time.Time
@@ -513,7 +570,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
} }
} }
transaction := types.Transaction{ transaction := Transaction{
Id: id, Id: id,
UserId: userId, UserId: userId,
@@ -536,7 +593,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) updateErrors(t *types.Transaction) { func (s ServiceImpl) updateErrors(t *Transaction) {
errorStr := "" errorStr := ""
switch { switch {

View File

@@ -4,13 +4,13 @@ import (
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"spend-sparrow/internal/account" "spend-sparrow/internal/account"
"spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg" "spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"time" "time"
) )
templ Transaction(items templ.Component, filter types.TransactionItemsFilter, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) { templ TransactionComp(items templ.Component, filter TransactionItemsFilter, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<form <form
@@ -91,7 +91,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
</div> </div>
} }
templ TransactionItems(transactions []*types.Transaction, accounts, treasureChests map[uuid.UUID]string) { templ TransactionItems(transactions []*Transaction, accounts, treasureChests map[uuid.UUID]string) {
<div id="transaction-items" class="my-6"> <div id="transaction-items" class="my-6">
for _, transaction := range transactions { for _, transaction := range transactions {
@TransactionItem(transaction, accounts, treasureChests) @TransactionItem(transaction, accounts, treasureChests)
@@ -99,7 +99,7 @@ templ TransactionItems(transactions []*types.Transaction, accounts, treasureChes
</div> </div>
} }
templ EditTransaction(transaction *types.Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) { templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
{{ {{
var ( var (
timestamp time.Time timestamp time.Time
@@ -223,7 +223,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*account.Accoun
</div> </div>
} }
templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) { templ TransactionItem(transaction *Transaction, accounts, treasureChests map[uuid.UUID]string) {
{{ {{
background := "bg-gray-50" background := "bg-gray-50"
if transaction.Error != nil { if transaction.Error != nil {
@@ -276,9 +276,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</p> </p>
</div> </div>
if transaction.Value < 0 { if transaction.Value < 0 {
<p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p> <p class="mr-8 min-w-22 text-right text-red-700">{ core.FormatEuros(transaction.Value) }</p>
} else { } else {
<p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p> <p class="mr-8 w-22 text-right text-green-700">{ core.FormatEuros(transaction.Value) }</p>
} }
<button <button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" } hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }

View File

@@ -1,4 +1,4 @@
package types package transaction
import ( import (
"time" "time"

View File

@@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
) )
type Handler interface { type Handler interface {
@@ -35,7 +34,7 @@ func (h HandlerImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -52,7 +51,7 @@ func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -91,7 +90,7 @@ func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -113,7 +112,7 @@ func (h HandlerImpl) renderItems(w http.ResponseWriter, r *http.Request, user *a
var transactionsRecurring []*TransactionRecurring var transactionsRecurring []*TransactionRecurring
var err error var err error
if accountId == "" && treasureChestId == "" { if accountId == "" && treasureChestId == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
} }
if accountId != "" { if accountId != "" {
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId) transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)

View File

@@ -8,9 +8,7 @@ import (
"math" "math"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/service"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"strconv" "strconv"
"time" "time"
@@ -29,23 +27,19 @@ type Service interface {
GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error) GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error)
GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*TransactionRecurring, error) GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*TransactionRecurring, error)
Delete(ctx context.Context, user *auth_types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
GenerateTransactions(ctx context.Context) error
} }
type ServiceImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock core.Clock clock core.Clock
random core.Random random core.Random
transaction service.Transaction
} }
func NewService(db *sqlx.DB, random core.Random, clock core.Clock, transaction service.Transaction) Service { func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return ServiceImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
transaction: transaction,
} }
} }
@@ -324,62 +318,6 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
return nil return nil
} }
func (s ServiceImpl) GenerateTransactions(ctx context.Context) error {
now := s.clock.Now()
tx, err := s.db.BeginTxx(ctx, nil)
err = core.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
recurringTransactions := make([]*TransactionRecurring, 0)
err = tx.SelectContext(ctx, &recurringTransactions, `
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
now)
err = core.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
for _, transactionRecurring := range recurringTransactions {
user := &auth_types.User{
Id: transactionRecurring.UserId,
}
transaction := types.Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, err = s.transaction.Add(ctx, tx, user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = core.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = core.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s ServiceImpl) validateAndEnrichTransactionRecurring( func (s ServiceImpl) validateAndEnrichTransactionRecurring(
ctx context.Context, ctx context.Context,
tx *sqlx.Tx, tx *sqlx.Tx,

View File

@@ -1,9 +1,11 @@
package transaction_recurring package transaction_recurring
import "fmt" import (
import "time" "fmt"
import "spend-sparrow/internal/template/svg" "spend-sparrow/internal/core"
import "spend-sparrow/internal/types" "spend-sparrow/internal/template/svg"
"time"
)
templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, editId, accountId, treasureChestId string) { templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, editId, accountId, treasureChestId string) {
<!-- Don't use table, because embedded forms are only valid for cells --> <!-- Don't use table, because embedded forms are only valid for cells -->
@@ -53,9 +55,9 @@ templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accou
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s) Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
</p> </p>
if transactionRecurring.Value < 0 { if transactionRecurring.Value < 0 {
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-red-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
} else { } else {
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-green-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
} }
<div class="flex gap-2"> <div class="flex gap-2">
<button <button

View File

@@ -5,7 +5,6 @@ import (
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/transaction_recurring" "spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/google/uuid" "github.com/google/uuid"
@@ -42,7 +41,7 @@ func (h HandlerImpl) handleHandlerPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -71,7 +70,7 @@ func (h HandlerImpl) handleHandlerItemComp() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -118,7 +117,7 @@ func (h HandlerImpl) handleUpdateHandler() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -163,7 +162,7 @@ func (h HandlerImpl) handleDeleteHandler() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }

View File

@@ -2,9 +2,9 @@ package treasure_chest
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg" "spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
) )
templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) { templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
@@ -134,14 +134,14 @@ templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, month
<p class="mr-auto">{ treasureChest.Name }</p> <p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600"> <p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
+ { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span> + { core.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
} }
</p> </p>
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
if treasureChest.CurrentBalance < 0 { if treasureChest.CurrentBalance < 0 {
<p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-red-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-green-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
} }
} }
<a <a

View File

@@ -5,7 +5,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/types"
"spend-sparrow/mocks" "spend-sparrow/mocks"
"strings" "strings"
"testing" "testing"
@@ -18,7 +17,7 @@ import (
) )
var ( var (
settings = types.Settings{ settings = core.Settings{
Port: "", Port: "",
BaseUrl: "", BaseUrl: "",
Environment: "test", Environment: "test",