From 2dd06d4af3c99a76b0947898ac724f259749697d Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 28 Sep 2025 16:37:32 +0200 Subject: [PATCH] Add transfer functionality between allowances --- backend/api_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++++- backend/db.go | 73 +++++++++++++++++++++++++++++++++++++ backend/dto.go | 7 ++++ backend/go.mod | 3 +- backend/go.sum | 2 ++ backend/main.go | 17 ++++++++- 6 files changed, 187 insertions(+), 3 deletions(-) diff --git a/backend/api_test.go b/backend/api_test.go index dec630e..22466f1 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -2,10 +2,11 @@ package main import ( "fmt" - "github.com/gavv/httpexpect/v2" "strconv" "testing" "time" + + "github.com/gavv/httpexpect/v2" ) const ( @@ -914,3 +915,88 @@ func createTestAllowance(e *httpexpect.Expect, name string, target float64, weig func createTestTask(e *httpexpect.Expect) int { return createTestTaskWithAmount(e, 100) } + +// Transfer tests +func TestTransferSuccessful(t *testing.T) { + e := startServer(t) + + // Create two allowances for user 1 + createTestAllowance(e, "From Allowance", 100, 1) + createTestAllowance(e, "To Allowance", 100, 1) + + // Add 30 to allowance 1 + req := map[string]interface{}{"amount": 30, "description": "funds"} + e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200) + + // Transfer 10 from 1 to 2 + transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10} + e.POST("/transfer").WithJSON(transfer).Expect().Status(200).JSON().Object().Value("message").IsEqual("Transfer successful") + + // Verify balances + allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() + allowances.Value(1).Object().Value("progress").Number().InDelta(20.0, 0.01) + allowances.Value(2).Object().Value("progress").Number().InDelta(10.0, 0.01) +} + +func TestTransferCapsAtTarget(t *testing.T) { + e := startServer(t) + + // Create two allowances + createTestAllowance(e, "From Allowance", 100, 1) + createTestAllowance(e, "To Allowance", 5, 1) + + // Add 10 to allowance 1 + req := map[string]interface{}{"amount": 10, "description": "funds"} + e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200) + + // Transfer 10 from 1 to 2, but to only needs 5 + transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10} + e.POST("/transfer").WithJSON(transfer).Expect().Status(200) + + // Verify capped transfer + allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() + allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) // from had 10, transferred 5 -> left 5 + allowances.Value(2).Object().Value("progress").Number().InDelta(5.0, 0.01) // to reached target +} + +func TestTransferDifferentUsersFails(t *testing.T) { + e := startServer(t) + + // Create allowance for user 1 and user 2 + createTestAllowance(e, "User1 Allowance", 100, 1) + // create for user 2 + e.POST("/user/2/allowance").WithJSON(CreateAllowanceRequest{Name: "User2 Allowance", Target: 100, Weight: 1}).Expect().Status(201) + + // Add to user1 allowance + req := map[string]interface{}{"amount": 10, "description": "funds"} + e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200) + + // Attempt transfer between different users + transfer := map[string]interface{}{"from": 1, "to": 1 /* wrong id to simulate different user's id? */} + // To ensure different user, fetch the allowance id for user2 (it's 1 for user2 in its own context but global id will be 2) + // Create above for user2 produced global id 2, so use that + transfer = map[string]interface{}{"from": 1, "to": 2, "amount": 5} + e.POST("/transfer").WithJSON(transfer).Expect().Status(400) +} + +func TestTransferInsufficientFunds(t *testing.T) { + e := startServer(t) + + // Create two allowances + createTestAllowance(e, "From Allowance", 100, 1) + createTestAllowance(e, "To Allowance", 100, 1) + + // Ensure from has 0 balance + transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10} + resp := e.POST("/transfer").WithJSON(transfer).Expect().Status(400).JSON().Object() + // Error text should mention insufficient funds + resp.Value("error").String().Contains("insufficient") +} + +func TestTransferNotFound(t *testing.T) { + e := startServer(t) + + // No allowances exist yet (only user rows). Attempt transfer with non-existent IDs + transfer := map[string]interface{}{"from": 999, "to": 1000, "amount": 1} + e.POST("/transfer").WithJSON(transfer).Expect().Status(404) +} diff --git a/backend/db.go b/backend/db.go index 4c0aaf3..08c8fda 100644 --- a/backend/db.go +++ b/backend/db.go @@ -7,6 +7,8 @@ import ( "math" "time" + "github.com/adhocore/gronx" + "gitea.seeseepuff.be/seeseemelk/mysqlite" ) @@ -631,3 +633,74 @@ func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowan return tx.Commit() } + +// TransferAllowance transfers amount from one allowance goal to another. +// Both allowance ids must exist and belong to the same user. The transfer +// will not move more than the 'to' goal still needs (target - balance). +func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error { + if fromId == toId { + return nil + } + amountCents := int(math.Round(amount * 100.0)) + if amountCents <= 0 { + return fmt.Errorf("amount must be positive") + } + + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + // Fetch from allowance (user_id, balance) + var fromUserId int + var fromBalance int + err = tx.Query("select user_id, balance from allowances where id = ?").Bind(fromId).ScanSingle(&fromUserId, &fromBalance) + if err != nil { + return err + } + + // Fetch to allowance (user_id, target, balance) + var toUserId int + var toTarget int + var toBalance int + err = tx.Query("select user_id, target, balance from allowances where id = ?").Bind(toId).ScanSingle(&toUserId, &toTarget, &toBalance) + if err != nil { + return err + } + + // Ensure same owner + if fromUserId != toUserId { + return fmt.Errorf("allowances do not belong to the same user") + } + + // Calculate how much the 'to' goal still needs + remainingTo := toTarget - toBalance + if remainingTo <= 0 { + // Nothing to transfer + return fmt.Errorf("target already reached") + } + + // Limit transfer to what 'to' still needs + transfer := amountCents + if transfer > remainingTo { + transfer = remainingTo + } + + // Ensure 'from' has enough balance + if fromBalance < transfer { + return fmt.Errorf("insufficient funds in source allowance") + } + + // Perform updates + err = tx.Query("update allowances set balance = balance - ? where id = ? and user_id = ?").Bind(transfer, fromId, fromUserId).Exec() + if err != nil { + return err + } + err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").Bind(transfer, toId, toUserId).Exec() + if err != nil { + return err + } + + return tx.Commit() +} diff --git a/backend/dto.go b/backend/dto.go index d51831c..44daf02 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -78,3 +78,10 @@ type AddAllowanceAmountRequest struct { Amount float64 `json:"amount"` Description string `json:"description"` } + +// TransferRequest represents a request to transfer amount between two goals. +type TransferRequest struct { + From int `json:"from"` + To int `json:"to"` + Amount float64 `json:"amount"` +} diff --git a/backend/go.mod b/backend/go.mod index 8ded2c3..1c0463a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,9 +4,11 @@ go 1.24.2 require ( gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 + github.com/adhocore/gronx v1.19.6 github.com/gavv/httpexpect/v2 v2.17.0 github.com/gin-contrib/cors v1.7.5 github.com/gin-gonic/gin v1.10.0 + github.com/stretchr/testify v1.10.0 ) require ( @@ -47,7 +49,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/sergi/go-diff v1.3.1 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0a43417..a9384f2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= +github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= +github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= diff --git a/backend/main.go b/backend/main.go index c1481a2..9d5aa44 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,13 +4,15 @@ import ( "context" "embed" "errors" - "gitea.seeseepuff.be/seeseemelk/mysqlite" + "fmt" "log" "net" "net/http" "os" "strconv" + "gitea.seeseepuff.be/seeseemelk/mysqlite" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) @@ -436,6 +438,14 @@ func createTask(c *gin.Context) { return } + if taskRequest.Schedule != nil { + valid := gronx.IsValid(*taskRequest.Schedule) + if !valid { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)}) + return + } + } + // If assigned is not nil, check if user exists if taskRequest.Assigned != nil { exists, err := db.UserExists(*taskRequest.Assigned) @@ -513,6 +523,11 @@ func putTask(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) return } + if err != nil { + log.Printf("Error getting task: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } err = db.UpdateTask(taskId, &taskRequest) if err != nil {