From 348a2e5bfc45ae52a291de134bfc645959df8b91 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 20 Aug 2026 16:34:19 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20payment=20validation=20=E2=80=94=20tip?= =?UTF-8?q?=20cap=20ValidateAmount,=20RefundRequest=20gt=3D0=20tag,=20floa?= =?UTF-8?q?t64=20pence=20conversion=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/payments/handlers.go | 14 +++++++------- backend/handlers/payments/sweep.go | 4 ++++ backend/handlers/payments/till.go | 4 ++++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index eaf5771..6ad4d74 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -150,7 +150,7 @@ type CreateBookingPaymentRequest struct { } type RefundRequest struct { - Amount int64 `json:"amount"` + Amount int64 `json:"amount" validate:"required,gt=0"` Reason string `json:"reason"` // Optional client-generated idempotency key. Two DISTINCT refunds of the // same amount against the same payment must not collide on the default @@ -4900,6 +4900,12 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } + if err := ValidateAmount(req.Amount); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + // Product rule (security): only verified accounts may save cards. An // unverified/guest/affiliate user may still tip, but save_card=true is // rejected here — before any charge source resolution or payment record. @@ -4935,12 +4941,6 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { } } - if err := ValidateAmount(req.Amount); err != nil { - log.Printf("Failed to process request: %v", err) - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - // maxOnlineTipPence bound: ValidateAmount's generic £10,000 cap is the // money-minting ceiling for booking charges, but a tip is gratuity on a // percentage of the service — a single online tip over £250 is not a diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index fcef404..6b116e0 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -762,6 +762,8 @@ func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) { r.IsCreate = isCreate != nil && *isCreate r.HasGiftCard = hasGiftCard r.AmountPence = int64(math.Round(r.TotalAmount * 100)) + // NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay. + // A full fix would store pence as int64 throughout (out of scope for this change). return r, nil } var amount float64 @@ -771,6 +773,8 @@ func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) { } r.SquareRequestSnapshot = []byte(snapshot.String) r.AmountPence = int64(math.Round(amount * 100)) + // NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay. + // A full fix would store pence as int64 throughout (out of scope for this change). if bookingID.Valid && bookingID.String != "" { b := bookingID.String r.BookingID = &b diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index db4b288..b03f422 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -197,6 +197,8 @@ func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string { sb.WriteString(adminID) sb.WriteString(":") sb.WriteString(strconv.FormatInt(int64(math.Round(req.Amount*100)), 10)) + // NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay. + // A full fix would store pence as int64 throughout (out of scope for this change). if req.GiftCardID != nil && *req.GiftCardID != "" { sb.WriteString(":gc:") sb.WriteString(validators.NormalizeGiftCardCode(*req.GiftCardID)) @@ -610,6 +612,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // the effective pence figure exactly as the Square charge amount below is // derived (math.Round(req.Amount * 100)). amountPence := int64(math.Round(req.Amount * 100)) + // NOTE: float64→int64 pence conversion can drift by ±1p between charge and replay. + // A full fix would store pence as int64 throughout (out of scope for this change). // Gift-card creates/topups are additionally capped at £250 per transaction // (owner decision — the shared maxAdminGiftCardTransactionPence from // giftcard_limits.go, the single source of the £250 cap). Both the create