Files
Crussell/backend/handlers/payments/loop_b_money_fixes_test.go
T
popertots 1543160f6a fix: loop-B full-scope adversarial findings — tip-excluded detail endpoints, £0-charge guard, 24h window, 2FA single-use everywhere
Loop B full-scope red-team (money/security/dup-mod) findings:
- CRITICAL: booking detail handlers (GetBookingHandler/GetAdminBookingHandler) now exclude payment_type='tip' from amount_paid — a tip before the final balance no longer undercharges the booking (bookings.go x3 sites)
- HIGH: A6 deposit clamp adds a zero-guard — when the eligible discount covers the entire deposit, the flow returns deposit_covered_by_discount instead of charging £0 at Square (real Square rejects £0; the mock accepted it); square_dev CreatePayment + CreateRefund now reject Amount <= 0 (mock/prod parity)
- HIGH: replayLegitimateRetryWindow restored to 22h (== stalePendingKeyedAge) so sweep-produced duplicate charges are still auto-refunded, not rescued-and-hidden
- HIGH: 2FA single-use consume-at-gate applied to ALL saved-card charge gates (booking 2263, admin saved-card 960, tip 4483, till 967, gift-card purchase 1482) with re-issue-on-failed-charge on each; pending-reuse retries keep their code
- MEDIUM: 2FA re-issue now fires only when the gate actually consumed a code (fresh saved-card path) — new-card failures no longer silently burn a standing code
- MEDIUM: pre_start tip-exclusion consistent across admin lists + detail handlers (bookings.go)
- MEDIUM: remaining-balance counts pending refunds (service.go) — capacity consistent with GetBookingPaymentInfo
- Mock CreatePayment/CreateRefund reject £0 amounts (INVALID_REQUEST_ERROR) for dev/prod parity

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00

153 lines
6.0 KiB
Go

//go:build test && dev
package payments
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// Finding 2 — the A6 deposit clamp can produce chargeAmount=0 with no guard:
// the handler then charged £0 at Square (invalid in prod, minted a completed
// £0 deposit in the dev mock that consumed the discount). A deposit whose
// eligible campaign credit covers the ENTIRE remaining obligation must skip the
// Square call and report deposit_covered_by_discount.
// =============================================================================
// seedActiveCampaign inserts an active time-based campaign with the given
// discount percent and returns its id.
func seedActiveCampaign(t *testing.T, ctx context.Context, q db.Querier, percent int) string {
t.Helper()
now := clock.Now()
var id string
err := q.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', $2, 'active', $3, $4, 0)
RETURNING id
`, "Money-Fix Campaign", percent, now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&id)
require.NoError(t, err)
return id
}
// failOnChargeClient fails the test if a Square charge is attempted. Proves a
// discount-covered deposit skips the Square call entirely (finding 2).
type failOnChargeClient struct {
square.SquareClient
t *testing.T
}
func (c *failOnChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
c.t.Fatalf("Square CreatePayment must NOT be called for a discount-covered deposit (amount=%d)", req.Amount)
return nil, nil
}
func TestBookingPayment_DepositFullyCoveredByDiscount_SkipsSquareCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// 100% time-based campaign = £50 discount on the £50 fixture booking, which
// fully covers the £25 deposit request (chargeAmount clamps to £0).
seedActiveCampaign(t, ctx, tx, 100)
origClient := SquareClient
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
defer func() { SquareClient = origClient }()
cardToken := "cnon:deposit-covered"
req := CreateBookingPaymentRequest{
Amount: 2500, // £25 deposit
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-covered-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a discount-covered deposit must complete without a Square charge, body: %s", w.Body.String())
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, true, body["deposit_covered_by_discount"], "the response must signal the discount-covered deposit")
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
assert.Zero(t, payCount, "no payment row may be recorded for a discount-covered deposit")
// The discount rows are applied by the next real charge / at completion,
// never minted for a charge that did not happen.
var discountCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
assert.Zero(t, discountCount, "no discount may be applied for a charge that never happened")
}
// =============================================================================
// Finding 7 — remaining-balance capacity ignored PENDING refunds: service.go
// counted only completed refunds in GetBookingRemainingBalancePence while
// GetBookingPaymentInfo counts completed + pending. An in-flight refund
// understated the remaining balance and blocked a legitimate retry.
// =============================================================================
func TestGetBookingRemainingBalancePence_PendingRefundsReopenCapacity(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
var bookingTotal int64
require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
svc := NewPaymentService()
// Pay the full booking amount.
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "full",
PaymentMethod: "online_square",
Status: "completed",
Amount: float64(bookingTotal) / 100.0,
}, nil)
require.NoError(t, err)
remaining, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
// A PENDING refund is money in flight that will come back — it must re-open
// capacity by its amount exactly like a completed refund.
var payRowID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID))
refundAmount := bookingTotal / 2
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
VALUES ($1, $2, $3, 'pending', 'in-flight test refund', 'manual')
`, payRowID, bookingID, float64(refundAmount)/100.0)
require.NoError(t, err)
remaining, err = svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, refundAmount, remaining, "a pending refund must re-open the remaining balance by its amount")
}