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.
153 lines
6.0 KiB
Go
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")
|
|
}
|