Files
Crussell/backend/handlers/payments/handlers.go
T
popertots e8abf7b4b3 Refactor payment charge paths into shared helpers; classify Square failures
Extracts resolveChargeSource (new-card vs saved-card vs one-off nonce, with Square customer provisioning) and shared advisory-lock + post-charge recheck helpers into charge_helpers.go. Adds errors.go with chargeFailureStatus: transport/5xx/context and 429/408/425 map to 503 (retryable), structured 4xx declines map to 402, used across all four charge paths. Also fixes the no-client-key refund fallback to append a crypto/rand suffix (distinct same-amount partial refunds no longer collide) and adds refundResumeKey so legacy NULL idempotency_key rows resume with a derived key instead of an empty one.
2026-08-22 00:34:49 +01:00

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