Add transfer functionality between allowances
This commit is contained in:
parent
5a20e76df2
commit
2dd06d4af3
@ -2,10 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gavv/httpexpect/v2"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gavv/httpexpect/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -914,3 +915,88 @@ func createTestAllowance(e *httpexpect.Expect, name string, target float64, weig
|
|||||||
func createTestTask(e *httpexpect.Expect) int {
|
func createTestTask(e *httpexpect.Expect) int {
|
||||||
return createTestTaskWithAmount(e, 100)
|
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)
|
||||||
|
}
|
||||||
|
@ -7,6 +7,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/adhocore/gronx"
|
||||||
|
|
||||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -631,3 +633,74 @@ func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowan
|
|||||||
|
|
||||||
return tx.Commit()
|
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()
|
||||||
|
}
|
||||||
|
@ -78,3 +78,10 @@ type AddAllowanceAmountRequest struct {
|
|||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
Description string `json:"description"`
|
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"`
|
||||||
|
}
|
||||||
|
@ -4,9 +4,11 @@ go 1.24.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
|
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/gavv/httpexpect/v2 v2.17.0
|
||||||
github.com/gin-contrib/cors v1.7.5
|
github.com/gin-contrib/cors v1.7.5
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -47,7 +49,6 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sanity-io/litter v1.5.8 // indirect
|
github.com/sanity-io/litter v1.5.8 // indirect
|
||||||
github.com/sergi/go-diff v1.3.1 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
@ -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=
|
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 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
|
||||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
|
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 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
|
@ -4,13 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -436,6 +438,14 @@ func createTask(c *gin.Context) {
|
|||||||
return
|
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 assigned is not nil, check if user exists
|
||||||
if taskRequest.Assigned != nil {
|
if taskRequest.Assigned != nil {
|
||||||
exists, err := db.UserExists(*taskRequest.Assigned)
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
|
||||||
return
|
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)
|
err = db.UpdateTask(taskId, &taskRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user