fix: payment validation — tip cap ValidateAmount, RefundRequest gt=0 tag, float64 pence conversion docs

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent e5c151f994
commit 348a2e5bfc
3 changed files with 15 additions and 7 deletions
+7 -7
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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