From ba8a7e8690f43e8fa18efd779d06e3ce2782c768 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 | 74 +++++++++++++++++++++++++++++++++++++- backend/dto.go | 7 ++++ backend/main.go | 73 +++++++++++++++++++++++++++++++++---- 4 files changed, 234 insertions(+), 8 deletions(-) diff --git a/backend/api_test.go b/backend/api_test.go index 7959b17..dd3db77 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 ( @@ -963,3 +964,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 71b7d9d..9738e1d 100644 --- a/backend/db.go +++ b/backend/db.go @@ -3,11 +3,12 @@ package main import ( "errors" "fmt" - "github.com/adhocore/gronx" "log" "math" "time" + "github.com/adhocore/gronx" + "gitea.seeseepuff.be/seeseemelk/mysqlite" ) @@ -711,3 +712,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 6b89d3c..0ecee10 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -80,3 +80,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/main.go b/backend/main.go index 514972c..adcf302 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,14 +5,15 @@ import ( "embed" "errors" "fmt" - "gitea.seeseepuff.be/seeseemelk/mysqlite" - "github.com/adhocore/gronx" "log" "net" "net/http" "os" "strconv" + "gitea.seeseepuff.be/seeseemelk/mysqlite" + "github.com/adhocore/gronx" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) @@ -653,10 +654,68 @@ func getHistory(c *gin.Context) { c.IndentedJSON(http.StatusOK, history) } -/* -Initialises the database, and then starts the server. -If the context gets cancelled, the server is shutdown and the database is closed. -*/ +// TransferRequest handler: accepts JSON with from, to, amount and transfers between allowances +func transferHandler(c *gin.Context) { + var req TransferRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("Error parsing transfer request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.From == 0 || req.To == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance id"}) + return + } + if req.Amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Amount must be positive"}) + return + } + + // Verify both allowances exist and fetch owners + var fromUserId int + err := db.db.Query("select user_id from allowances where id = ?").Bind(req.From).ScanSingle(&fromUserId) + if err != nil { + if errors.Is(err, mysqlite.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Source allowance not found"}) + return + } + log.Printf("Error checking source allowance: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + + var toUserId int + err = db.db.Query("select user_id from allowances where id = ?").Bind(req.To).ScanSingle(&toUserId) + if err != nil { + if errors.Is(err, mysqlite.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Destination allowance not found"}) + return + } + log.Printf("Error checking destination allowance: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + + if fromUserId != toUserId { + c.JSON(http.StatusBadRequest, gin.H{"error": "Allowances do not belong to the same user"}) + return + } + + // Perform transfer + err = db.TransferAllowance(req.From, req.To, req.Amount) + if err != nil { + // Map common errors to 400 + if errors.Is(err, mysqlite.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Transfer successful"}) +} + func start(ctx context.Context, config *ServerConfig) { db = NewDb(config.Datasource) defer db.db.MustClose() @@ -688,6 +747,8 @@ func start(ctx context.Context, config *ServerConfig) { router.PUT("/api/task/:taskId", putTask) router.DELETE("/api/task/:taskId", deleteTask) router.POST("/api/task/:taskId/complete", completeTask) + // transfer endpoint + router.POST("/api/transfer", transferHandler) srv := &http.Server{ Addr: config.Addr,