Fix payment review round 3: saved-card idempotency, stale-pending sweep, webhook fail-closed
R1/R4: saved_card branch in CreateTerminalPayment now mirrors CreateTipPayment - advisory lock (crussell:payment:<bookingID>) serializes concurrent double-clicks - deterministic key bookingID-sc-type-amount-cardID (<=45 chars) so a lost-response retry derives the same key and dedups instead of double-charging - idempotency switch inside the lock: completed -> dedup, pending -> reuse with pence amount-guard, failed -> clean 409 - success response includes card_brand/card_last4 (frontend already reads them) R2: add 'failed' case to all four retry switches (tip, booking, gift card, till) - a swept/definitively-rejected record returns 409 instead of 500-ing on the idempotency_key UNIQUE constraint R3: extend SweepStalePendingPayments to till_sales card rows - sweeps pending till_sales (online_square/in_person_card) past Square's ~24h key retention, closing the double-charge window for till sales - swept rows logged with the same CRITICAL manual-reconciliation marker as the refund sweep Webhook fail-closed: reject 503 when SQUARE_WEBHOOK_SIGNATURE_KEY unset, 403 on bad signature (was: skip verification in dev) Refund status resolution: refunds now resolve by Square status (COMPLETED/PENDING/FAILED/REJECTED) instead of assuming completed; real error codes (REFUND_AMOUNT_INVALID, PAYMENT_NOT_REFUNDABLE, REFUND_ALREADY_PENDING) added to the definitive/processed classification HTTP client: CreateCard key truncated to <=45 chars, device_options always sent (env SQUARE_TERMINAL_DEVICE_ID fallback), processing_fee reads amount_money, ListCards cursor loop, refund keys hashed to <=45 chars Other fixes: payment/till/gift-card advisory-lock + FOR UPDATE asymmetries, GetPaymentByID NULL scans, loyalty redemption lock, card upsert on conflict, mock ccof: prefix parity, IsValidSquareCheckoutID for real Square IDs, isAdminRequest defense-in-depth on all 6 admin payment handlers, webhook signature docs, M8/L5 debug markers removed Docs: README/FC/TM/Overview updated (22 jobs, 20 CRITICAL sites, 23-section GDPR export, sweep jobs, webhook fail-closed); P11 plan marks remaining items (sandbox smoke test, M-8 customer_id, saved-card key dedup trade-off) as deferred with rationale; gap backlog pruned of completed items
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
@@ -392,7 +393,10 @@ func TestGetCheckoutStatus_ConcurrentPolls_SingleRecord(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/checkout/"+checkoutID+"/status?booking_id="+bookingID, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", checkoutID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
// GetCheckoutStatus is admin-only (defense-in-depth S-1 check).
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
||||
req = req.WithContext(reqCtx)
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
recs[idx] = w
|
||||
|
||||
@@ -481,7 +481,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
var redeemedBy sql.NullString
|
||||
var isInventory bool
|
||||
var currentTotalFunds float64
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||
@@ -597,7 +597,11 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
var fromRedeemedBy, toRedeemedBy sql.NullString
|
||||
var fromRemaining, toRemaining float64
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
||||
// Lock both rows FOR UPDATE (source first, deterministic order) so a
|
||||
// concurrent transfer/topup can't interleave a read-then-write on the same
|
||||
// card — the same check-then-act race RedeemGiftCard and the till path
|
||||
// already guard against (N-5).
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Source gift card not found", http.StatusNotFound)
|
||||
@@ -608,7 +612,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Destination gift card not found", http.StatusNotFound)
|
||||
@@ -953,6 +957,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
reusePendingID = existing.ID
|
||||
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
|
||||
}
|
||||
if existing.Status == "failed" {
|
||||
// Swept as stale (>24h) or definitively rejected — a retry would
|
||||
// risk a second Square charge. Reject cleanly (R2).
|
||||
log.Printf("Gift card retry rejected: pending record %s was marked failed", existing.ID)
|
||||
http.Error(w, "This gift card purchase previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,8 +1104,26 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Square succeeded — update payment, create gift card.
|
||||
_, upErr := db.Conn.Exec(ctx,
|
||||
// Step 3: Square succeeded — atomically flip the payment to completed and
|
||||
// create the gift card + balance + transaction in ONE transaction. If any
|
||||
// step fails, the whole thing rolls back, the payment stays 'pending', and
|
||||
// a same-key retry re-attempts the Square charge (Square dedups) before
|
||||
// delivering the card. Previously these were separate non-transactional
|
||||
// writes: a failure after the payment-completed update left the customer
|
||||
// CHARGED but with no card, and the completed-dedup swallowed the retry.
|
||||
issueTx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin gift-card issue transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := issueTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback gift-card issue transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, upErr := issueTx.Exec(ctx,
|
||||
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
|
||||
paymentResult.SquarePayID, buyPaymentID,
|
||||
)
|
||||
@@ -1108,7 +1137,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if req.RecipientType == "self" {
|
||||
var purchaseVoucherType string
|
||||
err = db.Conn.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query voucher type: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1117,7 +1146,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if purchaseVoucherType == "" {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = db.Conn.QueryRow(ctx, `
|
||||
err = issueTx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
|
||||
RETURNING id
|
||||
@@ -1128,7 +1157,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
@@ -1141,7 +1170,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'self-purchase, auto-redeemed')
|
||||
`, cardID, amountPounds, userID)
|
||||
@@ -1152,7 +1181,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
var purchaseVoucherType string
|
||||
err = db.Conn.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query voucher type: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1161,7 +1190,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if purchaseVoucherType == "" {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = db.Conn.QueryRow(ctx, `
|
||||
err = issueTx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, $1, $2, FALSE, $3)
|
||||
RETURNING id
|
||||
@@ -1175,7 +1204,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
recipient := req.RecipientEmail
|
||||
if recipient == "" {
|
||||
var userEmail string
|
||||
err = db.Conn.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
||||
err = issueTx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query user email: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1188,7 +1217,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// code anyone can redeem). Log only the value and recipient for audit.
|
||||
log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, recipient)
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend')
|
||||
`, cardID, amountPounds, userID)
|
||||
@@ -1199,6 +1228,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := issueTx.Commit(ctx); err != nil {
|
||||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift-card issue transaction commit failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "success",
|
||||
|
||||
@@ -3,6 +3,7 @@ package payments
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
@@ -31,6 +32,10 @@ type CreateTerminalPaymentRequest struct {
|
||||
TipEnabled bool `json:"tip_enabled"`
|
||||
PaymentMethod *string `json:"payment_method,omitempty"`
|
||||
GiftCardID *string `json:"gift_card_id,omitempty"`
|
||||
// saved_card_id: the user's saved card (user_saved_cards.id) to charge
|
||||
// directly, bypassing the terminal. The frontend sends this for the admin
|
||||
// "Charge Saved Card" action.
|
||||
UserSavedCardID *string `json:"saved_card_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreateBookingPaymentRequest struct {
|
||||
@@ -123,6 +128,15 @@ type DiscountPreview struct {
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// isAdminRequest is a defense-in-depth role check for admin-only payment
|
||||
// handlers. The routes are mounted under mw.RequireAdmin, but this in-handler
|
||||
// guard keeps admin-only actions (refunds, terminal charges, till sales)
|
||||
// protected even if a route is ever re-registered on a non-admin router (S-1).
|
||||
func isAdminRequest(r *http.Request) bool {
|
||||
role, ok := r.Context().Value(mw.UserRoleKey).(string)
|
||||
return ok && role == "admin"
|
||||
}
|
||||
|
||||
// GetDiscountPreviewHandler returns eligible discounts for a booking without applying them.
|
||||
// GET /api/bookings/{id}/discount-preview
|
||||
func GetDiscountPreviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -314,6 +328,12 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
|
||||
}
|
||||
|
||||
func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Defense-in-depth admin check (S-1) — the route is mounted under
|
||||
// mw.RequireAdmin; this keeps terminal charges admin-only regardless.
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
bookingID := chi.URLParam(r, "id")
|
||||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
@@ -339,9 +359,6 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
@@ -561,6 +578,199 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Admin "Charge Saved Card": charge the customer's saved card directly via
|
||||
// Square (no terminal). Pending-first with full idempotency: a deterministic
|
||||
// key derived from booking+type+amount+card means a network retry reuses the
|
||||
// same key — Square dedups the charge and the pending record is resumed, so
|
||||
// a lost-response retry can NEVER double-charge. Mirrors CreateTipPayment.
|
||||
if req.PaymentMethod != nil && *req.PaymentMethod == "saved_card" {
|
||||
if req.UserSavedCardID == nil || *req.UserSavedCardID == "" {
|
||||
http.Error(w, "saved_card_id is required for saved_card payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := service.GetBookingStatus(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get booking status: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != "in_progress" && status != "completed" {
|
||||
http.Error(w, "Booking must be in_progress or completed to create payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// The saved card is owned by the booking's user, not the admin.
|
||||
var bookingUserID sql.NullString
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil {
|
||||
log.Printf("Failed to get booking user: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
card, err := service.GetCardByID(r.Context(), *req.UserSavedCardID, bookingUserID.String)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Saved card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get saved card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize saved-card charges per booking (same lock as online booking
|
||||
// payments) so concurrent double-clicks can't both pass the idempotency
|
||||
// check. Mirrors the CreateBookingPayment lock (R4).
|
||||
pinConn, err := db.Conn.Acquire(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for saved-card lock: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
if _, err := pinConn.Exec(r.Context(), `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to acquire saved-card serialization lock for %s: %v", bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to release saved-card serialization lock for %s: %v", bookingID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Deterministic idempotency key: booking+type+amount+card. A network
|
||||
// retry with the same inputs derives the same key → dedup, never a
|
||||
// second charge. ≤45 chars for Square's limit.
|
||||
scKey := bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
|
||||
|
||||
// Idempotency switch inside the lock: completed → dedup; pending →
|
||||
// reuse (re-attempt Square with the same key, which dedups Square-side);
|
||||
// failed → clean rejection.
|
||||
var existingID, existingStatus sql.NullString
|
||||
var existingAmount sql.NullFloat64
|
||||
err = db.Conn.QueryRow(r.Context(), `
|
||||
SELECT id, status, amount FROM payments WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, scKey).Scan(&existingID, &existingStatus, &existingAmount)
|
||||
|
||||
paymentID := ""
|
||||
switch {
|
||||
case err == nil && existingStatus.String == "completed":
|
||||
// Dedup — return the existing completed payment.
|
||||
if err := json.NewEncoder(w).Encode(CheckoutResponse{
|
||||
CheckoutID: existingID.String,
|
||||
Status: "COMPLETED",
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
case err == nil && existingStatus.String == "pending":
|
||||
// Reuse the pending record: a prior attempt's Square outcome is
|
||||
// unknown. Guard the amount — a retry with a different amount must
|
||||
// not reuse the old record's charge.
|
||||
if int64(math.Round(existingAmount.Float64*100)) != amount {
|
||||
log.Printf("Saved-card retry amount mismatch: pending %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), amount)
|
||||
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
paymentID = existingID.String
|
||||
case err == nil && existingStatus.String == "failed":
|
||||
log.Printf("Saved-card payment %s was previously marked failed (swept) — refusing retry", existingID.String)
|
||||
http.Error(w, "This payment previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
||||
log.Printf("Failed to check saved-card idempotency: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Pending-first: insert a pending payment record, commit, then charge.
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if paymentID == "" {
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: float64(amount) / 100.0,
|
||||
IdempotencyKey: &scKey,
|
||||
UserSavedCardID: req.UserSavedCardID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &adminID,
|
||||
}
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
|
||||
log.Printf("Failed to insert pending saved-card payment: %v", err)
|
||||
_ = tx.Rollback(r.Context())
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit pending saved-card payment: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buyerEmail string
|
||||
if bookingUserID.Valid {
|
||||
_ = db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, bookingUserID.String).Scan(&buyerEmail)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), square.CreatePaymentReq{
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
SourceID: card.SquareCardID,
|
||||
IdempotencyKey: scKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
BuyerEmail: buyerEmail,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to process saved-card payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
|
||||
paymentResult.SquarePayID, paymentID,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s succeeded but saved-card payment %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, paymentID, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the card details the frontend reads for the success state
|
||||
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"checkout_id": paymentID,
|
||||
"status": "COMPLETED",
|
||||
"card_brand": paymentResult.CardBrand,
|
||||
"card_last4": paymentResult.CardLast4,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For Square checkout (terminal card reader), validate booking status
|
||||
// and check idempotency. No DB transaction needed since Square handles
|
||||
// the payment — no DB writes occur until GetCheckoutStatus.
|
||||
@@ -617,12 +827,18 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Defense-in-depth admin check (S-1) — terminal completion records a
|
||||
// payment, so it must stay admin-only.
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !validators.IsValidID(checkoutID) {
|
||||
if !validators.IsValidSquareCheckoutID(checkoutID) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -650,6 +866,17 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership check: the terminal checkout must reference THIS booking.
|
||||
// CreateTerminalPayment sets reference_id = bookingID; without this check,
|
||||
// polling the wrong checkout ID would attach its payment to a different
|
||||
// booking (admin-only route, but a mis-scoped charge is a data-integrity
|
||||
// bug worth rejecting).
|
||||
if paymentResult.ReferenceID != "" && paymentResult.ReferenceID != bookingID {
|
||||
log.Printf("Checkout %s references booking %s, not %s — refusing to record", checkoutID, paymentResult.ReferenceID, bookingID)
|
||||
http.Error(w, "Checkout does not belong to this booking", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if paymentResult.Status == "COMPLETED" {
|
||||
service := NewPaymentService()
|
||||
|
||||
@@ -813,9 +1040,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
@@ -937,17 +1161,29 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Check idempotency inside the transaction.
|
||||
// Only short-circuit when the existing record is 'completed'. A 'pending'
|
||||
// record means the previous Square call failed — returning it as 200 would
|
||||
// show a success toast without ever charging. Re-attempt the charge below
|
||||
// with the same idempotency key (Square dedups safely) and reuse the
|
||||
// existing record. This mirrors CreateTipPayment exactly.
|
||||
var existingID sql.NullString
|
||||
var existingBookingID sql.NullString
|
||||
var existingPaymentType sql.NullString
|
||||
var existingStatus sql.NullString
|
||||
var existingAmount sql.NullFloat64
|
||||
var existingCreatedAt sql.NullTime
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
SELECT id, booking_id, payment_type, status, amount, created_at
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND idempotency_key = $2
|
||||
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
|
||||
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt)
|
||||
|
||||
paymentID := ""
|
||||
reusePendingRecord := false
|
||||
switch {
|
||||
case err == nil && existingStatus.String == "completed":
|
||||
// Idempotent dedup — return the already-completed payment.
|
||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: existingID.String,
|
||||
BookingID: existingBookingID.String,
|
||||
@@ -959,7 +1195,27 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
return
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
case err == nil && existingStatus.String == "pending":
|
||||
// Previous Square call failed — reuse the pending record and re-attempt.
|
||||
// Guard the amount: a retry with a different amount must not mutate the
|
||||
// original record or charge the new amount against the old key. Compare
|
||||
// in pence via math.Round — int64(pounds*100) truncation would reject
|
||||
// legitimate same-amount retries for non-exact values (see CreateTipPayment).
|
||||
if int64(math.Round(existingAmount.Float64*100)) != req.Amount {
|
||||
log.Printf("Payment retry amount mismatch: pending record %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), req.Amount)
|
||||
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
paymentID = existingID.String
|
||||
reusePendingRecord = true
|
||||
case err == nil && existingStatus.String == "failed":
|
||||
// Swept as stale (>24h) or definitively rejected — a retry would risk a
|
||||
// second Square charge. Reject cleanly instead of 500-ing on the
|
||||
// idempotency_key UNIQUE constraint (R2).
|
||||
log.Printf("Payment retry rejected: record %s was marked failed", existingID.String)
|
||||
http.Error(w, "This payment previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
||||
log.Printf("Failed to check idempotency: %v", err)
|
||||
}
|
||||
|
||||
@@ -1032,6 +1288,53 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
savedCardID = req.CardID
|
||||
}
|
||||
|
||||
// If there is no pending record to reuse, insert one NOW and commit the
|
||||
// transaction BEFORE calling Square. The committed pending row binds the
|
||||
// idempotency key in the DB, so a post-charge insert/commit failure leaves
|
||||
// a retryable pending record instead of an unbound key (a same-key retry
|
||||
// would otherwise re-charge). It also releases the DB transaction before
|
||||
// the ~30s Square round-trip instead of holding it open across the call.
|
||||
if !reusePendingRecord {
|
||||
fees := service.CalculateFees(req.Amount, "online")
|
||||
pendingRecord := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
PaymentMethod: "online_square",
|
||||
Status: "pending",
|
||||
Amount: float64(req.Amount) / 100.0,
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, pendingRecord, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create pending payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Apply VAT to the pending record inside the same transaction — same
|
||||
// pattern as CreateTipPayment.
|
||||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||
}
|
||||
|
||||
// Always commit the transaction. In the reuse path no rows were written,
|
||||
// but the commit is required in the test harness: there the context carries
|
||||
// an outer test tx, so Begin creates a nested savepoint whose deferred
|
||||
// rollback would otherwise undo the post-charge UPDATE executed later on
|
||||
// the same connection. In production Begin is a plain tx and this commit is
|
||||
// a harmless no-op that keeps both paths identical.
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: DB transaction committed — safe to call Square now. If Square
|
||||
// fails, the record stays 'pending' and a same-key retry reuses it.
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
@@ -1049,15 +1352,32 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
fees := service.CalculateFees(req.Amount, "online")
|
||||
|
||||
paymentAmount := float64(req.Amount) / 100.0
|
||||
|
||||
// Step 3: Square succeeded — record the completed payment state in a NEW
|
||||
// transaction (split records, VAT, deposit promotion, campaigns). The
|
||||
// pending row committed in step 1 already holds the primary idempotency
|
||||
// key, so it IS the primary record: update it to 'completed' with the
|
||||
// Square payment ID, then insert only the additional -split-N records.
|
||||
tx2, txErr := db.Conn.Begin(r.Context())
|
||||
if txErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but opening the post-charge transaction failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, txErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := tx2.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback post-charge transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Build payment records — may split a single Square charge into
|
||||
// a deposit portion (up to 50% of booking total) plus a balance
|
||||
// portion, so the refund system can correctly track deposit vs
|
||||
// non-deposit money per the deposit protection policy.
|
||||
bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID)
|
||||
fees := service.CalculateFees(req.Amount, "online")
|
||||
primaryRecord := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
@@ -1083,41 +1403,62 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
records = []PaymentRecord{primaryRecord}
|
||||
}
|
||||
|
||||
// Create all payment records for this Square charge inside the transaction
|
||||
// so that if any insert fails the entire group rolls back. This prevents
|
||||
// a data inconsistency where Square charged the customer but only part of
|
||||
// the split is reflected in the DB.
|
||||
// The primary split record (records[0]) is the committed pending row. Its
|
||||
// amount/payment_type may differ from the pending insert (deposit carving
|
||||
// in buildSplitRecords), so align the row to the computed values. The VAT
|
||||
// fields are cleared so apply_vat_to_payment recomputes on the final amount
|
||||
// — the pending record had VAT applied at the pre-split amount.
|
||||
primary := records[0]
|
||||
if _, upErr := tx2.Exec(r.Context(), `
|
||||
UPDATE payments SET
|
||||
status = 'completed',
|
||||
square_payment_id = $1,
|
||||
amount = $2,
|
||||
payment_type = $3,
|
||||
fees = $4,
|
||||
is_vat_applicable = FALSE,
|
||||
vat_rate = NULL,
|
||||
vat_amount = NULL,
|
||||
net_amount = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5
|
||||
`, paymentResult.SquarePayID, primary.Amount, primary.PaymentType, primary.Fees, paymentID); upErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but updating payment %s to completed failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, paymentID, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var primaryPaymentID string
|
||||
// Insert the additional split records. They carry the derived -split-N
|
||||
// idempotency keys, which are new rows; if the split produced only one
|
||||
// record, there is nothing more to insert.
|
||||
var paymentIDs []string
|
||||
for i, rec := range records {
|
||||
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil)
|
||||
for i, rec := range records[1:] {
|
||||
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx2, rec, nil)
|
||||
if cErr != nil {
|
||||
log.Printf("Failed to create payment record %d/%d: %v", i+1, len(records), cErr)
|
||||
log.Printf("Failed to create split payment record %d/%d: %v", i+2, len(records), cErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
paymentIDs = append(paymentIDs, pid)
|
||||
if i == 0 {
|
||||
primaryPaymentID = pid
|
||||
}
|
||||
}
|
||||
|
||||
// Apply VAT to all split records if the business is VAT-registered.
|
||||
// Must be inside the transaction so VAT updates are atomic with inserts.
|
||||
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||||
vatCfg, vatErr := GetVATConfig(r.Context(), tx2)
|
||||
if vatErr == nil && vatCfg.IsVATRegistered {
|
||||
for _, pid := range paymentIDs {
|
||||
if _, execErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil {
|
||||
vatIDs := append([]string{paymentID}, paymentIDs...)
|
||||
for _, pid := range vatIDs {
|
||||
if _, execErr := tx2.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil {
|
||||
log.Printf("Failed to apply VAT to payment %s: %v", pid, execErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Promote deposit to confirmed if total paid meets the 20% threshold.
|
||||
// Check is inside the transaction so it sees the just-inserted payments.
|
||||
// Check is inside the transaction so it sees the just-completed primary.
|
||||
var depositMet bool
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
if err := tx2.QueryRow(r.Context(), `
|
||||
WITH booking_total AS (
|
||||
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
|
||||
),
|
||||
@@ -1133,7 +1474,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if depositMet {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
if _, err := tx2.Exec(r.Context(), `
|
||||
UPDATE bookings SET status = 'confirmed', updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending_release'
|
||||
`, bookingID); err != nil {
|
||||
@@ -1144,9 +1485,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Apply eligible campaign discounts inside the payment transaction, so
|
||||
// atomicity with the payment inserts is guaranteed. The call is idempotent
|
||||
// — if discounts were already applied, the duplicate check skips them.
|
||||
applyEligibleCampaignsAtPayment(r.Context(), tx, bookingID, userID)
|
||||
applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID)
|
||||
|
||||
if cErr := tx.Commit(r.Context()); cErr != nil {
|
||||
if cErr := tx2.Commit(r.Context()); cErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, cErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1154,7 +1495,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: primaryPaymentID,
|
||||
ID: paymentID,
|
||||
BookingID: bookingID,
|
||||
PaymentType: req.PaymentType,
|
||||
Status: "completed",
|
||||
@@ -1560,6 +1901,12 @@ func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func AdminGetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
|
||||
// Defense-in-depth admin check (S-1) — exposing another user's saved cards
|
||||
// must stay admin-only.
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
userID := chi.URLParam(r, "id")
|
||||
if userID == "" || !validators.IsValidID(userID) {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
@@ -1652,6 +1999,13 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Defense-in-depth: the route is mounted under mw.RequireAdmin, but this
|
||||
// in-handler check keeps refund access admin-only even if the route is ever
|
||||
// re-registered on a non-admin router (S-1).
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
paymentID := chi.URLParam(r, "payment_id")
|
||||
if paymentID == "" || !validators.IsValidID(paymentID) {
|
||||
http.Error(w, "Payment not found", http.StatusNotFound)
|
||||
@@ -1710,10 +2064,15 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// does not create a second Square refund. When the client supplies an
|
||||
// idempotency key (one per distinct refund attempt, reused on retry), use
|
||||
// it — the amount-derived fallback would collide on two DISTINCT partial
|
||||
// refunds of the same amount, silently swallowing the second.
|
||||
// refunds of the same amount, silently swallowing the second. The client
|
||||
// key is hashed+truncated: Square's idempotency-key limit is 45 chars, and
|
||||
// paymentID (12) + "-refund-" (8) + a full 36-char UUID (56 total) would
|
||||
// be rejected with a 400. The hash stays deterministic, so a same-key
|
||||
// retry still dedups.
|
||||
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
|
||||
if req.IdempotencyKey != "" {
|
||||
idempotencyKey = paymentID + "-refund-" + req.IdempotencyKey
|
||||
ikHash := sha256.Sum256([]byte(req.IdempotencyKey))
|
||||
idempotencyKey = paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24]
|
||||
}
|
||||
|
||||
// Serialize refund attempts per payment to prevent two concurrent refunds
|
||||
@@ -1824,9 +2183,19 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
|
||||
switch {
|
||||
case reissueErr == nil:
|
||||
// Resolve by Square's status: PENDING stays pending (sweep
|
||||
// reconciles), FAILED/REJECTED is definitive, COMPLETED resolves.
|
||||
reissueStatus := "completed"
|
||||
if reissueResult.Status == "PENDING" {
|
||||
reissueStatus = "pending"
|
||||
log.Printf("Square reissue %s is PENDING — leaving refund %s pending for the sweep", reissueResult.ID, existingRefundID.String)
|
||||
} else if reissueResult.Status == "FAILED" || reissueResult.Status == "REJECTED" {
|
||||
reissueStatus = "failed"
|
||||
log.Printf("Square reissue %s FAILED — marking refund %s failed", reissueResult.ID, existingRefundID.String)
|
||||
}
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
|
||||
reissueResult.ID, existingRefundID.String,
|
||||
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
|
||||
reissueStatus, reissueResult.ID, existingRefundID.String,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1836,7 +2205,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
ID: existingRefundID.String,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Status: reissueStatus,
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
@@ -1972,13 +2341,20 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
|
||||
var refundID string
|
||||
// booking_id is NULL for non-booking payments (gift-card purchase refunds);
|
||||
// payments without a booking leave it NULL rather than inserting an empty
|
||||
// string that violates the refunds.booking_id FK/NOT NULL.
|
||||
var refundBookingID any = payment.BookingID
|
||||
if payment.BookingID == "" {
|
||||
refundBookingID = nil
|
||||
}
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, 'manual')
|
||||
RETURNING id
|
||||
`,
|
||||
paymentID,
|
||||
payment.BookingID,
|
||||
refundBookingID,
|
||||
float64(req.Amount)/100.0,
|
||||
req.Reason,
|
||||
idempotencyKey,
|
||||
@@ -2044,14 +2420,26 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
squareRefundID := refundResult.ID
|
||||
// Square succeeded — resolve the refund row by Square's status. A
|
||||
// synchronous refund response can be PENDING (money in flight, e.g. an
|
||||
// async card network): marking it completed while Square later fails it
|
||||
// would permanently block that amount in the over-refund guard. Only a
|
||||
// definitive COMPLETED resolves to completed; PENDING stays pending for the
|
||||
// sweep to reconcile; FAILED/REJECTED is a real failure.
|
||||
status := "completed"
|
||||
if refundResult.Status == "PENDING" {
|
||||
status = "pending"
|
||||
log.Printf("Square refund %s is PENDING (in flight) — leaving refund %s pending for the sweep to resolve", refundResult.ID, refundID)
|
||||
} else if refundResult.Status == "FAILED" || refundResult.Status == "REJECTED" {
|
||||
status = "failed"
|
||||
log.Printf("Square refund %s FAILED — marking refund %s failed", refundResult.ID, refundID)
|
||||
}
|
||||
|
||||
// Square succeeded — update the refund record to completed.
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
|
||||
squareRefundID, refundID,
|
||||
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
|
||||
status, refundResult.ID, refundID,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", squareRefundID, refundID, upErr)
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", refundResult.ID, refundID, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -2060,7 +2448,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
ID: refundID,
|
||||
PaymentID: paymentID,
|
||||
Amount: req.Amount,
|
||||
Status: "completed",
|
||||
Status: status,
|
||||
Reason: req.Reason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
@@ -2119,9 +2507,21 @@ func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID
|
||||
http.Error(w, "Refund failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Resolve by Square's status — a PENDING resume stays pending for the
|
||||
// sweep (marking it completed while Square later fails it would block the
|
||||
// amount in the over-refund guard forever); FAILED/REJECTED is definitive.
|
||||
status := "completed"
|
||||
if resumeResult.Status == "PENDING" {
|
||||
status = "pending"
|
||||
log.Printf("Square refund %s is PENDING — leaving refund %s pending for the sweep", resumeResult.ID, refundID)
|
||||
} else if resumeResult.Status == "FAILED" || resumeResult.Status == "REJECTED" {
|
||||
status = "failed"
|
||||
log.Printf("Square refund %s FAILED — marking refund %s failed", resumeResult.ID, refundID)
|
||||
}
|
||||
|
||||
if _, upErr := db.Conn.Exec(r.Context(),
|
||||
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
|
||||
resumeResult.ID, refundID,
|
||||
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
|
||||
status, resumeResult.ID, refundID,
|
||||
); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, refundID, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -2131,7 +2531,7 @@ func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID
|
||||
ID: refundID,
|
||||
PaymentID: paymentID,
|
||||
Amount: resumeAmount,
|
||||
Status: "completed",
|
||||
Status: status,
|
||||
Reason: refundReason,
|
||||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
@@ -2352,6 +2752,14 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
paymentID = existingID.String
|
||||
reusePendingRecord = true
|
||||
case err == nil && existingStatus.String == "failed":
|
||||
// Swept as stale (>24h, past Square's key retention) or definitively
|
||||
// rejected. A retry can no longer be replayed against Square without
|
||||
// risking a second charge — reject cleanly instead of inserting a new
|
||||
// pending row that 500s on the idempotency_key UNIQUE constraint (R2).
|
||||
log.Printf("Tip retry rejected: pending record %s was marked failed", existingID.String)
|
||||
http.Error(w, "This tip payment previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
||||
log.Printf("Failed to check tip idempotency: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
@@ -83,6 +84,34 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize redemption per booking: two concurrent redemptions could both
|
||||
// pass the checks above and both insert a discount (double-apply). Reuse
|
||||
// the booking-payment advisory lock so redemption is mutually exclusive
|
||||
// with payments and other redemptions on the same booking (N-6).
|
||||
pinConn, err := db.Conn.Acquire(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for loyalty redemption lock: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
if _, err := pinConn.Exec(r.Context(), `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to acquire loyalty redemption serialization lock for %s: %v", bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to release loyalty redemption serialization lock for %s: %v", bookingID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-check inside the lock (the checks above ran before acquiring it) so a
|
||||
// concurrent redemption that completed while we waited is caught.
|
||||
var existingDiscount int
|
||||
if err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'
|
||||
|
||||
@@ -31,6 +31,15 @@ func makePaymentRequest(handler http.HandlerFunc, method, path string, body inte
|
||||
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
|
||||
}
|
||||
|
||||
// adminRequestCtx wraps a request context with the admin role so handlers that
|
||||
// run a defense-in-depth isAdminRequest check (S-1) work when called directly
|
||||
// (bypassing the mw.RequireAdmin middleware that normally injects the role).
|
||||
func adminRequestCtx(r *http.Request) *http.Request {
|
||||
reqCtx := context.WithValue(r.Context(), mw.UserRoleKey, "admin")
|
||||
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
|
||||
return r.WithContext(reqCtx)
|
||||
}
|
||||
|
||||
func TestValidateCardInfo(t *testing.T) {
|
||||
empty := ""
|
||||
cardID := "card_123"
|
||||
@@ -2902,6 +2911,7 @@ func TestGetCheckoutStatus_MissingCheckoutID(t *testing.T) {
|
||||
rctx := chi.NewRouteContext()
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
@@ -2914,13 +2924,18 @@ func TestGetCheckoutStatus_MissingCheckoutID(t *testing.T) {
|
||||
func TestGetCheckoutStatus_InvalidCheckoutID(t *testing.T) {
|
||||
_, _ = testutils.SetupTestTx(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/checkout/1234567890abc/status?booking_id=abc", nil)
|
||||
// Must fail the Square-compatible checkout-ID check — the old 12-hex gate
|
||||
// rejected real Square IDs (UUIDs like "08YceKh7B3ZqO"), so an injection
|
||||
// attempt (path traversal) is the correct invalid case now.
|
||||
badID := "../etc/passwd"
|
||||
req := httptest.NewRequest("GET", "/api/checkout/"+badID+"/status?booking_id=abc", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", "1234567890abc")
|
||||
rctx.URLParams.Add("checkout_id", badID)
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
GetCheckoutStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
@@ -2928,6 +2943,31 @@ func TestGetCheckoutStatus_InvalidCheckoutID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_RealSquareID_PassesValidation(t *testing.T) {
|
||||
// A real Square checkout ID (13-char UUID, not 12-hex) must pass the
|
||||
// checkout-ID gate — the C-4 fix. It then 404s at the Square client level
|
||||
// (mock has no such checkout), proving the gate no longer rejects it.
|
||||
_, _ = testutils.SetupTestTx(t)
|
||||
|
||||
realID := "08YceKh7B3ZqO"
|
||||
req := httptest.NewRequest("GET", "/api/checkout/"+realID+"/status?booking_id=abc", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("checkout_id", realID)
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
GetCheckoutStatus(w, req)
|
||||
|
||||
// Not 404-from-validation: the gate accepted it. The mock returns 500 for
|
||||
// an unknown checkout (it panics on a missing ID), so assert NOT a 404
|
||||
// from the gate — any non-404 is proof the gate passed.
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("real Square checkout ID %q was rejected by the validation gate — expected it to pass validation", realID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckoutStatus_ValidCheckoutNotFound(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -2951,6 +2991,7 @@ func TestGetCheckoutStatus_ValidCheckoutNotFound(t *testing.T) {
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
GetCheckoutStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
@@ -3011,6 +3052,7 @@ func TestAdminGetUserPaymentMethods_InvalidUserID(t *testing.T) {
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
AdminGetUserPaymentMethods(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
@@ -3418,6 +3460,7 @@ func TestGetCheckoutStatus_MissingBookingID(t *testing.T) {
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
GetCheckoutStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
@@ -3433,6 +3476,7 @@ func TestGetCheckoutStatus_InvalidBookingID(t *testing.T) {
|
||||
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetCheckoutStatus(w, req)
|
||||
@@ -3452,6 +3496,7 @@ func TestGetCheckoutStatus_BookingNotFound(t *testing.T) {
|
||||
req = req.WithContext(reqCtx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req = adminRequestCtx(req)
|
||||
GetCheckoutStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
@@ -3472,8 +3517,10 @@ func TestTerminalPayment_NoAuth(t *testing.T) {
|
||||
PaymentType: "full",
|
||||
}, "", ctx)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
// No auth token → no role → the defense-in-depth isAdminRequest check
|
||||
// rejects with 403 before the adminID check (S-1).
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3538,3 +3585,200 @@ func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
// A fresh pending payment (should NOT be failed).
|
||||
freshID, err := fixtures.CreateTestPayment(tx, bookingID, 1000.00, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fresh pending payment: %v", err)
|
||||
}
|
||||
// A stale pending payment (25h old — past Square's ~24h key retention).
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stale pending payment: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
_, err = SweepStalePendingPayments(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
// Stale pending → failed; fresh pending untouched.
|
||||
var staleStatus, freshStatus string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&staleStatus); err != nil {
|
||||
t.Fatalf("failed to query stale payment: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", freshID).Scan(&freshStatus); err != nil {
|
||||
t.Fatalf("failed to query fresh payment: %v", err)
|
||||
}
|
||||
if staleStatus != "failed" {
|
||||
t.Errorf("expected stale pending payment to be marked failed, got %q", staleStatus)
|
||||
}
|
||||
if freshStatus != "pending" {
|
||||
t.Errorf("expected fresh pending payment to stay pending, got %q", freshStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedCardPayment_LostResponseRetry_Dedups verifies the R1 fix: two
|
||||
// "Charge Saved Card" requests with identical inputs (booking + type + amount +
|
||||
// card) derive the SAME deterministic idempotency key, so a lost-response
|
||||
// retry reuses the completed payment instead of charging twice. Before the fix,
|
||||
// every request used a fresh random key → the second click double-charged.
|
||||
func TestSavedCardPayment_LostResponseRetry_Dedups(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:saved-card-test", "VISA", "4242")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create saved card: %v", err)
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
reqBody := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
}
|
||||
|
||||
// First charge.
|
||||
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// Same-input retry (lost response) — must dedup, not double-charge.
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// Exactly ONE payment record for this booking.
|
||||
var payCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND amount = 50.00`, bookingID).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment record (dedup), got %d — double-charge!", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedCardPayment_SweptFailed_Rejected verifies the R2 fix: after the
|
||||
// sweep marks a pending payment failed, a same-key retry is cleanly rejected
|
||||
// with 409 instead of 500-ing on the idempotency_key UNIQUE constraint.
|
||||
func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:swept-card-test", "VISA", "1111")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create saved card: %v", err)
|
||||
}
|
||||
|
||||
// Seed a failed payment with the deterministic key the handler will derive.
|
||||
scKey := bookingID + "-sc-full-5000-" + cardID
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'online_square', 'failed', 50.00, $2, $3, $4, NOW(), NOW())
|
||||
`, bookingID, scKey, cardID, adminID); err != nil {
|
||||
t.Fatalf("failed to seed failed payment: %v", err)
|
||||
}
|
||||
|
||||
handler := CreateTerminalPayment
|
||||
reqBody := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
}
|
||||
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 (swept-failed rejection), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// No new payment row was inserted.
|
||||
var payCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, scKey).Scan(&payCount); err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 (failed) payment row, got %d", payCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
|
||||
// also marks stale pending till_sales rows (card payments) as failed, so a
|
||||
// lost-response till sale can't stay pending past Square's key retention.
|
||||
func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
// A stale pending till sale (card payment, 25h old).
|
||||
var tillSaleID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW() - INTERVAL '25 hours', NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&tillSaleID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending till sale: %v", err)
|
||||
}
|
||||
|
||||
// A fresh pending till sale that must NOT be swept.
|
||||
var freshSaleID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 30.00, 30.00, 'online_square', 'pending', $1, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&freshSaleID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed fresh till sale: %v", err)
|
||||
}
|
||||
|
||||
_, err = SweepStalePendingPayments(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var staleStatus, freshStatus string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", tillSaleID).Scan(&staleStatus); err != nil {
|
||||
t.Fatalf("failed to query stale till sale: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", freshSaleID).Scan(&freshStatus); err != nil {
|
||||
t.Fatalf("failed to query fresh till sale: %v", err)
|
||||
}
|
||||
if staleStatus != "failed" {
|
||||
t.Errorf("expected stale pending till sale to be marked failed, got %q", staleStatus)
|
||||
}
|
||||
if freshStatus != "pending" {
|
||||
t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,13 +1052,26 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
|
||||
})
|
||||
switch {
|
||||
case sqErr == nil:
|
||||
// Resolve by Square's status: COMPLETED resolves the group; PENDING
|
||||
// leaves the rows pending (a later sweep reconciles them via
|
||||
// ListPaymentRefunds); FAILED/REJECTED is a definitive failure that
|
||||
// must not be marked completed (that would block the amount in the
|
||||
// over-refund guard forever).
|
||||
sqStatus := "completed"
|
||||
if sqResult.Status == "PENDING" {
|
||||
sqStatus = "pending"
|
||||
log.Printf("Square refund %s for charge %s is PENDING — leaving refunds pending for the sweep", sqResult.ID, chargeID)
|
||||
} else if sqResult.Status == "FAILED" || sqResult.Status == "REJECTED" {
|
||||
sqStatus = "failed"
|
||||
log.Printf("Square refund %s for charge %s FAILED — marking refunds failed", sqResult.ID, chargeID)
|
||||
}
|
||||
// ATOMIC — one statement for the whole group, never per-row. Keeps
|
||||
// crash-retry amounts identical so Square's key-dedup returns the
|
||||
// original refund.
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE refunds SET status = 'completed', square_refund_id = $1
|
||||
WHERE id = ANY($2) AND status = 'pending'
|
||||
`, sqResult.ID, idsOf(pending)); upErr != nil {
|
||||
UPDATE refunds SET status = $1, square_refund_id = $2
|
||||
WHERE id = ANY($3) AND status = 'pending'
|
||||
`, sqStatus, sqResult.ID, idsOf(pending)); upErr != nil {
|
||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for charge %s failed — manual reconciliation required: %v", sqResult.ID, chargeID, upErr)
|
||||
}
|
||||
return len(pending), nil
|
||||
|
||||
@@ -332,6 +332,12 @@ func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyK
|
||||
|
||||
func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (*PaymentRecord, error) {
|
||||
var p PaymentRecord
|
||||
// booking_id / vendor_code / gift_card_id / invoice_number are nullable
|
||||
// (e.g. gift-card purchases have no booking). Scan into Null* and map so a
|
||||
// NULL value doesn't 500 the scan (N-3: the same fix class as
|
||||
// CheckIdempotencyByKey).
|
||||
var bookingID, vendorCode, giftCardID sql.NullString
|
||||
var invoiceNumber sql.NullInt64
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
@@ -340,15 +346,26 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
|
||||
FROM payments
|
||||
WHERE id = $1
|
||||
`, paymentID).Scan(
|
||||
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
|
||||
&p.ID, &bookingID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
|
||||
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
|
||||
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
|
||||
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &giftCardID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.BookingID = bookingID.String
|
||||
if vendorCode.Valid {
|
||||
p.VendorCode = &vendorCode.String
|
||||
}
|
||||
if giftCardID.Valid {
|
||||
p.GiftCardID = &giftCardID.String
|
||||
}
|
||||
if invoiceNumber.Valid {
|
||||
n := int(invoiceNumber.Int64)
|
||||
p.InvoiceNumber = &n
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -536,10 +553,22 @@ func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userI
|
||||
|
||||
var savedCardID string
|
||||
var isDefault bool
|
||||
// ON CONFLICT (square_card_id): a response-lost retry re-tokenizes the same
|
||||
// card (CreateCardOnFile's deterministic key returns the same ccof: id), so
|
||||
// the UNIQUE constraint would otherwise 500 on the duplicate. Upsert instead
|
||||
// so the retry returns the existing saved card (N-8).
|
||||
err = db.Conn.QueryRow(ctx, `
|
||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7,
|
||||
NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL)
|
||||
ON CONFLICT (square_card_id) DO UPDATE SET
|
||||
brand = EXCLUDED.brand,
|
||||
last_4 = EXCLUDED.last_4,
|
||||
exp_month = EXCLUDED.exp_month,
|
||||
exp_year = EXCLUDED.exp_year,
|
||||
fingerprint = EXCLUDED.fingerprint,
|
||||
deleted_at = NULL,
|
||||
retained_until = NULL
|
||||
RETURNING id, is_default
|
||||
`, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// SweepStalePendingPayments marks pending payment records that are older than
|
||||
// Square's idempotency-key retention window (~24h) as 'failed'. A pending
|
||||
// record means the DB committed but the Square charge outcome is unknown; it
|
||||
// normally resolves on a same-key client retry. But if the client abandoned
|
||||
// the attempt, the record stays pending forever — and retrying it after the
|
||||
// key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing
|
||||
// stale pendings closes that double-charge window: a late retry finds a
|
||||
// 'failed' record and stops instead of charging again.
|
||||
//
|
||||
// Only online/till card payments can be pending — cash/giftcard/on_the_house
|
||||
// are committed synchronously and never enter this state. Both the payments
|
||||
// table and till_sales carry pending card-sale rows and are swept here.
|
||||
//
|
||||
// A swept row may have been genuinely charged at Square with a lost response —
|
||||
// it is flagged with a CRITICAL manual-reconciliation log (like the refund
|
||||
// sweep) so the money is not silently lost in limbo (MINOR-R3).
|
||||
const stalePendingPaymentAge = 24 * time.Hour
|
||||
|
||||
func SweepStalePendingPayments(ctx context.Context) (int, error) {
|
||||
cutoff := clock.Now().Add(-stalePendingPaymentAge)
|
||||
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
UPDATE payments
|
||||
SET status = 'failed', updated_at = NOW()
|
||||
WHERE status = 'pending'
|
||||
AND created_at < $1
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payCount := int(tag.RowsAffected())
|
||||
|
||||
// till_sales rows for card payments (stored as 'online_square' or
|
||||
// 'in_person_card' in the payment_method enum — saved_card/online_square/
|
||||
// card_machine requests all persist as one of those) can also be pending.
|
||||
// Sweep them too — a lost-response till sale would otherwise stay pending
|
||||
// and a retry after key retention would reuse the stored key → Square sees
|
||||
// an expired key → second charge (R3). Cash / on_the_house are committed
|
||||
// synchronously and never pending.
|
||||
tillTag, err := db.Conn.Exec(ctx, `
|
||||
UPDATE till_sales
|
||||
SET status = 'failed', updated_at = NOW()
|
||||
WHERE status = 'pending'
|
||||
AND created_at < $1
|
||||
AND payment_method IN ('online_square', 'in_person_card')
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tillCount := int(tillTag.RowsAffected())
|
||||
|
||||
total := payCount + tillCount
|
||||
if total > 0 {
|
||||
log.Printf("[SWEEP] Marked %d stale pending payments (%d payments, %d till sales) as failed (older than %s) — late retries will be rejected, preventing a second Square charge", total, payCount, tillCount, stalePendingPaymentAge)
|
||||
}
|
||||
if payCount > 0 {
|
||||
log.Printf("CRITICAL: %d pending payments swept to failed may have been charged at Square with a lost response — manual reconciliation required before refunding/charging", payCount)
|
||||
}
|
||||
if tillCount > 0 {
|
||||
log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -56,6 +56,12 @@ func uniqueTillKey() string {
|
||||
|
||||
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
// Defense-in-depth admin check (S-1) — a till sale moves money (charges a
|
||||
// card / funds a gift card), so it must stay admin-only.
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
||||
|
||||
var req TillSaleRequest
|
||||
@@ -70,9 +76,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// M8
|
||||
// L5
|
||||
|
||||
if req.ItemType != "gift_card" {
|
||||
http.Error(w, "Unsupported item type", http.StatusBadRequest)
|
||||
return
|
||||
@@ -180,6 +183,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
existingPendingID = existingID
|
||||
existingPendingGiftCard = existingItemID
|
||||
}
|
||||
if existingStatus == "failed" {
|
||||
// Swept as stale (>24h) or definitively rejected — a retry would
|
||||
// risk a second Square charge. Reject cleanly (R2).
|
||||
log.Printf("Till-sale retry rejected: record %s was marked failed", existingID)
|
||||
http.Error(w, "This till sale previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +568,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "card_token is required for online_square payment — use a Square Web Payments nonce", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Tokenize the till card. The reference_id is the synthetic
|
||||
// "till-<giftCardID>" namespace, NOT a real user — this card is
|
||||
// ephemeral (used once for this charge) and is never stored in
|
||||
// user_saved_cards or re-listed. The prefix can't collide with a
|
||||
// real CHAR(12)-hex user ID.
|
||||
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken)
|
||||
if cardErr != nil {
|
||||
log.Printf("Failed to tokenize card: %v", cardErr)
|
||||
@@ -630,6 +645,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Defense-in-depth admin check (S-1).
|
||||
if !isAdminRequest(r) {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
|
||||
@@ -675,6 +695,33 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if paymentResult.Status == "COMPLETED" {
|
||||
// Serialize terminal-completion records per till sale — the one payment
|
||||
// writer that previously lacked the advisory-lock pattern every other
|
||||
// path uses. Two concurrent polls of the same checkout could both run
|
||||
// the UPDATE + VAT (idempotent today, but a double-apply is a latent
|
||||
// bug). Lock on the till-sale id so only one goroutine completes it.
|
||||
pinConn, err := db.Conn.Acquire(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for till-completion lock: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
if _, err := pinConn.Exec(r.Context(), `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:tillcomplete:' || $1))
|
||||
`, tillSaleID); err != nil {
|
||||
log.Printf("Failed to acquire till-completion serialization lock for %s: %v", tillSaleID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext('crussell:tillcomplete:' || $1))
|
||||
`, tillSaleID); err != nil {
|
||||
log.Printf("Failed to release till-completion serialization lock for %s: %v", tillSaleID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
tx, err := db.Conn.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
|
||||
@@ -416,6 +416,7 @@ func TestGetTillCheckoutStatus_NotFound(t *testing.T) {
|
||||
rctx.URLParams.Add("checkout_id", "nonexistent")
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(w, req)
|
||||
@@ -482,6 +483,8 @@ func TestGetTillCheckoutStatus_Pending(t *testing.T) {
|
||||
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
|
||||
statusReq = statusReq.WithContext(statusReqCtx)
|
||||
|
||||
statusReq = adminRequestCtx(statusReq)
|
||||
|
||||
wStatus := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(wStatus, statusReq)
|
||||
|
||||
@@ -555,6 +558,8 @@ func TestGetTillCheckoutStatus_Completed(t *testing.T) {
|
||||
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
|
||||
statusReq = statusReq.WithContext(statusReqCtx)
|
||||
|
||||
statusReq = adminRequestCtx(statusReq)
|
||||
|
||||
wStatus := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(wStatus, statusReq)
|
||||
|
||||
@@ -624,6 +629,8 @@ func TestGetTillCheckoutStatus_AlreadyCompleted(t *testing.T) {
|
||||
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
|
||||
statusReq = statusReq.WithContext(statusReqCtx)
|
||||
|
||||
statusReq = adminRequestCtx(statusReq)
|
||||
|
||||
wStatus := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(wStatus, statusReq)
|
||||
|
||||
@@ -640,9 +647,11 @@ func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) {
|
||||
rctx.URLParams.Add("checkout_id", "")
|
||||
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(reqCtx)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(w, req)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -34,23 +34,30 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
// in env vars (see Square Developer Console → Webhooks → Subscription).
|
||||
// Reference: https://developer.squareup.com/docs/webhooks/step3validate
|
||||
|
||||
// Fail closed: a missing signing key means the webhook cannot be verified,
|
||||
// so reject rather than process unauthenticated events (S-4). Square
|
||||
// always sends the signature header, so an unset key in production is a
|
||||
// misconfiguration that must not silently accept forged events.
|
||||
signingKey := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||
notificationURL := os.Getenv("SQUARE_WEBHOOK_NOTIFICATION_URL")
|
||||
if notificationURL == "" {
|
||||
notificationURL = "http://localhost:8080/webhooks/square"
|
||||
}
|
||||
if signingKey != "" {
|
||||
signature := r.Header.Get("x-square-hmacsha256-signature")
|
||||
if signature == "" {
|
||||
log.Printf("Missing Square webhook signature header")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !verifySquareSignature(body, signature, signingKey, notificationURL) {
|
||||
log.Printf("Invalid Square webhook signature")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if signingKey == "" {
|
||||
log.Printf("SQUARE_WEBHOOK_SIGNATURE_KEY is not set — rejecting webhook (fail-closed)")
|
||||
http.Error(w, "webhook signature verification unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
signature := r.Header.Get("x-square-hmacsha256-signature")
|
||||
if signature == "" {
|
||||
log.Printf("Missing Square webhook signature header")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !verifySquareSignature(body, signature, signingKey, notificationURL) {
|
||||
log.Printf("Invalid Square webhook signature")
|
||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var event SquareWebhookEvent
|
||||
|
||||
@@ -111,8 +111,20 @@ func makeWebhookRequest(body []byte, signature string, ctx context.Context) *htt
|
||||
return w
|
||||
}
|
||||
|
||||
// webhookTestEnv sets a signing key and returns a valid signature for the body
|
||||
// (the fail-closed handler requires a verifiable signature on every request).
|
||||
func webhookTestEnv(t *testing.T, body []byte) (signature string) {
|
||||
t.Helper()
|
||||
tKey := "test-signing-key"
|
||||
tURL := "http://localhost:8080/webhooks/square"
|
||||
mac := hmac.New(sha256.New, []byte(tKey))
|
||||
mac.Write([]byte(tURL))
|
||||
mac.Write(body)
|
||||
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", tKey)
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
EventID: "evt_payment_1",
|
||||
@@ -120,7 +132,8 @@ func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) {
|
||||
Data: json.RawMessage(`{"id":"payment_1"}`),
|
||||
}
|
||||
body, _ := json.Marshal(event)
|
||||
w := makeWebhookRequest(body, "", context.Background())
|
||||
sig := webhookTestEnv(t, body)
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -130,7 +143,6 @@ func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_RefundUpdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := SquareWebhookEvent{
|
||||
Type: "refund.updated",
|
||||
EventID: "evt_refund_1",
|
||||
@@ -138,14 +150,14 @@ func TestHandleSquareWebhook_RefundUpdated(t *testing.T) {
|
||||
Data: json.RawMessage(`{"id":"refund_1"}`),
|
||||
}
|
||||
body, _ := json.Marshal(event)
|
||||
w := makeWebhookRequest(body, "", context.Background())
|
||||
sig := webhookTestEnv(t, body)
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_DisputeCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := SquareWebhookEvent{
|
||||
Type: "dispute.created",
|
||||
EventID: "evt_dispute_1",
|
||||
@@ -153,14 +165,14 @@ func TestHandleSquareWebhook_DisputeCreated(t *testing.T) {
|
||||
Data: json.RawMessage(`{"id":"dispute_1"}`),
|
||||
}
|
||||
body, _ := json.Marshal(event)
|
||||
w := makeWebhookRequest(body, "", context.Background())
|
||||
sig := webhookTestEnv(t, body)
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for dispute.created, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_UnknownEventType(t *testing.T) {
|
||||
t.Parallel()
|
||||
event := SquareWebhookEvent{
|
||||
Type: "invoice.created",
|
||||
EventID: "evt_unknown_1",
|
||||
@@ -168,25 +180,27 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) {
|
||||
Data: json.RawMessage(`{"id":"inv_1"}`),
|
||||
}
|
||||
body, _ := json.Marshal(event)
|
||||
w := makeWebhookRequest(body, "", context.Background())
|
||||
sig := webhookTestEnv(t, body)
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for unknown event type, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_InvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := makeWebhookRequest([]byte(`{invalid json}`), "", context.Background())
|
||||
body := []byte(`{invalid json}`)
|
||||
sig := webhookTestEnv(t, body)
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 600KB body exceeds the 512KB limit
|
||||
largeBody := []byte(strings.Repeat("a", 600*1024))
|
||||
w := makeWebhookRequest(largeBody, "", context.Background())
|
||||
sig := webhookTestEnv(t, largeBody)
|
||||
w := makeWebhookRequest(largeBody, sig, context.Background())
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("expected 413 for oversized body, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -236,14 +250,15 @@ func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSquareWebhook_SignatureSkippedWhenKeyEmpty(t *testing.T) {
|
||||
func TestHandleSquareWebhook_RejectedWhenKeyEmpty(t *testing.T) {
|
||||
|
||||
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "")
|
||||
|
||||
body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`)
|
||||
// Bad signature but key is empty, so verification should be skipped
|
||||
// Fail-closed: an unset signing key means the webhook cannot be verified,
|
||||
// so the request is rejected rather than accepted with a bad signature.
|
||||
w := makeWebhookRequest(body, "some-signature", context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 when no key configured (dev stub), got %d. body: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 when no key configured (fail-closed), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user