Add transfer functionality between allowances
All checks were successful
Backend Build and Test / build (push) Successful in 3m45s

This commit is contained in:
Sebastiaan de Schaetzen 2025-09-28 16:37:32 +02:00
parent 1e463fec55
commit ba8a7e8690
4 changed files with 234 additions and 8 deletions

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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"`
}

View File

@ -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,