From e20bad869d7b03358f911e51c05bb5dd80f11375 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 | 71 ++++++++++++++++++++++++++++++++++++ backend/dto.go | 7 ++++ backend/go.mod | 4 +-- backend/go.sum | 10 ------ backend/main.go | 5 +-- 6 files changed, 170 insertions(+), 15 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..20418a2 100644 --- a/backend/db.go +++ b/backend/db.go @@ -711,3 +711,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/go.mod b/backend/go.mod index 4e1850c..7e0cf88 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,14 +4,15 @@ 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.1 + github.com/stretchr/testify v1.10.0 ) require ( github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect - github.com/adhocore/gronx v1.19.6 // indirect github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic v1.13.2 // indirect @@ -48,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.14 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 411208b..d7b0aab 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -34,8 +34,6 @@ github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYM github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -129,8 +127,6 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -220,8 +216,6 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= -modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -232,8 +226,6 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= @@ -243,7 +235,5 @@ modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= -zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/backend/main.go b/backend/main.go index 514972c..50b0753 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" )