package payments import ( "context" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" "crypto/rand" "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) type CreateTerminalPaymentRequest struct { Amount int64 `json:"amount" validate:"required,gt=0"` PaymentType string `json:"payment_type" validate:"required"` OverrideAmount *int64 `json:"override_amount,omitempty"` 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 { Amount int64 `json:"amount" validate:"required,gt=0"` PaymentType string `json:"payment_type" validate:"required"` CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` IdempotencyKey string `json:"idempotency_key" validate:"required"` VerificationToken *string `json:"verification_token,omitempty"` } type RefundRequest struct { Amount int64 `json:"amount"` Reason string `json:"reason"` // Optional client-generated idempotency key. Two DISTINCT refunds of the // same amount against the same payment must not collide on the default // amount-derived key (the dedup lookup would swallow the second refund). // The frontend sends a UUID generated per refund attempt and reuses it on // retry, mirroring the tip-flow pattern. IdempotencyKey string `json:"idempotency_key,omitempty"` } type CreateTipPaymentRequest struct { Amount int64 `json:"amount" validate:"required,gt=0"` CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` IdempotencyKey string `json:"idempotency_key,omitempty"` VerificationToken *string `json:"verification_token,omitempty"` } type CheckoutResponse struct { CheckoutID string `json:"checkout_id"` Status string `json:"status"` } type PaymentStatusResponse struct { Status string `json:"status"` PaymentID string `json:"payment_id,omitempty"` Amount int64 `json:"amount,omitempty"` CardBrand string `json:"card_brand,omitempty"` CardLast4 string `json:"card_last4,omitempty"` ReceiptURL string `json:"receipt_url,omitempty"` } type PaymentResponse struct { ID string `json:"id"` BookingID string `json:"booking_id"` PaymentType string `json:"payment_type"` Status string `json:"status"` Amount int64 `json:"amount"` CardBrand string `json:"card_brand,omitempty"` CardLast4 string `json:"card_last4,omitempty"` ReceiptURL string `json:"receipt_url,omitempty"` CreatedAt string `json:"created_at"` } type RefundResponse struct { ID string `json:"id"` PaymentID string `json:"payment_id"` Amount int64 `json:"amount"` Status string `json:"status"` Reason string `json:"reason"` CreatedAt string `json:"created_at"` } type PaymentSummaryResponse struct { TotalAmount int64 `json:"total_amount"` PaidAmount int64 `json:"paid_amount"` RefundedAmount int64 `json:"refunded_amount"` RemainingAmount int64 `json:"remaining_amount"` TotalVATAmount int64 `json:"total_vat_amount"` TotalNetAmount int64 `json:"total_net_amount"` Payments []PaymentResponse `json:"payments"` Refunds []RefundResponse `json:"refunds"` } // DiscountPreviewResponse describes eligible discounts for a booking. type DiscountPreviewResponse struct { Eligible bool `json:"eligible"` Discounts []DiscountPreview `json:"discounts"` OriginalTotal float64 `json:"original_total"` DiscountedTotal float64 `json:"discounted_total"` } // DiscountPreview describes a single eligible discount. type DiscountPreview struct { Source string `json:"source"` Name string `json:"name"` Percent float64 `json:"percent"` 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) { bookingID := chi.URLParam(r, "id") if !validators.IsValidID(bookingID) { http.Error(w, "Invalid booking ID", http.StatusBadRequest) return } userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } userRole, _ := r.Context().Value(mw.UserRoleKey).(string) service := NewPaymentService() // Fail closed (R5): a non-admin request must own the booking. The previous // handler computed the discount preview for ANY booking id the caller // supplied — leaking another user's booking total and eligible discounts // (IDOR). Mirror GetBookingPaymentSummary's ownership check exactly: a // request with no user context gets 401, and a non-owner gets 403. if userRole != "admin" { if userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } bookingUserID, err := service.GetBookingUserID(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 user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } } preview := calculateDiscountPreview(r.Context(), bookingID, userID) if err := json.NewEncoder(w).Encode(preview); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // calculateDiscountPreview runs the same eligibility queries as // applyEligibleCampaignsAtPayment (via ComputeEligibleDiscounts) but returns // the results without inserting any records. func calculateDiscountPreview(ctx context.Context, bookingID string, userID string) DiscountPreviewResponse { resp := DiscountPreviewResponse{ Discounts: []DiscountPreview{}, } var bookingTotal float64 if err := db.Conn.QueryRow(ctx, ` SELECT total_amount FROM bookings WHERE id = $1 `, bookingID).Scan(&bookingTotal); err != nil { log.Printf("Failed to query booking total for discount preview %s: %v", bookingID, err) } if bookingTotal <= 0 { return resp } resp.OriginalTotal = bookingTotal discountTotal := 0.0 // Shared with the apply-at-payment path so the preview shows exactly what // payment will apply — including the global in-person milestone discount // that was previously only computed at payment time. for _, d := range ComputeEligibleDiscounts(ctx, db.Conn, bookingID, userID, bookingTotal) { resp.Discounts = append(resp.Discounts, DiscountPreview{ Source: d.Source, Name: d.Name, Percent: d.Percent, Amount: d.Amount, }) discountTotal += d.Amount } resp.Eligible = len(resp.Discounts) > 0 resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal) return resp } 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) return } adminID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req CreateTerminalPaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode terminal payment request: %v", err) http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateAmount(req.Amount); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidatePaymentType(req.PaymentType); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } service := NewPaymentService() amount := req.Amount if req.OverrideAmount != nil { amount = *req.OverrideAmount } // Idempotency key for the payment. Cash/giftcard terminal payments are // always fresh admin actions (not network-retryable), and the request has // no client key — so a deterministic booking+type+amount key would wrongly // dedup two legitimate identical payments (e.g. two £50 cash receipts on // one booking). Use a unique key per payment: retries of a lost response // are handled by the Square-side key for card payments, and cash/giftcard // are DB-committed synchronously. idempotencyKey := uniqueChargeKey("tip-") // Route based on payment method if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") { // Start transaction before the status and idempotency checks so they // are atomic with the payment insert. 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 } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Check booking status inside the transaction. var status string if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); 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 } // Check idempotency inside the transaction. var existingID string var existingStatus string if err := tx.QueryRow(r.Context(), ` SELECT id, status FROM payments WHERE booking_id = $1 AND idempotency_key = $2 `, bookingID, idempotencyKey).Scan(&existingID, &existingStatus); err == nil { if err := json.NewEncoder(w).Encode(CheckoutResponse{ CheckoutID: existingID, Status: existingStatus, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } else if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to check idempotency: %v", err) } amountPounds := float64(amount) / 100.0 var paymentID string if *req.PaymentMethod == "cash" { err = tx.QueryRow(r.Context(), ` INSERT INTO payments ( booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at ) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW()) RETURNING id `, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID) if err != nil { log.Printf("Failed to create cash payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } ApplyVATToBookingPayment(r.Context(), tx, paymentID) } else { // giftcard var customerID sql.NullString err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID) if err != nil { log.Printf("Failed to query booking user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } usedBalance := false if customerID.Valid { var balance float64 err = tx.QueryRow(r.Context(), "SELECT balance FROM user_giftcard_balances WHERE user_id = $1 FOR UPDATE", customerID.String).Scan(&balance) if err == nil { if balance < amountPounds { http.Error(w, "Insufficient gift card balance on user account", http.StatusBadRequest) return } // Deduct from account balance _, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String) if err != nil { log.Printf("Failed to deduct user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } usedBalance = true } else if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } var cardVoucherType string // voucher_type_at_purchase from the gift card if !usedBalance { // Try direct card redemption (for guests or users without a redeemed balance) if req.GiftCardID == nil || *req.GiftCardID == "" { http.Error(w, "Gift card ID is required", http.StatusBadRequest) return } cleanCardID := validators.NormalizeGiftCardCode(*req.GiftCardID) var gcRemaining float64 var redeemedBy sql.NullString var vtp sql.NullString err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by, voucher_type_at_purchase FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy, &vtp) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to query gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if redeemedBy.Valid { http.Error(w, "This gift card has already been redeemed to an account. Please pay using the account balance.", http.StatusBadRequest) return } if gcRemaining < amountPounds { http.Error(w, "Insufficient balance on gift card", http.StatusBadRequest) return } // Record the voucher_type_at_purchase for later VAT decision. // Legacy cards (created before this column existed) have NULL → default to SPV. if vtp.Valid { cardVoucherType = vtp.String } else { cardVoucherType = "SPV" } // Deduct directly from card remaining amount _, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID) if err != nil { log.Printf("Failed to deduct gift card amount: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } err = tx.QueryRow(r.Context(), ` INSERT INTO payments ( booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at ) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW()) RETURNING id `, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID) if err != nil { log.Printf("Failed to create giftcard payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Apply VAT at redemption only if the gift card was purchased as MPV // (VAT deferred to redemption). For SPV, VAT was already paid at sale. // For account balance payments (usedBalance=true), VAT was already paid // when the original card was purchased. if usedBalance { // VAT already paid at purchase time — nothing to do here. } else if cardVoucherType == "MPV" { vatCfg, vatErr := GetVATConfig(r.Context(), tx) if vatErr == nil && vatCfg.IsVATRegistered { if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil { log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr) } } } } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit payment: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(CheckoutResponse{ CheckoutID: paymentID, Status: "COMPLETED", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } 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 } // Resolve the saved-card Square source for the booking's user (the // card's owner, not the admin) — shared new-card-vs-saved-card // resolution, see resolveChargeSource for the R6 rationale. sourceID, _, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, bookingUserID.String, nil, req.UserSavedCardID, false, "Saved card not found") if !sourceOK { 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). The lock is // acquired with a bounded try-lock loop (R6): a blocking pg_advisory_lock // would hold the pinned pool connection for the full Square round-trip of // whichever request holds the lock, and ~4 concurrent same-booking // requests would exhaust the whole pool. pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID) // 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: sourceID, CustomerID: savedCardCustomerID, 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", chargeFailureStatus(err)) return } // Defensive post-charge recheck (R9): the window is tiny — this branch // only runs on in_progress/completed bookings and the pending record // committed moments ago — but a concurrent cancellation/eviction can // still move the booking between the Square call and this record. A // charge landing on a cancelled/lapsed booking must not be recorded as // completed (the cancellation refund path computes refunds from // completed payments). Mark the row failed and alert ops: money was // taken at Square and MUST be refunded manually. recheckStatus, payable, err := recheckBookingPayable(r.Context(), db.Conn, bookingID) if err != nil { log.Printf("CRITICAL: Square payment %s was processed for booking %s but re-reading booking status failed: %v — manual reconciliation required", paymentResult.SquarePayID, bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !payable { log.Printf("CRITICAL: Square payment %s was processed but booking %s is now %q — marking saved-card payment %s failed; money taken at Square MUST be refunded manually", paymentResult.SquarePayID, bookingID, recheckStatus, paymentID) if _, upErr := db.Conn.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil { log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking payment %s failed errored: %v — manual reconciliation required", paymentResult.SquarePayID, recheckStatus, bookingID, paymentID, upErr) } http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) 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. // No DB transaction is needed for the Square call itself; the in-flight // guard below serializes checkout creation per booking and records the // checkout's payment type for GetCheckoutStatus to read back. 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 } // Serialize terminal-checkout creation per booking. This is the backend // half of the double-submit fix: a lost-response retry must not create a // second live Square checkout for the same booking while the first is in // flight. Bounded try-lock (R6) so a contended lock never blocks the pool // across the Square round-trip. pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID) if existing := activeTerminalCheckoutID(r.Context(), bookingID); existing != "" { if err := json.NewEncoder(w).Encode(CheckoutResponse{ CheckoutID: existing, Status: "PENDING", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } // Insert the tracked terminal_checkouts row FIRST with a provisional // (pre-Square) checkout_id, THEN create the checkout at Square, THEN update // the row with the real checkout_id (R3). A hard crash between the insert // and the Square call leaves a visible PENDING row the in-flight guard and // sweep can resolve as failed — the old order (CreateCheckout first) left a // live untracked checkout the sweep could not see. The provisional id is // synthetic ("tmp-") because the column is a NOT NULL // PRIMARY KEY; a row carrying one is provably pre-Square (no checkout was // ever created for it). provisionalID := "tmp-" + idempotencyKey if _, err := db.Conn.Exec(r.Context(), ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) VALUES ($1, $2, $3, 'PENDING', $4) `, provisionalID, bookingID, req.PaymentType, float64(amount)/100.0); err != nil { log.Printf("Failed to record provisional terminal checkout for booking %s: %v", bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } checkoutReq := square.CreateCheckoutReq{ Amount: amount, Currency: "GBP", IdempotencyKey: idempotencyKey, ReferenceID: bookingID, AllowTipping: req.TipEnabled, } checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq) if err != nil { log.Printf("Failed to create checkout: %v", err) // The provisional row is pre-Square and can never produce a charge — // mark it failed so a retry can proceed (best-effort; log CRITICAL if // the row update itself fails, since the row would then wedge the // booking's in-flight guard). if _, upErr := db.Conn.Exec(r.Context(), ` UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1 AND status = 'PENDING' `, provisionalID); upErr != nil { log.Printf("CRITICAL: failed to mark provisional terminal checkout %s failed after CreateCheckout error (%v): %v — MANUAL RECONCILIATION REQUIRED", provisionalID, err, upErr) } http.Error(w, "Failed to create payment", http.StatusInternalServerError) return } // Attach the real Square checkout id to the tracked row (the provisional // id was never seen by the client, so no poller can race this). tag, upErr := db.Conn.Exec(r.Context(), ` UPDATE terminal_checkouts SET checkout_id = $1, updated_at = NOW() WHERE checkout_id = $2 `, checkout.ID, provisionalID) if upErr != nil { log.Printf("CRITICAL: terminal checkout %s was created at Square but the tracking UPDATE (from provisional %s) failed: %v — manual reconciliation required", checkout.ID, provisionalID, upErr) // The checkout is live at Square but untracked — best-effort cancel so // a customer cannot complete a charge the backend can't record. if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil { log.Printf("CRITICAL: failed to cancel orphaned terminal checkout %s after the tracking UPDATE failed: %v — MANUAL RECONCILIATION REQUIRED: the checkout may still be live at Square", checkout.ID, cErr) } http.Error(w, "internal server error", http.StatusInternalServerError) return } if tag.RowsAffected() == 0 { // The provisional row vanished while the Square call was in flight // (the sweep resolved it as stale) — the checkout is now live at // Square but untracked. log.Printf("CRITICAL: terminal checkout %s was created at Square but provisional row %s was already resolved — the checkout is untracked; MANUAL RECONCILIATION REQUIRED", checkout.ID, provisionalID) if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil { log.Printf("CRITICAL: failed to cancel untracked terminal checkout %s: %v — MANUAL RECONCILIATION REQUIRED", checkout.ID, cErr) } http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(CheckoutResponse{ CheckoutID: checkout.ID, Status: checkout.Status, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // activeTerminalCheckoutID returns the checkout_id of an in-flight terminal // checkout for the booking, or "" if none. Called under the // crussell:payment: advisory lock. A PENDING/IN_PROGRESS row is // resolved against Square: an already-completed checkout must not block a new // charge, while one still live at Square is returned so a lost-response retry // reuses it instead of creating a second live checkout. // // A checkout in a definitively terminal state (CANCELED / CANCEL_REQUESTED, or // NOT_FOUND for an expired checkout) is ALSO resolved: it can never complete, // so it must not wedge the booking. Such a checkout surfaces as a GetCheckout // error (the HTTP client returns an error for any non-COMPLETED, non-PENDING // status) and would otherwise be treated as "still in flight" forever, blocking // every future terminal charge on the booking. Only ErrCheckoutPending and // ambiguous transport errors keep the checkout in flight — a second live // checkout must never be created while the first one's money state is unknown. func activeTerminalCheckoutID(ctx context.Context, bookingID string) string { var checkoutID string if err := db.Conn.QueryRow(ctx, ` SELECT checkout_id FROM terminal_checkouts WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') ORDER BY created_at ASC LIMIT 1 `, bookingID).Scan(&checkoutID); err != nil { if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to query active terminal checkout for booking %s: %v", bookingID, err) } return "" } // A provisional (pre-Square) row carries a synthetic "tmp-" checkout_id (or // an empty one for legacy rows) — no checkout was ever created at Square // for it, so it is PROVABLY not live (R3). A hard crash between the // terminal_checkouts insert and the Square CreateCheckout call is the only // way one exists. Mark it failed so a fresh checkout can be created instead // of wedging the booking behind a non-existent Square checkout. if checkoutID == "" || strings.HasPrefix(checkoutID, "tmp-") { log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, bookingID) if checkoutID != "" { if _, upErr := db.Conn.Exec(ctx, ` UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1 `, checkoutID); upErr != nil { log.Printf("Failed to mark provisional terminal checkout %s failed: %v", checkoutID, upErr) } } return "" } // Resolve against Square: once the terminal charge finished, the checkout // is COMPLETED and its payment may already be recorded — it must not // block a subsequent charge on the same booking. result, err := SquareClient.GetCheckout(ctx, checkoutID) if err == nil && result.Status == "COMPLETED" { if _, upErr := db.Conn.Exec(ctx, ` UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 `, checkoutID); upErr != nil { log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr) } return "" } // A definitively terminal checkout (cancelled / cancel-requested / // expired-NOT_FOUND) can never complete — mark the row failed and allow a // new checkout instead of wedging the booking forever. if isTerminalCheckoutError(err) { log.Printf("Terminal checkout %s is definitively terminal at Square (%v) — allowing a new checkout for booking %s", checkoutID, err, bookingID) if _, upErr := db.Conn.Exec(ctx, ` UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1 `, checkoutID); upErr != nil { log.Printf("Failed to mark terminal checkout %s failed after terminal state: %v", checkoutID, upErr) } return "" } // ErrCheckoutPending or any ambiguous error: treat the checkout as still // in flight. Never create a second live checkout while the first one's // money state at Square is unknown. return checkoutID } 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.IsValidSquareCheckoutID(checkoutID) { http.Error(w, "not found", http.StatusNotFound) return } // Reject provisional "tmp-" checkout ids: CreateTerminalPayment stores a // synthetic "tmp-" id in terminal_checkouts until Square // returns the real checkout id (provisional-row design), so such an id was // never a real checkout — resolving it against Square would come back // NOT_FOUND and surface as a 500. Answer 404 instead (mirrors the guard in // CreateTerminalPayment and the sweep). if strings.HasPrefix(checkoutID, "tmp-") { http.Error(w, "Checkout not found", http.StatusNotFound) return } bookingID := r.URL.Query().Get("booking_id") if bookingID == "" { http.Error(w, "booking_id query parameter is required", http.StatusBadRequest) return } if !validators.IsValidID(bookingID) { http.Error(w, "Invalid booking ID", http.StatusBadRequest) return } paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID) if err != nil { if errors.Is(err, square.ErrCheckoutPending) { if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to get checkout status: %v", err) http.Error(w, "Failed to get checkout status", http.StatusInternalServerError) 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). An EMPTY reference_id is also rejected: a checkout // created outside this app with no reference must not be attachable to a // booking (S-1) — fail closed on anything that is not exactly this booking. if paymentResult.ReferenceID == "" || paymentResult.ReferenceID != bookingID { log.Printf("Checkout %s does not reference booking %s (reference_id=%q) — refusing to record", checkoutID, bookingID, paymentResult.ReferenceID) http.Error(w, "Checkout does not belong to this booking", http.StatusBadRequest) return } if paymentResult.Status == "COMPLETED" { service := NewPaymentService() // Serialize terminal-completion records per Square payment ID. Two // concurrent polls of the same checkout could otherwise BOTH pass the // dedup SELECT and BOTH INSERT, with the second dying on the // idempotency_key UNIQUE constraint after the customer already paid — // the same double-record race every other payment path guards against. // Bounded try-lock (R6) so a contended lock never blocks the pool. terminalLockKey := paymentResult.SquarePayID pinConn, err := db.Conn.Acquire(r.Context()) if err != nil { log.Printf("Failed to acquire connection for terminal-completion lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:terminal:"+terminalLockKey) if err != nil { log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", terminalLockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", terminalLockKey) http.Error(w, "Payment in progress, try again", http.StatusConflict) return } defer func() { if _, err := pinConn.Exec(context.Background(), ` SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1)) `, terminalLockKey); err != nil { log.Printf("Failed to release terminal-completion serialization lock for %s: %v", terminalLockKey, err) } }() // Deterministic idempotency key derived from booking + amount + Square // payment ID. The Square payment ID disambiguates two distinct // equal-amount charges on the same booking, so equal amounts never // collide on the UNIQUE constraint. idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID // Begin the transaction BEFORE the dedup lookup so it's atomic with the // payment insert. 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 } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Dedup by Square payment ID: a double poll of the same terminal // checkout must return the existing payment row instead of inserting a // duplicate (which previously 500'd on the idempotency-key UNIQUE // violation after the customer had already paid). var existingID string if err := tx.QueryRow(r.Context(), ` SELECT id FROM payments WHERE booking_id = $1 AND square_payment_id = $2 `, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil { if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: existingID, Amount: paymentResult.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } else if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to check for existing payment: %v", err) } // Re-check the booking status after the advisory lock: a concurrent // cancellation/eviction can move the booking out of a payable state // between the terminal charge completing and this poll recording it. A // charge landing on a cancelled/lapsed/no-show booking must not be // recorded as a completed payment — the cancellation refund path // computes refunds from completed payments and would silently exclude // this charge. Mark the checkout failed and alert ops: money was taken // at Square and MUST be refunded manually (mirrors CreateBookingPayment's // post-charge recheck). var recheckStatus string if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&recheckStatus); err != nil { log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required", paymentResult.SquarePayID, checkoutID, bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !bookingStatusAllowsCompletedPayment(recheckStatus) { log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually", paymentResult.SquarePayID, checkoutID, bookingID, recheckStatus) if _, upErr := tx.Exec(r.Context(), `UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1`, checkoutID); upErr != nil { log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required", paymentResult.SquarePayID, recheckStatus, bookingID, checkoutID, upErr) } if cErr := tx.Commit(r.Context()); cErr != nil { log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required", paymentResult.SquarePayID, recheckStatus, bookingID, cErr) } http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) return } // The payment type the admin charged is recorded on the checkout row // by CreateTerminalPayment. Fall back to 'full' for legacy checkouts // created before that record existed. var checkoutPaymentType string if err := tx.QueryRow(r.Context(), ` SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1 `, checkoutID).Scan(&checkoutPaymentType); err != nil { if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err) } checkoutPaymentType = "full" } record := PaymentRecord{ BookingID: bookingID, PaymentType: checkoutPaymentType, PaymentMethod: "in_person_card", Status: "completed", Amount: float64(paymentResult.Amount) / 100.0, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &idempotencyKey, Fees: float64(paymentResult.Fees) / 100.0, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), } paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } ApplyVATToBookingPayment(r.Context(), tx, paymentID) // Release the in-flight guard: this checkout is done, so a subsequent // charge on the same booking is allowed. if _, err := tx.Exec(r.Context(), ` UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 `, checkoutID); err != nil { log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err) } 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 } if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: paymentID, Amount: paymentResult.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } // No non-COMPLETED fallthrough here: GetCheckout (via getCheckoutHTTP) // only returns a nil error for a COMPLETED checkout — a non-COMPLETED // status or an expired/cancelled checkout surfaces as an error, which was // already handled above (ErrCheckoutPending → PENDING, everything else → // 500). The previous trailing `http.Error(w, "Payment failed", 402)` was // unreachable dead code and has been removed. } // IsValidBookingStatusForPayment returns true if the booking status allows // accepting payments. This guard prevents racing with CleanupExpiredDeposits — // once a booking's slot has been released (deposit_lapsed, etc.), // we must reject the payment before hitting Square's API. func IsValidBookingStatusForPayment(status string) bool { switch status { case "confirmed", "pending", "pending_release", "in_progress": return true default: return false } } // bookingStatusAllowsCompletedPayment reports whether a charge that already // went through Square can still be recorded as a completed payment. It differs // from IsValidBookingStatusForPayment: a booking that legitimately completed // ('completed') must still accept the recorded payment, while a cancelled, // lapsed, or no-show booking must NOT — the money would bypass the // cancellation refund system, which computes refunds from completed payments. func bookingStatusAllowsCompletedPayment(status string) bool { switch status { case "confirmed", "pending", "pending_release", "in_progress", "completed": return true default: return false } } func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req CreateBookingPaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode booking payment request: %v", err) http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Resolve buyer email for Square receipt delivery (failure is non-fatal). var bookingBuyerEmail string if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } if err := ValidateAmount(req.Amount); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidatePaymentType(req.PaymentType); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateVerificationToken(req.VerificationToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } service := NewPaymentService() if req.PaymentType == "partial" { remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID) if err != nil { log.Printf("Failed to get remaining balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } } bookingUserID, err := service.GetBookingUserID(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 user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } // Serialize payment attempts for this booking to prevent concurrent payments // across browser tabs or duplicate requests. Uses a PostgreSQL session-level // advisory lock so that only one goroutine processes payment for a given // booking at a time, even if two requests pass the optimistic status check below. // // We acquire a dedicated connection from the pool and hold it for the // duration of the handler so that lock and unlock use the same connection. // Using db.Conn.Exec() for both would be unsafe — each call may get a // different pool connection, and pg_advisory_unlock on a different session // is a silent no-op, leaking the lock. // // R6: the lock is acquired with a bounded try-lock loop rather than the // blocking pg_advisory_lock. A blocking lock would pin the pool connection // for the whole Square round-trip (~30s), so ~4 concurrent same-booking // payments would exhaust the default pool and hang every request. pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID) // Now that we hold the serialization lock, begin a transaction and re-check // the booking status inside it. If another request (e.g. from a different tab) // already processed a payment and promoted the booking while we were waiting, // we see that here. tx, txErr := db.Conn.Begin(r.Context()) if txErr != nil { log.Printf("Failed to begin transaction: %v", txErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var status string if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking status for payment check: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !IsValidBookingStatusForPayment(status) { log.Printf("Payment rejected: booking %s is in status %q (no longer accepting payments)", bookingID, status) http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) return } if status == "pending" { log.Printf("Payment rejected: booking %s is 'pending' — must be confirmed first", bookingID) http.Error(w, "This booking has not been confirmed yet. Please wait for the booking to be confirmed before making a payment.", http.StatusConflict) return } // 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 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) 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, PaymentType: existingPaymentType.String, Status: existingStatus.String, Amount: int64(math.Round(existingAmount.Float64 * 100)), CreatedAt: existingCreatedAt.Time.Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return 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) } // After the idempotency check (which handles same-key retries), verify // that no completed payment of the same non-partial type already exists. // buildSplitRecords converts 'full' and 'deposit' input types into a // 'deposit' PB record, so we also check for an existing deposit when the // incoming type is 'full' or 'deposit'. Together with the advisory lock, // this prevents the two-tab race where different idempotency keys allow // concurrent payments of the same type. if req.PaymentType != "partial" { var existingCount int if err := tx.QueryRow(r.Context(), ` SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND ( payment_type = $2 OR ($2 IN ('full', 'deposit') AND payment_type = 'deposit') ) `, bookingID, req.PaymentType).Scan(&existingCount); err == nil && existingCount > 0 { log.Printf("Payment rejected: booking %s already has a completed %q payment", bookingID, req.PaymentType) http.Error(w, "A payment of this type has already been processed for this booking", http.StatusConflict) return } } var sourceID string var savedCardID *string var savedCardCustomerID string // Resolve the new-card-vs-saved-card Square source (shared with // CreateTipPayment, BuyGiftCard, and the saved-card branch of // CreateTerminalPayment — see resolveChargeSource for the R6 rationale). sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found") if !sourceOK { return } // 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. var verificationToken string if req.VerificationToken != nil { verificationToken = *req.VerificationToken } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, CustomerID: savedCardCustomerID, IdempotencyKey: req.IdempotencyKey, ReferenceID: bookingID, Note: req.PaymentType, BuyerEmail: bookingBuyerEmail, VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq) if err != nil { log.Printf("Failed to create payment: %v", err) http.Error(w, "Payment failed", chargeFailureStatus(err)) return } 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) } }() // Re-check the booking status under the advisory lock: a concurrent // cancellation/eviction can move the booking out of a payable state between // the pending commit (step 1) and the Square charge completing. A charge // landing on a cancelled/lapsed/no-show booking must not be recorded as a // completed payment — the cancellation refund path computes refunds from // completed payments and would silently exclude this deposit. Mark it // failed and alert ops: money was taken at Square and MUST be refunded // manually. The pending row is marked 'failed' in the tx below, so // idempotency dedup still blocks a second Square charge, but the row no // longer shows pending — the frontend's retry gets a 409 Conflict. recheckStatus, payable, err := recheckBookingPayable(r.Context(), tx2, bookingID) if err != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !payable { log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking payment failed; money taken at Square MUST be refunded manually", paymentResult.Status, paymentResult.SquarePayID, bookingID, recheckStatus) if _, upErr := tx2.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking payment %s failed errored: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, paymentID, upErr) } if cErr := tx2.Commit(r.Context()); cErr != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, cErr) } http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) return } // 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, PaymentMethod: "online_square", Status: "completed", Amount: paymentAmount, SquarePaymentID: &paymentResult.SquarePayID, IdempotencyKey: &req.IdempotencyKey, Fees: float64(fees) / 100.0, UserSavedCardID: savedCardID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &userID, } var records []PaymentRecord if bErr == nil && bookingInfo != nil { records = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount) } else { if bErr != nil { log.Printf("Failed to get booking info for split: %v — using single record", bErr) } records = []PaymentRecord{primaryRecord} } // 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 } // 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[1:] { pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx2, rec, nil) if cErr != nil { 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) } // 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(), tx2) if vatErr == nil && vatCfg.IsVATRegistered { 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-completed primary. // Tip rows are excluded (they are gratuity, not payment toward the booking) // as are discount/on_the_house rows (no real money moved). var depositMet bool if err := tx2.QueryRow(r.Context(), ` WITH booking_total AS ( SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1 ), paid_total AS ( SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house') ) SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2) FROM booking_total bt, paid_total pt `, bookingID).Scan(&depositMet); err != nil { log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err) } if depositMet { if _, err := tx2.Exec(r.Context(), ` UPDATE bookings SET status = 'confirmed', updated_at = NOW() WHERE id = $1 AND status = 'pending_release' `, bookingID); err != nil { log.Printf("ALERT: payment recorded but failed to promote booking %s from pending_release: %v", bookingID, err) } } // 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(), tx2, bookingID, userID) 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) return } if err := json.NewEncoder(w).Encode(PaymentResponse{ ID: paymentID, BookingID: bookingID, PaymentType: req.PaymentType, Status: "completed", Amount: req.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // applyEligibleCampaignsAtPayment checks and applies any eligible discount // campaigns to the booking. Uses the provided transaction so that discount // writes are atomic with the caller's payment transaction — if the payment // commit fails, the discount writes roll back with it. // Skips if the booking already has 2+ completed non-discount payments — this // prevents applying new discounts after a customer has already paid, which // would create a credit balance or require a refund. func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID string, userID string) { var bookingTotal float64 if err := q.QueryRow(ctx, ` SELECT total_amount FROM bookings WHERE id = $1 `, bookingID).Scan(&bookingTotal); err != nil { log.Printf("Failed to calculate booking total for campaign check: %v", err) return } for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) { ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d) } } // buildSplitRecords determines whether to split a single Square charge into // multiple payment records. Before the booking start time, the first 50% of // the total is recorded as 'deposit' (protected under the deposit policy) and // // The first 50% of the booking total (minus any already deposited) is always // carved out as a 'deposit' record, regardless of the payment size. The // remainder first covers the booking balance then overflows into a 'tip' record. // // The primary record carries the Square payment ID for refund routing; split // records share the same SquarePaymentID so the refund loop can avoid duplicate // Square API calls while still creating audit records. // // MONEY INVARIANT (deliberately kept exact): the returned records always // partition paymentAmount — deposit + balance + tip === paymentAmount exactly // (every component is rounded to the cent and the parts are derived from one // another, so no rounding residue exists). The sum of the split records can // therefore never exceed the amount actually charged at Square. When deposit // AND balance are both zero (booking already fully paid) the tip record alone // carries the whole payment — the primary must NOT be appended as well, or the // amount would be recorded twice (see the tip block below). // // Discounts do NOT change this: a discount is applied at payment time as a // SEPARATE ledger payment row (payment_method='discount'), and GetBookingPaymentInfo // excludes those rows from TotalPaid (as do the refund and deposit-threshold // computations). buildSplitRecords therefore runs against the full booking // total and the REAL money already paid, so a discounted booking can at worst // over-allocate toward balance and under-allocate toward tip (a bookkeeping // simplification, not an overcharge) — the partition still equals the charged // amount. See TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge. func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord { // After the booking starts there is no deposit protection window — // record the payment as a single entry with its original type. if clock.Now().After(info.StartTime) { return []PaymentRecord{primary} } // 1. Deposit portion: up to 50% of total, minus what's already been paid. maxDeposit := info.TotalAmount * ProtectedDepositMaxPct remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid) depositAmount := math.Min(paymentAmount, remainingDepositRoom) depositAmount = math.Round(depositAmount*100) / 100 // 2. Remaining after deposit. remainingAfterDeposit := math.Round((paymentAmount-depositAmount)*100) / 100 // 3. Balance portion: covers whatever is still owed on the booking. bookingRemaining := math.Max(0, info.TotalAmount-info.TotalPaid-depositAmount) balancePortion := math.Min(remainingAfterDeposit, bookingRemaining) balancePortion = math.Round(balancePortion*100) / 100 // 4. Tip: anything beyond the booking total. tipPortion := math.Round((remainingAfterDeposit-balancePortion)*100) / 100 var records []PaymentRecord splitIdx := 0 // 1. Deposit portion (always present when there's deposit room left). if depositAmount > 0.004 { dep := primary dep.PaymentType = "deposit" dep.Amount = depositAmount records = append(records, dep) splitIdx++ } // 2. Balance / partial / full record — covers the remaining booking total. if balancePortion > 0.004 { bal := primary bal.Amount = balancePortion bal.Fees = 0 if primary.IdempotencyKey != nil { k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx) bal.IdempotencyKey = &k } totalPaidAfterBalance := info.TotalPaid + depositAmount + balancePortion switch { case totalPaidAfterBalance >= info.TotalAmount && totalPaidAfterBalance-balancePortion > 0: bal.PaymentType = "balance" case totalPaidAfterBalance >= info.TotalAmount: bal.PaymentType = "full" default: bal.PaymentType = "partial" } records = append(records, bal) splitIdx++ } // 3. Tip record — overflow beyond the booking total. Appended BEFORE the // primary fallback below: when BOTH the deposit and balance portions are // zero (deposit room exhausted AND the booking already fully paid — e.g. a // discounted booking whose TotalPaid, which excludes discount rows, has // reached the full total), the tip record carries the ENTIRE payment. // Appending the primary first would double-count the charged amount // (primary at the full amount + tip at the same full amount). if tipPortion > 0.004 { tip := primary tip.PaymentType = "tip" tip.Amount = tipPortion tip.Fees = 0 splitIdx++ if primary.IdempotencyKey != nil { k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx) tip.IdempotencyKey = &k } records = append(records, tip) } // Defensive fallback: nothing was appended (deposit, balance, AND tip all // zero — impossible given paymentAmount is validated > 0 upstream, so this // is a pure safety net). The primary is still a valid single record. if len(records) == 0 { primary.Fees = 0 records = append(records, primary) } return records } func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } service := NewPaymentService() cards, err := service.GetUserPaymentMethods(r.Context(), userID) if err != nil { log.Printf("Failed to get payment methods: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(cards); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } 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) return } service := NewPaymentService() cards, err := service.GetUserPaymentMethods(r.Context(), userID) if err != nil { log.Printf("Failed to get payment methods for user %s: %v", userID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(cards); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { cardID := chi.URLParam(r, "id") if cardID == "" || !validators.IsValidID(cardID) { http.Error(w, "Payment method not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } service := NewPaymentService() err := service.DeletePaymentMethod(r.Context(), cardID, userID) if err != nil { log.Printf("Failed to delete payment method: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } type CreatePaymentMethodRequest struct { CardToken string `json:"card_token" validate:"required"` } func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req CreatePaymentMethodRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.CardToken == "" { http.Error(w, "card_token is required — use a Square Web Payments nonce", http.StatusBadRequest) return } service := NewPaymentService() card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken) if err != nil { if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } log.Printf("Failed to create payment method: %v", err) http.Error(w, "Failed to add card", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(card); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } 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) return } adminID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req RefundRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode refund request: %v", err) http.Error(w, "invalid request", http.StatusBadRequest) return } if err := ValidateAmount(req.Amount); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateRefundReason(req.Reason); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } service := NewPaymentService() payment, err := service.GetPaymentByID(r.Context(), paymentID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Payment not found", http.StatusNotFound) return } log.Printf("Failed to get payment: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if payment.Status != "completed" { http.Error(w, "Can only refund completed payments", http.StatusBadRequest) return } if payment.SquarePaymentID == nil { http.Error(w, "Payment has no Square reference", http.StatusBadRequest) return } // Idempotency key for the refund. When the client supplies one (a UUID // generated per distinct refund attempt and REUSED on retry), the key is // hashed and 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. // // When the client sends NO key, the fallback must be UNIQUE per refund // attempt: the old amount-derived key (paymentID + "-refund-" + amount) // collided on two DISTINCT partial refunds of the same amount, and the // dedup lookup silently swallowed the second. The fallback appends a fresh // crypto/rand hex suffix so distinct same-amount refunds never collide; // a lost-response no-key retry still resumes via the (payment_id, amount) // pending fallback below. 6 random bytes (12 hex chars) keeps the full key // ≤45 chars even for a 9-digit pence amount. idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10) + "-" + randomHexSuffix(6) if 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 // both passing the over-refund guard and both charging Square. Mirrors the // tip/gift-card advisory-lock pattern. Bounded try-lock (R6) so a // contended lock never blocks the pool across the Square round-trip. refundLockKey := paymentID pinConn, err := db.Conn.Acquire(r.Context()) if err != nil { log.Printf("Failed to acquire connection for refund lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:refund:"+refundLockKey) if err != nil { log.Printf("Failed to acquire refund serialization lock for %s: %v", paymentID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Refund serialization lock for %s not acquired within bound — a refund is already in progress", paymentID) http.Error(w, "Refund in progress, try again", http.StatusConflict) return } defer func() { if _, err := pinConn.Exec(context.Background(), ` SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1)) `, refundLockKey); err != nil { log.Printf("Failed to release refund serialization lock for %s: %v", paymentID, err) } }() // Dedup/resume (inside the lock): a same-key retry of a completed or // in-flight (pending) refund must not create a second Square refund. Runs // BEFORE the over-refund guard so a resuming refund never evaluates its own // pending row against the guard. var existingRefundID sql.NullString var existingRefundStatus sql.NullString var existingRefundAmount sql.NullFloat64 var existingRefundOrigin sql.NullString var existingRefundReason sql.NullString var existingRefundCreatedAt sql.NullTime var existingRefundKey sql.NullString err = db.Conn.QueryRow(r.Context(), ` SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1 `, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey) switch { case err == nil && existingRefundStatus.String == "completed": if err := json.NewEncoder(w).Encode(RefundResponse{ ID: existingRefundID.String, PaymentID: paymentID, Amount: req.Amount, Status: "completed", Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return case err == nil && existingRefundStatus.String == "pending": // Resume the in-flight refund: the DB row was committed but the Square // call never completed (network timeout, crash, etc.). Retry Square with // the row's OWN stored idempotency key so Square returns the original // refund if one exists, never a second one. resumeManualPendingRefund(w, r, paymentID, payment, existingRefundID.String, existingRefundAmount.Float64, existingRefundReason.String, existingRefundKey.String) return case err == nil && existingRefundStatus.String == "failed": // A failed row with origin='manual' may actually have moved money at // Square (response loss after a definitive decline). Reconcile FIRST — // an exact-amount COMPLETED refund resolves the row to completed. // Otherwise the reconcile proves money did NOT move, so re-issuing with // the stored key/amount is safe (Square dedups same-key retries). if existingRefundOrigin.String == "manual" { refundSqPaymentID := *payment.SquarePaymentID resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100)) var reconcileTime time.Time if existingRefundCreatedAt.Valid { reconcileTime = existingRefundCreatedAt.Time } sqRefundID, rcErr := reconcileRefundAtSquare(r.Context(), refundSqPaymentID, resumeAmount, reconcileTime) switch { case rcErr != nil: // Reconcile failed — unknown whether Square refunded. Do NOT // re-issue on an unknown state: re-issuing would be safe // against Square's key dedup, but if money already moved the // over-refund guard would lose sight of it. Surface a retry. log.Printf("Failed to reconcile refund %s against Square before re-issue (%v) — not re-issuing, ask the admin to retry", existingRefundID.String, rcErr) http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable) return case sqRefundID != nil: if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, *sqRefundID, existingRefundID.String); upErr != nil { log.Printf("Failed to mark refund %s completed after Square reconcile: %v", existingRefundID.String, upErr) } if err := json.NewEncoder(w).Encode(RefundResponse{ ID: existingRefundID.String, PaymentID: paymentID, Amount: req.Amount, Status: "completed", Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } reissueReq := square.RefundPaymentReq{ PaymentID: refundSqPaymentID, Amount: resumeAmount, IdempotencyKey: refundResumeKey(paymentID, resumeAmount, existingRefundKey.String), Reason: existingRefundReason.String, } 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 = $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) return } if err := json.NewEncoder(w).Encode(RefundResponse{ ID: existingRefundID.String, PaymentID: paymentID, Amount: req.Amount, Status: reissueStatus, Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return case errors.Is(reissueErr, square.ErrRefundAlreadyProcessed): if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil { log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr) } if err := json.NewEncoder(w).Encode(RefundResponse{ ID: existingRefundID.String, PaymentID: paymentID, Amount: req.Amount, Status: "completed", Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return case errors.Is(reissueErr, square.ErrRefundDeclined): if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil { log.Printf("Failed to mark refund %s failed after re-issue rejection: %v", existingRefundID.String, upErr) } log.Printf("Refund %s re-issued with stored key definitively declined by Square: %v", existingRefundID.String, reissueErr) http.Error(w, "Refund failed", http.StatusInternalServerError) return default: // Ambiguous re-issue — put the row back to 'pending' so the // sweep's manual retry pass can re-attempt it. if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'pending' WHERE id = $1`, existingRefundID.String); upErr != nil { log.Printf("Failed to mark refund %s pending after ambiguous re-issue: %v", existingRefundID.String, upErr) } log.Printf("Refund %s re-issue left pending (ambiguous): %v", existingRefundID.String, reissueErr) http.Error(w, "Refund failed", http.StatusInternalServerError) return } } // Previously definitively rejected (non-manual) — a same-key retry cannot // succeed and the UNIQUE key would block re-insertion. Surface the // failure instead of 500-ing on a duplicate. log.Printf("Refund %s was previously marked failed — same-key retry rejected", existingRefundID.String) http.Error(w, "Refund failed", http.StatusInternalServerError) return case err != nil && !errors.Is(err, pgx.ErrNoRows): log.Printf("Failed to check refund idempotency: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Pending-resume fallback: the exact-key lookup missed, but a pending // refund for this (payment, amount) may exist from a prior attempt whose // Square call failed ambiguously. If the admin reopened the refund modal, // the frontend generated a NEW idempotency key, so the exact-key dedup // above cannot find the row. A pending row means the prior attempt's money // state at Square is UNKNOWN — re-issuing with a fresh key would double- // refund once the sweep processes both pending rows. Resume the existing // pending row with ITS OWN stored key instead, never creating a second one // while money state is unknown. Distinct COMPLETED refunds of the same // amount (the P2 equal-partial case) are untouched — they are not pending. var pendingResumeID sql.NullString var pendingResumeAmount sql.NullFloat64 var pendingResumeReason sql.NullString var pendingResumeKey sql.NullString err = db.Conn.QueryRow(r.Context(), ` SELECT id, amount, reason, idempotency_key FROM refunds WHERE payment_id = $1 AND amount = $2 AND status = 'pending' ORDER BY created_at LIMIT 1 `, paymentID, float64(req.Amount)/100.0).Scan(&pendingResumeID, &pendingResumeAmount, &pendingResumeReason, &pendingResumeKey) if err == nil { log.Printf("Refund exact-key lookup missed but found pending row %s for payment %s amount %.2f — resuming with its stored key", pendingResumeID.String, paymentID, pendingResumeAmount.Float64) resumeManualPendingRefund(w, r, paymentID, payment, pendingResumeID.String, pendingResumeAmount.Float64, pendingResumeReason.String, pendingResumeKey.String) return } if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to check pending-refund fallback: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Different-amount pending guard: no pending row matches this amount, but a // pending refund for a DIFFERENT amount may still be in flight. Creating a // second pending row would let the sweep process both (e.g. pending £20, // retry £30 on a £50 payment → £50 moves when the admin intended £30). A // pending row means the payment's money state at Square is unknown, so any // new refund of any amount is unsafe until it resolves. Reject with 409 — // the same policy as the tip-flow amount-mismatch guard. var anyPendingID string err = db.Conn.QueryRow(r.Context(), ` SELECT id FROM refunds WHERE payment_id = $1 AND status = 'pending' LIMIT 1 `, paymentID).Scan(&anyPendingID) if err == nil { log.Printf("Refund %s rejected: payment %s has an in-flight pending refund (row %s) for a different amount — refusing a second pending row", req.IdempotencyKey, paymentID, anyPendingID) http.Error(w, "A refund is already being processed for this payment — please wait for it to complete", http.StatusConflict) return } if !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to check in-flight pending refund: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Over-refund guard (inside the lock so concurrent refunds can't both pass). // GetAlreadyRefundedAmount counts completed AND pending refunds. alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID) if err != nil { log.Printf("Failed to get already refunded amount: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) { http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest) return } // Begin a transaction. Insert the refund record as 'pending' first, commit, // then call Square — so a Square failure leaves a retryable pending refund // (reprocessed by the scheduler in refunds.go). tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction for refund: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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, refundBookingID, float64(req.Amount)/100.0, req.Reason, idempotencyKey, adminID, clock.Now(), ).Scan(&refundID) if err != nil { log.Printf("Failed to create pending refund record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit refund transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } refundReq := square.RefundPaymentReq{ PaymentID: *payment.SquarePaymentID, Amount: req.Amount, IdempotencyKey: idempotencyKey, Reason: req.Reason, } refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq) if err != nil { if errors.Is(err, square.ErrRefundAlreadyProcessed) { // PAYMENT_ALREADY_REFUNDED — money already moved at Square. Resolve // to completed (square_refund_id stays NULL) rather than failed so // the over-refund guard can never issue money on top of it. if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil { log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr) } log.Printf("Refund %s already processed at Square — marked completed", refundID) if err := json.NewEncoder(w).Encode(RefundResponse{ ID: refundID, PaymentID: paymentID, Amount: req.Amount, Status: "completed", Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if errors.Is(err, square.ErrRefundDeclined) { // Definitive rejection (declined / already refunded / invalid // payment) — mark the refund failed so it never retries and never // blocks future refunds. if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil { log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr) } log.Printf("Refund %s definitively declined by Square: %v", refundID, err) http.Error(w, "Refund failed", http.StatusInternalServerError) return } // Ambiguous error — refund record intentionally left as 'pending' for // the scheduler to re-attempt (refunds.go ProcessPendingSquareRefunds). log.Printf("Failed to refund payment (refund %s left pending): %v", refundID, err) http.Error(w, "Refund failed", http.StatusInternalServerError) return } // 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) } if _, upErr := db.Conn.Exec(r.Context(), `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", refundResult.ID, refundID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(RefundResponse{ ID: refundID, PaymentID: paymentID, Amount: req.Amount, Status: status, Reason: req.Reason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // resumeManualPendingRefund retries Square for a pending manual refund using // the row's OWN stored idempotency key (never a fresh one), then resolves the // row. Called from RefundPayment's exact-key dedup and the (payment, amount) // pending fallback. Using the stored key lets Square return the original // refund if the prior attempt actually completed (response loss), so no second // refund can ever be issued for a row whose money state is unknown. // refundResumeKey returns the row's OWN stored idempotency key when present, or // a fresh deterministic fallback when the row predates keyed refunds (legacy // NULL idempotency_key). Square's RefundPayment REQUIRES a non-empty // idempotency key — re-issuing with "" returns a 400 INVALID_REQUEST_ERROR, // which classifies as ambiguous and leaves the refund pending forever. The // fallback mirrors the no-client-key shape (paymentID + "-refund-" + amount + // "-" + hex) and stays ≤45 chars (12 + 8 + up-to-9 + 1 + 12 ≈ 42), so the // re-issue is never rejected for length either. A legit stored key is ALWAYS // reused so Square's same-key dedup keeps returning the original refund. func refundResumeKey(paymentID string, amount int64, storedKey string) string { if storedKey != "" { return storedKey } return paymentID + "-refund-" + strconv.FormatInt(amount, 10) + "-" + randomHexSuffix(6) } func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID string, payment *PaymentRecord, refundID string, refundAmount float64, refundReason, refundKey string) { resumeAmount := int64(math.Round(refundAmount * 100)) resumeReq := square.RefundPaymentReq{ PaymentID: *payment.SquarePaymentID, Amount: resumeAmount, IdempotencyKey: refundResumeKey(paymentID, resumeAmount, refundKey), Reason: refundReason, } resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq) if resumeErr != nil { if errors.Is(resumeErr, square.ErrRefundAlreadyProcessed) { // PAYMENT_ALREADY_REFUNDED — money already moved at Square. // Resolve the pending row to completed (square_refund_id stays // NULL) so the guard can never over-refund on top of it. if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil { log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr) } log.Printf("Refund %s already processed at Square — marked completed", refundID) if err := json.NewEncoder(w).Encode(RefundResponse{ ID: refundID, PaymentID: paymentID, Amount: resumeAmount, Status: "completed", Reason: refundReason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if errors.Is(resumeErr, square.ErrRefundDeclined) { // Definitive rejection — mark failed so it never retries and // never blocks future refunds. if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil { log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr) } log.Printf("Refund %s definitively declined by Square: %v", refundID, resumeErr) http.Error(w, "Refund failed", http.StatusInternalServerError) return } // Ambiguous error — leave pending for the scheduler to retry. log.Printf("Failed to resume refund %s (left pending): %v", refundID, resumeErr) 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 = $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) return } if err := json.NewEncoder(w).Encode(RefundResponse{ ID: refundID, PaymentID: paymentID, Amount: resumeAmount, Status: status, Reason: refundReason, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func CreateTipPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req CreateTipPaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode tip payment request: %v", err) http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateAmount(req.Amount); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := ValidateVerificationToken(req.VerificationToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } service := NewPaymentService() bookingUserID, err := service.GetBookingUserID(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 user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } // Tips are only accepted on active bookings. A cancelled, lapsed, or // no-show booking must not accept tips — money would land on a booking // that can no longer pay out the service. Checked early, before any card // resolution or Square call. bookingStatus, err := service.GetBookingStatus(r.Context(), bookingID) if err != nil { log.Printf("Failed to get booking status: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !bookingStatusAllowsCompletedPayment(bookingStatus) { log.Printf("Tip rejected: booking %s is in status %q (no longer accepting tips)", bookingID, bookingStatus) http.Error(w, "This booking is no longer accepting tips", http.StatusConflict) return } hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID) if err != nil { log.Printf("Failed to check for completed payments: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !hasCompleted { http.Error(w, "Booking must have a completed payment before adding tip", http.StatusBadRequest) return } // Idempotency key: prefer the client-supplied UUID (one per attempt, so // two legitimate identical tips on the same booking don't collapse into // one). Fall back to a unique key when absent — must NOT be derived from // request fields alone (bookingID + amount would dedupe distinct tips). idempotencyKey := req.IdempotencyKey if idempotencyKey == "" { idempotencyKey = uniqueChargeKey("tip-") } // Resolve the card source ID — same pattern as CreateBookingPayment (see // resolveChargeSource for the R6 rationale). var sourceID string var savedCardID *string var savedCardCustomerID string sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found") if !sourceOK { return } // Serialize tip attempts for this booking to prevent concurrent duplicate // tip payments across browser tabs or retries. Uses a PostgreSQL session-level // advisory lock scoped to the booking ID. // Bounded try-lock (R6) so a contended lock never blocks the pool across // the Square round-trip. pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:tip:"+bookingID, "Payment in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:tip:"+bookingID) // Step 1: Insert payment record in 'pending' state inside a DB transaction. // Square is NOT called yet — if the tx fails, no harm done. 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 } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // 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. var existingID sql.NullString var existingBookingID sql.NullString var existingPaymentType sql.NullString var existingStatus sql.NullString var existingAmount sql.NullFloat64 var existingCreatedAt sql.NullTime 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, 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, PaymentType: existingPaymentType.String, Status: existingStatus.String, Amount: int64(math.Round(existingAmount.Float64 * 100)), CreatedAt: existingCreatedAt.Time.Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return 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 (books, VAT, refund caps) or silently charge the new // amount against the old record. Compare in pence via math.Round — the // stored pounds value is float64, so int64(pounds*100) truncation would // reject legitimate same-amount retries for non-exact values (e.g. £1.14 // stored as 1.1399999999999999 → int64 gives 113 ≠ 114). if int64(math.Round(existingAmount.Float64*100)) != req.Amount { log.Printf("Tip 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 tip payment", http.StatusBadRequest) return } 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) } if !reusePendingRecord { record := PaymentRecord{ BookingID: bookingID, PaymentType: "tip", PaymentMethod: "online_square", Status: "pending", Amount: float64(req.Amount) / 100.0, IdempotencyKey: &idempotencyKey, Fees: 0, UserSavedCardID: savedCardID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &userID, } paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } 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 status 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' for manual retry. // Resolve the user's email for Square receipt delivery. var buyerEmail string if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } var verificationToken string if req.VerificationToken != nil { verificationToken = *req.VerificationToken } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, CustomerID: savedCardCustomerID, IdempotencyKey: idempotencyKey, ReferenceID: bookingID, Note: "tip", BuyerEmail: buyerEmail, VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq) if err != nil { log.Printf("Failed to create tip payment: %v", err) // Payment record intentionally left as 'pending' for manual retry. http.Error(w, "Payment failed", chargeFailureStatus(err)) return } // Step 3a: post-charge recheck (R9). A concurrent cancellation/eviction // can move the booking out of a payable state between the pre-charge // status check and the Square charge completing. A tip landing on a // cancelled/lapsed booking must NOT be recorded as completed — the // cancellation refund path computes refunds from completed payments and // would silently exclude it. Mark the tip row failed and alert ops: money // was taken at Square and MUST be refunded manually (mirrors // CreateBookingPayment's post-charge recheck). tipRecheckStatus, tipPayable, err := recheckBookingPayable(r.Context(), db.Conn, bookingID) if err != nil { log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, bookingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !tipPayable { log.Printf("CRITICAL: Square tip payment %s (ID=%s) for booking %s was processed but booking is now %q — marking tip %s failed; money taken at Square MUST be refunded manually", paymentResult.Status, paymentResult.SquarePayID, bookingID, tipRecheckStatus, paymentID) if _, upErr := db.Conn.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil { log.Printf("CRITICAL: Square tip payment %s (ID=%s) landed on %q booking %s but marking tip %s failed errored: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, tipRecheckStatus, bookingID, paymentID, upErr) } http.Error(w, "This booking is no longer accepting tips", http.StatusConflict) return } // Step 3: Square succeeded — update the payment record. _, upErr := db.Conn.Exec(r.Context(), `UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`, paymentResult.SquarePayID, paymentID, ) if upErr != nil { log.Printf("Failed to update payment %s after Square success: %v (square_payment_id=%s)", paymentID, upErr, paymentResult.SquarePayID) // Square charge succeeded but status update failed. // Record stays 'pending' for manual reconciliation. http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(PaymentResponse{ ID: paymentID, BookingID: bookingID, PaymentType: "tip", Status: "completed", Amount: req.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, _ := r.Context().Value(mw.UserIDKey).(string) userRole, _ := r.Context().Value(mw.UserRoleKey).(string) service := NewPaymentService() // Fail closed: a non-admin request must carry a user ID. The previous // `userID != ""` guard silently skipped the ownership check for requests // with no user context, leaking another user's payment summary. if userRole != "admin" { if userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } bookingUserID, err := service.GetBookingUserID(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 user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } } summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID) if err != nil { log.Printf("Failed to get payment summary: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } payments := make([]PaymentResponse, len(summary.Payments)) for i, p := range summary.Payments { payments[i] = PaymentResponse{ ID: p.ID, BookingID: p.BookingID, PaymentType: p.PaymentType, Status: p.Status, Amount: int64(math.Round(p.Amount * 100)), CardLast4: p.CardLast4, CreatedAt: p.CreatedAt.Format(time.RFC3339), } } refunds := make([]RefundResponse, len(summary.Refunds)) for i, rf := range summary.Refunds { refunds[i] = RefundResponse{ ID: rf.ID, PaymentID: rf.PaymentID, Amount: int64(math.Round(rf.Amount * 100)), Status: rf.Status, Reason: rf.Reason, CreatedAt: rf.CreatedAt.Format(time.RFC3339), } } if err := json.NewEncoder(w).Encode(PaymentSummaryResponse{ TotalAmount: int64(math.Round(summary.TotalAmount * 100)), PaidAmount: int64(math.Round(summary.PaidAmount * 100)), RefundedAmount: int64(math.Round(summary.RefundedAmount * 100)), RemainingAmount: int64(math.Round(summary.RemainingAmount * 100)), TotalVATAmount: int64(math.Round(summary.TotalVATAmount * 100)), TotalNetAmount: int64(math.Round(summary.TotalNetAmount * 100)), Payments: payments, Refunds: refunds, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // PaymentLockDuration is the TTL for a payment-in-flight lock in minutes. const PaymentLockDuration = 5 // AcquirePaymentLock creates or extends a 5-minute time_blocker for the // booking's slot so that pending_release eviction is blocked during card // entry and Square charge processing. func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Verify the user owns this booking. var bookingUserID string if err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID, ).Scan(&bookingUserID); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } // Before acquiring the lock, double-check the slot is still available. // For confirmed/in_progress bookings this is a formality; for // pending_release bookings it catches the eviction race before we // create a time_blocker — the NOT EXISTS guard in eviction queries // handles the sub-5-minute race, this catches the >5-minute gap. var currentStatus string var startTime time.Time if err := db.Conn.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1", bookingID, ).Scan(¤tStatus, &startTime); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } // If the booking has been evicted (deposit_lapsed) or reached a terminal // state, reject the lock — payment cannot proceed. if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" { log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus) http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) return } // Upsert the time_blocker atomically: delete old PAYMENT_IN_FLIGHT and insert // a fresh one in a single transaction. Prevents lock loss if INSERT fails. tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction for payment lock: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 `, bookingID); err != nil { log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES (NOW(), $1, $2, $3) `, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil { log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err) http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit payment lock transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]any{ "status": "locked", "ttl_min": PaymentLockDuration, "bookingID": bookingID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking. func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } // Mirror AcquirePaymentLock's ownership check: releasing another user's // PAYMENT_IN_FLIGHT blocker would evict their slot mid-payment. The // booking's own user (or an admin) may release it. userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } userRole, _ := r.Context().Value(mw.UserRoleKey).(string) var bookingUserID string if err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID, ).Scan(&bookingUserID); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } if userRole != "admin" && bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } 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 } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 `, bookingID); err != nil { log.Printf("Failed to release payment lock for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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 } w.WriteHeader(http.StatusNoContent) } // uniqueChargeKey generates a unique idempotency key under the given prefix // (e.g. "tip-", "till-") where the client did not supply one. Client-supplied // keys handle retry dedup; this fallback only needs uniqueness so two // legitimate identical requests never collapse on the same key. Deliberately // NOT derived from request fields — two identical requests would hash to the // same key (the "tip" fallback must not dedupe two distinct equal tips on one // booking). Shared by the tip/till flows, which used to carry two identical // copies (uniqueTipKey/uniqueTillKey) differing only in the prefix string. func uniqueChargeKey(prefix string) string { return prefix + rand.Text() } // randomHexSuffix returns n random bytes hex-encoded (2n hex chars) from // crypto/rand, used to disambiguate idempotency fallback keys that would // otherwise collide on deterministic inputs (e.g. the no-client-key refund // key). Falls back to a masked monotonic timestamp if the OS entropy source // errors — effectively impossible on Linux (crypto/rand.Read blocks until // entropy is available) — keeping the same width so the key stays within // Square's 45-char idempotency-key limit. func randomHexSuffix(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%0*x", 2*n, time.Now().UnixNano()&(int64(1)<<(8*int64(n))-1)) } return fmt.Sprintf("%x", b) }