- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows - giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund) - sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge - webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync - account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
247 lines
12 KiB
Go
247 lines
12 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
// Round-10 adversarial tests: the synchronous charge path vs the Square
|
|
// payment.completed webhook race. The webhook can win the booking FOR UPDATE
|
|
// lock between the Square call returning and the sync path's post-charge
|
|
// completion flip, resolving the payment row to 'completed' (and running the
|
|
// booking-completion side-effects) first. These tests simulate the webhook
|
|
// landing DURING the sync CreatePayment call and verify the sync path's
|
|
// guarded flips (R10) no-op instead of re-flipping the row, overwriting the
|
|
// ledger, or re-inserting split records. They follow the existing sequential
|
|
// (non-parallel) conventions of the money-path tests.
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// webhookRaceClient wraps the dev Square client to simulate the
|
|
// payment.completed webhook landing DURING the synchronous CreatePayment call
|
|
// — the exact race window the R10 guards close. On a COMPLETED charge it
|
|
// resolves the just-created pending payment row for this booking to
|
|
// 'completed' BEFORE the sync handler's own post-charge completion flip runs.
|
|
// When simulateBookingCompletion is set it also completes the booking and
|
|
// awards the loyalty stamp, mirroring the webhook's booking-completion
|
|
// side-effects.
|
|
type webhookRaceClient struct {
|
|
square.SquareClient
|
|
bookingID string
|
|
userID string
|
|
simulateBookingCompletion bool
|
|
}
|
|
|
|
func (c *webhookRaceClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
res, err := c.SquareClient.CreatePayment(ctx, req)
|
|
if err != nil || res == nil || res.Status != "COMPLETED" {
|
|
return res, err
|
|
}
|
|
// The webhook completes the pending row for this charge (same booking +
|
|
// idempotency key) before the sync handler's flip runs.
|
|
if _, uErr := db.Conn.Exec(ctx, `
|
|
UPDATE payments
|
|
SET status = 'completed', square_payment_id = $1
|
|
WHERE booking_id = $2 AND idempotency_key = $3 AND status = 'pending'
|
|
`, res.SquarePayID, c.bookingID, req.IdempotencyKey); uErr != nil {
|
|
log.Printf("webhookRaceClient: failed to simulate payment.completed webhook: %v", uErr)
|
|
}
|
|
if c.simulateBookingCompletion {
|
|
if _, uErr := db.Conn.Exec(ctx, `
|
|
UPDATE bookings SET status = 'completed', loyalty_stamp_awarded_at = NOW()
|
|
WHERE id = $1
|
|
`, c.bookingID); uErr != nil {
|
|
log.Printf("webhookRaceClient: failed to simulate booking completion: %v", uErr)
|
|
}
|
|
if _, uErr := db.Conn.Exec(ctx, `UPDATE users SET loyalty_stamps = loyalty_stamps + 1 WHERE id = $1`, c.userID); uErr != nil {
|
|
log.Printf("webhookRaceClient: failed to simulate loyalty award: %v", uErr)
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
// TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite locks the
|
|
// R10 fix on the synchronous online booking path: when the Square
|
|
// payment.completed webhook wins the race and completes the pending payment
|
|
// row (plus the booking side-effects) before the sync completion flip, the
|
|
// sync path must NOT re-flip the already-completed row, OVERWRITE the primary
|
|
// row's amount/payment_type/VAT with buildSplitRecords' carved values, or
|
|
// re-insert split tip/balance rows. The response stays 200 (the payment IS
|
|
// completed) and the booking side-effects are not double-run.
|
|
func TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
// VAT-registered so a buggy split/VAT overwrite would visibly clear or
|
|
// recompute the row's VAT fields.
|
|
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
|
require.NoError(t, err)
|
|
|
|
// Past-start booking (total £50, per the round-9 fixture), in_progress.
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &webhookRaceClient{
|
|
SquareClient: square.NewDevClient(),
|
|
bookingID: bookingID,
|
|
userID: userID,
|
|
simulateBookingCompletion: true,
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
// £55 'full' on a £50 past-start booking: buildSplitRecords would carve
|
|
// [£50 booking, £5 tip]. The webhook completing the row FIRST must make
|
|
// the sync flip a no-op — no tip split inserted, primary not overwritten.
|
|
cardToken := "cnon:round10-webhook-first"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5500,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "round10-webhook-first-" + bookingID,
|
|
ConfirmOverflowTip: true, // B12: post-start overflow requires explicit confirmation
|
|
}
|
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved payment must still report success, body: %s", w.Body.String())
|
|
|
|
// Exactly ONE completed payment row — no duplicate split tip/balance rows.
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "the webhook-completed row must NOT be re-split into duplicate tip/balance rows")
|
|
|
|
// The primary row keeps the charged (webhook) values — the sync flip
|
|
// must not overwrite amount/payment_type with the split-carved values.
|
|
var amount float64
|
|
var paymentType string
|
|
var sqPayID string
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT amount, payment_type, COALESCE(square_payment_id, '') FROM payments WHERE booking_id = $1
|
|
`, bookingID).Scan(&amount, &paymentType, &sqPayID))
|
|
require.InDelta(t, 55.0, amount, 0.001, "the primary row must keep the charged amount (webhook's value), not the split-carved £50")
|
|
require.Equal(t, "full", paymentType, "the primary row must keep its original payment_type")
|
|
require.NotEmpty(t, sqPayID, "the webhook-completed row must carry the Square payment id")
|
|
|
|
// Booking side-effects not double-run: the webhook completed the booking
|
|
// and awarded one stamp; the sync path must not re-run completion.
|
|
var bookingStatus string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
|
require.Equal(t, "completed", bookingStatus, "the booking must stay completed — the sync path must not touch it")
|
|
|
|
var stamps int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps))
|
|
require.Equal(t, 1, stamps, "the loyalty stamp must be awarded exactly once (webhook's), not re-awarded by the sync path")
|
|
|
|
var discountCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
|
require.Equal(t, 0, discountCount, "no campaign discount rows may be created by a no-op completion")
|
|
}
|
|
|
|
// TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip locks the R10 fix
|
|
// on the tip path: when the webhook completes the pending tip row before the
|
|
// sync completion flip, the sync path must not re-flip it or double-record the
|
|
// tip — exactly one completed tip row exists and the response is 200.
|
|
func TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
// The tip flow requires an existing completed payment on the booking.
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ($1, 'full', 'online_square', 'completed', 50.00, $2, $3, NOW(), NOW())
|
|
`, bookingID, "round10-tip-primary-"+bookingID, userID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &webhookRaceClient{
|
|
SquareClient: square.NewDevClient(),
|
|
bookingID: bookingID,
|
|
userID: userID,
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: strPtr("cnon:round10-tip-webhook-first"),
|
|
IdempotencyKey: "round10-tip-webhook-first-" + bookingID,
|
|
}
|
|
w := makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved tip must still report success, body: %s", w.Body.String())
|
|
|
|
// Exactly ONE completed tip row — the sync flip must not create a second.
|
|
var tipCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
|
|
`, bookingID).Scan(&tipCount))
|
|
require.Equal(t, 1, tipCount, "the webhook-completed tip row must not be duplicated by the sync path")
|
|
|
|
var tipAmount float64
|
|
var sqPayID string
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT amount, COALESCE(square_payment_id, '') FROM payments
|
|
WHERE booking_id = $1 AND payment_type = 'tip'
|
|
`, bookingID).Scan(&tipAmount, &sqPayID))
|
|
require.InDelta(t, 5.0, tipAmount, 0.001, "the tip row must keep the charged amount")
|
|
require.NotEmpty(t, sqPayID, "the webhook-completed tip row must carry the Square payment id")
|
|
|
|
// Total ledger: the pre-existing full payment + exactly one tip.
|
|
var totalCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&totalCount))
|
|
require.Equal(t, 2, totalCount, "the ledger must hold the full payment + exactly one tip row")
|
|
}
|
|
|
|
// TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed locks the
|
|
// R10 fix in postChargeRecheck (charge_helpers.go): a charge landing on a
|
|
// cancelled booking must not mark 'failed' a payment row the webhook already
|
|
// resolved to 'completed'. The guarded failed-mark UPDATE no-ops on the
|
|
// completed row, the 409 conflict is still returned, and the completed row
|
|
// stays completed so the cancellation refund path (which computes refunds
|
|
// from completed payments) can reverse the money.
|
|
func TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
// The booking was cancelled between the Square call and the recheck.
|
|
_, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID)
|
|
require.NoError(t, err)
|
|
|
|
// The webhook already completed the payment row.
|
|
var paymentID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ($1, 'full', 'online_square', 'completed', 55.00, 'pay_round10_webhook_resolved', $2, $3, NOW(), NOW())
|
|
RETURNING id
|
|
`, bookingID, "round10-clobber-key", userID).Scan(&paymentID)
|
|
require.NoError(t, err)
|
|
|
|
recheckTx, bErr := db.Conn.Begin(ctx)
|
|
require.NoError(t, bErr)
|
|
defer func() { _ = recheckTx.Rollback(ctx) }()
|
|
|
|
w := httptest.NewRecorder()
|
|
payable, pErr := postChargeRecheck(ctx, w, recheckTx, bookingID, paymentID, "COMPLETED", "pay_round10_webhook_resolved", "payment", "This booking is no longer accepting payments")
|
|
require.NoError(t, pErr)
|
|
require.False(t, payable, "a cancelled booking must not accept the completed payment")
|
|
require.Equal(t, http.StatusConflict, w.Code, "the recheck must surface the 409 conflict")
|
|
|
|
// The completed row must survive the recheck — never clobbered to 'failed'.
|
|
var status string
|
|
var sqPayID string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1`, paymentID).Scan(&status, &sqPayID))
|
|
require.Equal(t, "completed", status, "a webhook-completed row must not be marked failed by the cancelled-booking recheck")
|
|
require.Equal(t, "pay_round10_webhook_resolved", sqPayID, "the webhook's square_payment_id must survive untouched")
|
|
|
|
var bookingStatus string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
|
|
require.Equal(t, "client_cancelled", bookingStatus, "the booking must stay cancelled")
|
|
}
|