Files
Crussell/backend/handlers/payments/handlers.go
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
2026-08-22 00:34:49 +01:00

3007 lines
120 KiB
Go

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
}
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 := uniqueTipKey()
// 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
}
card, err := service.GetCardByID(r.Context(), *req.UserSavedCardID, bookingUserID.String)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Saved card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get saved card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Serialize saved-card charges per booking (same lock as online booking
// payments) so concurrent double-clicks can't both pass the idempotency
// check. Mirrors the CreateBookingPayment lock (R4).
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for saved-card lock: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire saved-card serialization lock for %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release saved-card serialization lock for %s: %v", bookingID, err)
}
}()
// Deterministic idempotency key: booking+type+amount+card. A network
// retry with the same inputs derives the same key → dedup, never a
// second charge. ≤45 chars for Square's limit.
scKey := bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
// Idempotency switch inside the lock: completed → dedup; pending →
// reuse (re-attempt Square with the same key, which dedups Square-side);
// failed → clean rejection.
var existingID, existingStatus sql.NullString
var existingAmount sql.NullFloat64
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount FROM payments WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, scKey).Scan(&existingID, &existingStatus, &existingAmount)
paymentID := ""
switch {
case err == nil && existingStatus.String == "completed":
// Dedup — return the existing completed payment.
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingID.String,
Status: "COMPLETED",
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingStatus.String == "pending":
// Reuse the pending record: a prior attempt's Square outcome is
// unknown. Guard the amount — a retry with a different amount must
// not reuse the old record's charge.
if int64(math.Round(existingAmount.Float64*100)) != amount {
log.Printf("Saved-card retry amount mismatch: pending %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), amount)
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
return
}
paymentID = existingID.String
case err == nil && existingStatus.String == "failed":
log.Printf("Saved-card payment %s was previously marked failed (swept) — refusing retry", existingID.String)
http.Error(w, "This payment previously failed and can no longer be retried", http.StatusConflict)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check saved-card idempotency: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Pending-first: insert a pending payment record, commit, then charge.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
record := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
PaymentMethod: "online_square",
Status: "pending",
Amount: float64(amount) / 100.0,
IdempotencyKey: &scKey,
UserSavedCardID: req.UserSavedCardID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
if err := tx.QueryRow(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
log.Printf("Failed to insert pending saved-card payment: %v", err)
_ = tx.Rollback(r.Context())
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit pending saved-card payment: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
var buyerEmail string
if bookingUserID.Valid {
_ = db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, bookingUserID.String).Scan(&buyerEmail)
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), square.CreatePaymentReq{
Amount: amount,
Currency: "GBP",
SourceID: card.SquareCardID,
CustomerID: card.SquareCustomerID,
IdempotencyKey: scKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: buyerEmail,
})
if err != nil {
log.Printf("Failed to process saved-card payment: %v", err)
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
paymentResult.SquarePayID, paymentID,
); upErr != nil {
log.Printf("CRITICAL: Square payment %s succeeded but saved-card payment %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, paymentID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Return the card details the frontend reads for the success state
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
if err := json.NewEncoder(w).Encode(map[string]any{
"checkout_id": paymentID,
"status": "COMPLETED",
"card_brand": paymentResult.CardBrand,
"card_last4": paymentResult.CardLast4,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
// For Square checkout (terminal card reader), validate booking status.
// 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.
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for terminal checkout lock: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire terminal checkout serialization lock for %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release terminal checkout serialization lock for %s: %v", bookingID, err)
}
}()
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
}
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)
http.Error(w, "Failed to create payment", http.StatusInternalServerError)
return
}
// Record the checkout with the payment type the admin charged so
// GetCheckoutStatus records the payment with that type.
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)
`, checkout.ID, bookingID, req.PaymentType, float64(amount)/100.0); err != nil {
log.Printf("Failed to record terminal checkout %s: %v", checkout.ID, err)
// 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 DB insert 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 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:<booking> 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 ""
}
// 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
}
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).
if paymentResult.ReferenceID != "" && paymentResult.ReferenceID != bookingID {
log.Printf("Checkout %s references booking %s, not %s — refusing to record", checkoutID, paymentResult.ReferenceID, bookingID)
http.Error(w, "Checkout does not belong to this booking", http.StatusBadRequest)
return
}
if paymentResult.Status == "COMPLETED" {
service := NewPaymentService()
// 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.
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()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:terminal:' || $1))
`, terminalLockKey); 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
}
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
}
http.Error(w, "Payment failed", http.StatusPaymentRequired)
}
// 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.
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for payment lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire payment serialization lock for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release payment serialization lock for %s: %v", bookingID, err)
}
}()
// 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
if req.NewCardToken != nil && *req.NewCardToken != "" {
// CreateCardOnFile runs before the charge. If the subsequent payment
// fails, this card-on-file is intentionally NOT deleted: the pending
// record's retry re-creates it via the deterministic sha256 idempotency
// key, and Square returns the same card — deleting it would break that
// retry. The orphan is harmless (Square-side only, never charged).
//
// P14: when the card is being SAVED, provision (or reuse) the user's
// Square customer profile BEFORE tokenizing so the new card is created
// against that customer. One-off non-save charges pass "" — a cnon:
// nonce charge needs no customer.
squareCustomerID := ""
if req.SaveCard {
var custErr error
squareCustomerID, custErr = service.EnsureSquareCustomer(r.Context(), userID)
if custErr != nil {
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
}
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken, squareCustomerID)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
sourceID = cardOnFile.CardID
if req.SaveCard {
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil {
log.Printf("Failed to save card: %v", err)
} else {
savedCardID = &cardID
}
}
if savedCardID == nil && req.SaveCard {
log.Printf("Card was not saved despite save_card=true for user %s", userID)
}
} else if req.CardID != nil {
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
sourceID = card.SquareCardID
savedCardID = req.CardID
savedCardCustomerID = card.SquareCustomerID
}
// 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", http.StatusPaymentRequired)
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.
var recheckStatus string
if err := tx2.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&recheckStatus); 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 !bookingStatusAllowsCompletedPayment(recheckStatus) {
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.
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++
}
// If neither deposit nor balance was created (deposit exhausted, booking
// fully paid), the primary is still a valid record — use it directly.
if len(records) == 0 {
primary.Fees = 0
records = append(records, primary)
}
// Tip record — overflow beyond the booking total.
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)
}
// If nothing was appended (shouldn't happen given validation upstream),
// return the primary as a fallback.
if len(records) == 0 {
return []PaymentRecord{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
}
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund. When the client supplies an
// idempotency key (one per distinct refund attempt, reused on retry), use
// it — the amount-derived fallback would collide on two DISTINCT partial
// refunds of the same amount, silently swallowing the second. The client
// key is hashed+truncated: Square's idempotency-key limit is 45 chars, and
// paymentID (12) + "-refund-" (8) + a full 36-char UUID (56 total) would
// be rejected with a 400. The hash stays deterministic, so a same-key
// retry still dedups.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
if req.IdempotencyKey != "" {
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.
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()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
`, refundLockKey); err != nil {
log.Printf("Failed to acquire refund serialization lock for %s: %v", paymentID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
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: 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.
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: 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 = uniqueTipKey()
}
// Resolve the card source ID — same pattern as CreateBookingPayment.
var sourceID string
var savedCardID *string
var savedCardCustomerID string
if req.NewCardToken != nil && *req.NewCardToken != "" {
// CreateCardOnFile runs before the charge. If the subsequent payment
// fails, this card-on-file is intentionally NOT deleted: the pending
// record's retry re-creates it via the deterministic sha256 idempotency
// key, and Square returns the same card — deleting it would break that
// retry. The orphan is harmless (Square-side only, never charged).
//
// P14: when the card is being SAVED, provision (or reuse) the user's
// Square customer profile BEFORE tokenizing so the new card is created
// against that customer. One-off non-save charges pass "" — a cnon:
// nonce charge needs no customer.
squareCustomerID := ""
if req.SaveCard {
var custErr error
squareCustomerID, custErr = service.EnsureSquareCustomer(r.Context(), userID)
if custErr != nil {
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
}
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken, squareCustomerID)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
sourceID = cardOnFile.CardID
if req.SaveCard {
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil {
log.Printf("Failed to save card: %v", err)
} else {
savedCardID = &cardID
}
}
if savedCardID == nil && req.SaveCard {
log.Printf("Card was not saved despite save_card=true for user %s", userID)
}
} else if req.CardID != nil {
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
sourceID = card.SquareCardID
savedCardID = req.CardID
savedCardCustomerID = card.SquareCustomerID
}
// 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.
// See CreateBookingPayment lines 815-846 for the same pattern.
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for tip lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:tip:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire tip serialization lock for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:tip:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release tip serialization lock for %s: %v", bookingID, err)
}
}()
// 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", http.StatusPaymentRequired)
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(&currentStatus, &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)
}
// uniqueTipKey generates a unique idempotency key for tip payments where the
// client did not supply one. Client-supplied UUIDs handle retry dedup; this
// fallback only needs uniqueness so identical tips don't collapse.
func uniqueTipKey() string {
return "tip-" + rand.Text()
}