Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds: - F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks - F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back - F3: post-start online overflow carved as a tip record (mirrors terminal split builder) - A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError) - A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications - A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check) - A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected - A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point) - A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface - Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications) - Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments - Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files) - Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status) - gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys) All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
4322 lines
190 KiB
Go
4322 lines
190 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"`
|
|
// idempotency_key: optional client-generated per-attempt UUID for saved-card
|
|
// charges. The frontend generates one per distinct charge and reuses it
|
|
// across retries of the SAME charge, so two DISTINCT identical charges on
|
|
// one booking (e.g. a second £50 'full' charge for a second service) get
|
|
// different keys and never collapse on the deterministic fallback key.
|
|
// When absent, the handler falls back to the deterministic booking+type+
|
|
// amount+card key for no-client-key retry safety. Cap ≤45 (Square's
|
|
// idempotency-key limit for /v2/payments — this key feeds CreatePayment).
|
|
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
|
}
|
|
|
|
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,omitempty" validate:"omitempty,max=45"`
|
|
VerificationToken *string `json:"verification_token,omitempty"`
|
|
// ConfirmOverflowTip acknowledges that an overpayment beyond the booking's
|
|
// remaining balance will be recorded as a tip (M7). Tips cannot be paid in
|
|
// advance, so a pre-start overpayment is rejected with 400
|
|
// overflow_tip_confirmation_required unless the client sets this flag; the
|
|
// frontend prompts and resends with it. Post-start overpayments are always
|
|
// accepted (gratuity for service rendered).
|
|
ConfirmOverflowTip bool `json:"confirm_overflow_tip"`
|
|
}
|
|
|
|
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. Cap ≤45 (Square's /v2/refunds limit).
|
|
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
|
}
|
|
|
|
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" validate:"omitempty,max=45"`
|
|
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"
|
|
}
|
|
|
|
// isVerifiedRole reports whether the authenticated user may save cards
|
|
// (account_role in ('verified_email','admin')). Guests, unverified accounts,
|
|
// and affiliates may still pay but must never persist a card.
|
|
func isVerifiedRole(r *http.Request) bool {
|
|
role, ok := r.Context().Value(mw.UserRoleKey).(string)
|
|
return ok && (role == "verified_email" || role == "admin")
|
|
}
|
|
|
|
// rejectSaveCardForUnverified enforces the save-card product rule at the
|
|
// handler level for charge endpoints. When a charge request carries
|
|
// save_card=true for a non-verified user it responds 403 and returns true so
|
|
// the caller aborts BEFORE resolveChargeSource (where the card would be
|
|
// persisted), the pending payment record insert, or the Square call — failing
|
|
// closed with no side effects. A non-verified user may still pay; only card
|
|
// persistence is blocked. The dedicated save endpoints are additionally
|
|
// protected by mw.RequireVerified middleware (main.go).
|
|
func rejectSaveCardForUnverified(w http.ResponseWriter, r *http.Request, saveCard bool) bool {
|
|
if saveCard && !isVerifiedRole(r) {
|
|
http.Error(w, "Only verified accounts can save a card", http.StatusForbidden)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// C2: the struct validation above checks req.Amount only, and the override
|
|
// substitution happens after. A negative override would flip the gift-card
|
|
// balance deduction into a credit (money minting) and a zero override would
|
|
// record a free payment, so the EFFECTIVE amount must be validated here.
|
|
if err := ValidateAmount(amount); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid override amount: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 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. There is deliberately NO idempotency
|
|
// dedup check here — each request inserts its own row, and two identical
|
|
// cash receipts are legitimate distinct payments.
|
|
idempotencyKey := uniqueChargeKey("till-")
|
|
|
|
// 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. FOR UPDATE (C5): the
|
|
// payment insert below commits in this same transaction, so a
|
|
// concurrent cancellation (which takes the same row lock) must not be
|
|
// able to commit a cancelled status between this read and the commit —
|
|
// otherwise the payment would land on a cancelled booking with no
|
|
// refund ever generated.
|
|
var status string
|
|
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, 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
|
|
}
|
|
|
|
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
|
|
// giftCardPaymentID records the source of funds on the payment row:
|
|
// the gift_card_id for a direct card redemption, or nil when the
|
|
// payment came from the user's account balance (usedBalance). The
|
|
// cancellation refund loop reads this column to know where to
|
|
// credit money back (C3).
|
|
var giftCardPaymentID *string
|
|
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. A payment is a
|
|
// "use" per the rolling-expiry terms — reset the timer.
|
|
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
|
|
if expiryErr != nil {
|
|
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
|
gcExpiryMonths = defaultGiftCardExpiryMonths
|
|
}
|
|
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2", amountPounds, cleanCardID, gcExpiryMonths)
|
|
if err != nil {
|
|
log.Printf("Failed to deduct gift card amount: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
giftCardPaymentID = &cleanCardID
|
|
}
|
|
|
|
err = tx.QueryRow(r.Context(), `
|
|
INSERT INTO payments (
|
|
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id
|
|
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW(), $6)
|
|
RETURNING id
|
|
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID, giftCardPaymentID).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
|
|
}
|
|
// 2FA gating (C5): charging the customer's SAVED card requires 2FA when
|
|
// the feature is enforced. Gate on the card's owner — the booking's
|
|
// user, not the admin. New-card/terminal paths are not gated.
|
|
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String) {
|
|
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)
|
|
|
|
// Idempotency key — two tiers:
|
|
// 1. Client-supplied per-attempt UUID (preferred): the frontend
|
|
// generates one per DISTINCT charge and reuses it across retries of
|
|
// the same charge. Two distinct identical charges on one booking
|
|
// (e.g. a second £50 'full' charge for a second service) send
|
|
// different UUIDs → no dedup, each becomes its own payment. The
|
|
// UUID is globally unique so it is NOT namespaced with the booking
|
|
// id (the UNIQUE(idempotency_key) constraint is global); the dedup
|
|
// SELECT matches on booking_id + key, so a same-booking retry of
|
|
// the same UUID still dedups.
|
|
// 2. Deterministic booking+type+amount+card fallback when the client
|
|
// sends no key: a no-key network retry derives the same key → dedup,
|
|
// never a second charge (old-client retry safety).
|
|
// Both stay ≤45 chars for Square's limit (36-char UUID / ~38-char
|
|
// deterministic key).
|
|
scKey := req.IdempotencyKey
|
|
if scKey == "" {
|
|
// The candidate is built verbatim, then routed through
|
|
// truncateIdempotencyKey so it can never exceed Square's 45-char
|
|
// /v2/payments limit (a 400 would strand the payment). The truncation
|
|
// is deterministic, so identical inputs still derive the SAME key and
|
|
// the dedup SELECT below keeps working; candidates at or under 45
|
|
// chars (the current bookingID+cardID shape) pass through byte-identical.
|
|
scKey = truncateIdempotencyKey("sc", 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. First RE-VALIDATE
|
|
// the matched row's refund state (same guard as the CreateBookingPayment
|
|
// completed-dedup branches): a refunded payment's money is no longer
|
|
// live, so reporting it as "success" would let a same-key retry claim
|
|
// a payment that was already returned to the customer.
|
|
if refunded, rErr := paymentHasLiveRefund(r.Context(), db.Conn, existingID.String); rErr != nil {
|
|
log.Printf("Failed to re-validate saved-card dedup hit %s against refunds: %v", existingID.String, rErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
} else if refunded {
|
|
log.Printf("Payment retry rejected: saved-card payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, scKey)
|
|
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
|
return
|
|
}
|
|
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,
|
|
SquareSourceID: &sourceID,
|
|
CreatedAt: clock.Now(),
|
|
UpdatedAt: clock.Now(),
|
|
CreatedBy: &adminID,
|
|
}
|
|
// M4: the service's record function sets every column the inline
|
|
// INSERT previously left to defaults (fees, VAT fields, etc.), so
|
|
// the pending row is created the same way every other flow creates
|
|
// its payment records.
|
|
var insertErr error
|
|
paymentID, insertErr = service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
|
|
if insertErr != nil {
|
|
log.Printf("Failed to insert pending saved-card payment: %v", insertErr)
|
|
_ = tx.Rollback(r.Context())
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
// Reused pending row: same immutability rule as the booking/tip
|
|
// reuse paths. square_source_id is refreshed ONLY for snapshot-less
|
|
// legacy rows; when the row already carries the original
|
|
// square_request_snapshot it is left untouched so the sweep's
|
|
// by-key replay keeps matching the FIRST attempt's body. Saved-card
|
|
// ccof sources are stable, so this is mostly latent, but keeping
|
|
// the snapshot immutable is money-safe (see the booking reuse
|
|
// comment above).
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
|
|
log.Printf("Failed to update square_source_id on reused saved-card payment %s: %v", paymentID, srcErr)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
paymentReq := square.CreatePaymentReq{
|
|
Amount: amount,
|
|
Currency: "GBP",
|
|
SourceID: sourceID,
|
|
CustomerID: savedCardCustomerID,
|
|
IdempotencyKey: scKey,
|
|
ReferenceID: bookingID,
|
|
Note: req.PaymentType,
|
|
BuyerEmail: buyerEmail,
|
|
// C3: a saved-card (ccof) charge is customer-initiated — Square
|
|
// requires customer_details on stored-credential payments, and
|
|
// omitting it can fail or silently misclassify the charge.
|
|
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
|
}
|
|
// M1: store the verbatim request JSON so the sweep can replay the charge
|
|
// with an IDENTICAL body under the same key — Square compares the whole
|
|
// request on key reuse, and a reconstructed body returns
|
|
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot
|
|
// is written ONLY when the row has none: it records the FIRST attempt's
|
|
// body, which stays immutable so a reused pending row never redirects
|
|
// the sweep's replay away from the original charge (same rule as the
|
|
// booking/tip paths — see the reuse branch above).
|
|
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
|
log.Printf("Failed to marshal square_request_snapshot for saved-card payment %s: %v", paymentID, mErr)
|
|
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
|
log.Printf("Failed to encrypt square_request_snapshot for saved-card payment %s: %v", paymentID, eErr)
|
|
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
|
|
log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr)
|
|
}
|
|
|
|
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
|
if err != nil {
|
|
log.Printf("Failed to process saved-card payment: %v (error_code=%q)", err, square.ErrorCode(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.
|
|
//
|
|
// The recheck and the status write run in ONE transaction so the
|
|
// FOR UPDATE row lock taken inside recheckBookingPayable persists to
|
|
// commit (C5) — a concurrent cancellation cannot commit a cancelled
|
|
// status between the recheck and the payments UPDATE.
|
|
recheckTx, reTxErr := db.Conn.Begin(r.Context())
|
|
if reTxErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s was processed for booking %s but opening the post-charge recheck transaction failed: %v — manual reconciliation required",
|
|
paymentResult.SquarePayID, bookingID, reTxErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := recheckTx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback post-charge recheck transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
recheckStatus, payable, err := recheckBookingPayable(r.Context(), recheckTx, 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 := recheckTx.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)
|
|
}
|
|
if cErr := recheckTx.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the 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
|
|
}
|
|
|
|
if _, upErr := recheckTx.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
|
|
}
|
|
if cErr := recheckTx.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s succeeded but committing the post-charge status update for payment %s failed: %v — manual reconciliation required",
|
|
paymentResult.SquarePayID, paymentID, cErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// F6: a fully-paid saved-card charge completes the booking exactly like
|
|
// the terminal path (recordTerminalPaymentTx → completeFullyPaidBooking,
|
|
// sweep.go:1632). Runs in its OWN transaction after the status commit
|
|
// above, so the completion side-effects (loyalty, campaign discounts,
|
|
// deposits_required) are atomic and a booking paid in full by a
|
|
// saved-card charge leaves the admin's Current Appointment view. The
|
|
// eligible-discount application happens inside the completion
|
|
// side-effects, guarded by the same over-credit cap as every other path.
|
|
completeFullyPaidBooking(r.Context(), bookingID)
|
|
|
|
// 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{
|
|
"payment_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
|
|
}
|
|
|
|
// Attach the booking customer's Square customer id (if any) so the terminal
|
|
// checkout is associated with their Square profile. Read-only lookup
|
|
// mirroring ensureSquareCustomer's read, but NEVER provisioning — a
|
|
// terminal checkout also serves walk-ins, and minting a customer profile
|
|
// for a terminal tap would create an unowned customer. No id → empty.
|
|
var checkoutCustomerID string
|
|
var bookingUserID sql.NullString
|
|
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err == nil && bookingUserID.Valid {
|
|
_ = db.Conn.QueryRow(r.Context(), `
|
|
SELECT square_customer_id FROM user_saved_cards
|
|
WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> ''
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, bookingUserID.String).Scan(&checkoutCustomerID)
|
|
}
|
|
|
|
checkoutReq := square.CreateCheckoutReq{
|
|
Amount: amount,
|
|
Currency: "GBP",
|
|
IdempotencyKey: idempotencyKey,
|
|
ReferenceID: bookingID,
|
|
// The tip (if any) is already embedded in `amount` by the frontend
|
|
// (totalWithTip), so the terminal must NOT prompt for a second tip —
|
|
// setting AllowTipping here would double-count the tip in production.
|
|
AllowTipping: false,
|
|
CustomerID: checkoutCustomerID,
|
|
}
|
|
|
|
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). It is no longer PROVABLY not live (H4): a
|
|
// hard crash between the terminal_checkouts insert and the provisional→real
|
|
// UPDATE leaves a LIVE checkout at Square (created under the idempotency
|
|
// key embedded in the tmp id) while the row still carries the synthetic id.
|
|
// Resolving it to failed unconditionally would let a lost-response retry
|
|
// create a SECOND live checkout (C2) while C1 can still complete at the
|
|
// terminal into an untracked charge. Query Square first to disambiguate.
|
|
if checkoutID == "" || strings.HasPrefix(checkoutID, "tmp-") {
|
|
if checkoutID != "" {
|
|
result, err := SquareClient.GetCheckout(ctx, checkoutID)
|
|
switch {
|
|
case err == nil && result.Status == "COMPLETED":
|
|
// C1 actually completed at the terminal. The payment MUST be
|
|
// recorded now — the old code only marked the row COMPLETED and
|
|
// relied on GetCheckoutStatus (the poll path) to record it, but a
|
|
// checkout that is never polled (abandoned booking / lost poll)
|
|
// would leave the charge permanently untracked:
|
|
// SweepStaleTerminalCheckouts only re-examines PENDING/IN_PROGRESS
|
|
// rows, so a row already marked COMPLETED here is never revisited
|
|
// and the money stays unrecorded and unrefundable via the app.
|
|
// Mirror the sweep's recordUntrackedTerminalPayment: the same
|
|
// crussell:terminal:<squarePayID> advisory lock (serializes
|
|
// against a concurrent poll), the same dedup by booking_id +
|
|
// square_payment_id, the same PaymentRecord shape, the same
|
|
// deposit/balance/tip split, and the same fully-paid completion.
|
|
recorded := recordUntrackedTerminalPayment(ctx, checkoutID, bookingID, result)
|
|
if recorded {
|
|
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — payment recorded", checkoutID, bookingID)
|
|
return ""
|
|
}
|
|
// Recording failed (transient DB/lock contention) — keep the
|
|
// in-flight guard UP so a second live checkout is never created
|
|
// while the charge is unrecorded. The row stays PENDING, so the
|
|
// stale-terminal sweep re-runs recordUntrackedTerminalPayment on
|
|
// it; once recorded, this guard releases on the next attempt.
|
|
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square but payment recording failed — keeping it in flight; no second checkout until the charge is recorded", checkoutID, bookingID)
|
|
return checkoutID
|
|
case errors.Is(err, square.ErrCheckoutPending):
|
|
// C1 is still live at Square — reuse it instead of creating C2.
|
|
log.Printf("Provisional terminal checkout %s for booking %s is live at Square — reusing it", checkoutID, bookingID)
|
|
return checkoutID
|
|
case isTerminalCheckoutError(err):
|
|
// NOT_FOUND (no checkout was ever created — the crash happened
|
|
// before the Square call) or CANCELED — safe to resolve failed
|
|
// and create a fresh checkout.
|
|
log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, 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 provisional terminal checkout %s failed: %v", checkoutID, upErr)
|
|
}
|
|
return ""
|
|
default:
|
|
// Ambiguous error — the checkout's money state at Square is
|
|
// unknown. Keep it in flight rather than spawning a second live
|
|
// checkout.
|
|
return checkoutID
|
|
}
|
|
}
|
|
log.Printf("Legacy empty checkout_id row for booking %s resolved as failed — no live checkout at Square", bookingID)
|
|
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" {
|
|
// 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 releasePaymentLock(pinConn, "crussell:terminal:"+terminalLockKey)
|
|
|
|
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
|
|
// payment insert. The shared money-recording core
|
|
// (recordTerminalPaymentTx, sweep.go) runs inside this transaction,
|
|
// commits it, and completes a now-fully-paid booking; this handler
|
|
// maps the result to the HTTP response.
|
|
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)
|
|
}
|
|
}()
|
|
|
|
paymentID, recErr := recordTerminalPaymentTx(r.Context(), tx, checkoutID, bookingID, paymentResult)
|
|
if recErr != nil {
|
|
if errors.Is(recErr, errTerminalBookingNotPayable) {
|
|
// The core already committed the checkout's 'failed' mark:
|
|
// money was taken at Square on a booking that is no longer
|
|
// payable and MUST be refunded manually.
|
|
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
// Product rule (security): only verified accounts may save cards. An
|
|
// unverified/guest/affiliate user may still pay, but save_card=true is
|
|
// rejected here — before any charge source resolution or payment record.
|
|
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
|
|
return
|
|
}
|
|
|
|
service := NewPaymentService()
|
|
|
|
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
|
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID) {
|
|
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
|
|
}
|
|
|
|
// A3: tips have a dedicated endpoint (POST /api/bookings/{id}/tip,
|
|
// CreateTipPayment) which enforces the M4 "tips only after the service
|
|
// starts" gate. A 'tip' payment_type on the booking payment endpoint would
|
|
// bypass that gate — the overflow/tip guard below explicitly skips tip-type
|
|
// requests and buildSplitRecords would carve the charge as a deposit or
|
|
// balance (or silently overflow into a tip record) — so it is rejected
|
|
// outright here, before any charge source resolution or payment record.
|
|
if req.PaymentType == "tip" {
|
|
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
|
|
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", 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
|
|
}
|
|
|
|
// M1: when the client sends NO idempotency key, a DETERMINISTIC fallback
|
|
// is derived (booking_id + payment_type + amount + card_id) so a
|
|
// lost-response no-key retry reuses the same key instead of minting a
|
|
// second charge. The derivation deliberately runs INSIDE the transaction
|
|
// under the per-booking advisory lock (below): the helper advances a
|
|
// sequence past "spent" key slots, and that scan must not race a
|
|
// concurrent same-booking charge. See deriveBookingPaymentIdempotencyKey
|
|
// for how the fallback distinguishes "same live operation retried" (dedup)
|
|
// from "new operation that happens to have equal amount" (new charge).
|
|
if req.PaymentType == "partial" {
|
|
remainingPence, err := service.GetBookingRemainingBalancePence(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, remainingPence); 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)
|
|
}
|
|
}()
|
|
|
|
// M1: derive the deterministic no-client-key fallback INSIDE the
|
|
// transaction under the advisory lock so the spent-slot scan below races
|
|
// no concurrent charge (two equal partials must get distinct keys even
|
|
// when they arrive back-to-back). The scan itself does the idempotency
|
|
// re-validation: a completed row that has been refunded never blocks a new
|
|
// equal-amount charge, while an un-refunded completed row keeps its key so
|
|
// the dedup lookup below returns it (double-charge protection).
|
|
if req.IdempotencyKey == "" {
|
|
cardPart := "new"
|
|
if req.CardID != nil && *req.CardID != "" {
|
|
cardPart = *req.CardID
|
|
}
|
|
key, keyErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart)
|
|
if keyErr != nil {
|
|
log.Printf("Failed to derive deterministic idempotency key for booking %s: %v", bookingID, keyErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.IdempotencyKey = key
|
|
}
|
|
|
|
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) {
|
|
// A fully-paid booking auto-completes ('completed') and can no longer
|
|
// accept new payments. But a same-key retry of a payment that already
|
|
// went through must still dedup to the existing completed row —
|
|
// otherwise a client retrying after a lost response gets a 409 even
|
|
// though the charge succeeded. Any other payment attempt on a
|
|
// completed booking falls through and is rejected below.
|
|
if status == "completed" {
|
|
var existingID sql.NullString
|
|
var existingBookingID sql.NullString
|
|
var existingPaymentType sql.NullString
|
|
var existingStatus sql.NullString
|
|
var existingAmount sql.NullFloat64
|
|
var existingCreatedAt sql.NullTime
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT id, booking_id, payment_type, status, amount, created_at
|
|
FROM payments
|
|
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
|
|
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
|
|
// A12: re-validate the matched row's refund state exactly like
|
|
// the general dedup path below. A refunded payment's money is
|
|
// no longer live, so reporting it as success here would let a
|
|
// same-key retry claim a payment that was already returned to
|
|
// the customer (money collected for the booking was refunded,
|
|
// yet the retry shows paid).
|
|
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
|
|
log.Printf("Failed to re-validate completed-booking dedup hit %s against refunds: %v", existingID.String, rErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
} else if refunded {
|
|
log.Printf("Payment retry rejected: completed-booking payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
|
|
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// Authoritative remaining-balance re-check for 'partial' payments, inside
|
|
// the advisory lock. The cheap pre-lock ValidatePartialAmount above can
|
|
// race a concurrent partial payment on the same booking: both pass against
|
|
// the same remaining balance, then both charge at Square, and the overflow
|
|
// is silently recorded as a tip by buildSplitRecords. The lock serializes
|
|
// payment attempts, so by the time we re-read here a competing payment has
|
|
// already committed — reject before any pending record is inserted or
|
|
// Square is hit.
|
|
if req.PaymentType == "partial" {
|
|
remainingPence, err := service.GetBookingRemainingBalancePence(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, remainingPence); err != nil {
|
|
log.Printf("Payment rejected: %v", err)
|
|
http.Error(w, "Partial amount exceeds remaining balance", 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. First
|
|
// RE-VALIDATE the matched row's state: a refunded completed payment's
|
|
// money is no longer live, so returning it as "success" would silently
|
|
// swallow a new equal-amount charge (the booking shows paid with no
|
|
// money collected). The no-client-key deterministic path already
|
|
// rotates the key past refunded rows (deriveBookingPaymentIdempotencyKey),
|
|
// so this guard primarily covers client-keyed retries and is
|
|
// defense-in-depth for the deterministic path.
|
|
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
|
|
log.Printf("Failed to re-validate dedup hit %s against refunds: %v", existingID.String, rErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
} else if refunded {
|
|
log.Printf("Payment retry rejected: payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
|
|
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// A4: compute the campaign credit this payment will receive. Running the
|
|
// read-only ComputeEligibleDiscounts here (before the pending insert,
|
|
// under the advisory lock) returns exactly the discounts
|
|
// applyEligibleCampaignsAtPayment will create for the booking inside the
|
|
// post-charge transaction — no completed payment or booking_discounts row
|
|
// exists yet, so both runs see the same state. The credit is used below to
|
|
// (a) keep the overflow→tip guard honest about what the customer actually
|
|
// owes and (b) reduce the amount charged for a deposit payment, which the
|
|
// frontend always sends RAW (no client-side discount).
|
|
var bookingTotal float64
|
|
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
|
|
log.Printf("Failed to load booking total for discount computation: %v", err)
|
|
}
|
|
var eligibleDiscountPence int64
|
|
for _, d := range ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal) {
|
|
eligibleDiscountPence += int64(math.Round(d.Amount * 100))
|
|
}
|
|
|
|
// M4/M7: cap pay-early at 100%. A payment that exceeds the booking's
|
|
// remaining balance overflows into a tip record via buildSplitRecords, but a
|
|
// tip is gratuity for service already rendered — an unconfirmed pre-start
|
|
// overpayment is therefore rejected instead of silently becoming a tip.
|
|
// Post-start overpayments proceed (gratuity is legitimate once the service
|
|
// has started), and a pre-start overpayment with ConfirmOverflowTip set
|
|
// proceeds after the frontend's explicit confirmation prompt. Placed AFTER
|
|
// the idempotency dedup: a same-key retry of an already-completed payment
|
|
// short-circuits above and must not hit this guard (the booking is fully
|
|
// paid by then). 'tip'-type requests are excluded — tips are charged via
|
|
// CreateTipPayment (which enforces its own start-time gate). The overflow
|
|
// comparison uses the DISCOUNTED remaining (raw remaining + this payment's
|
|
// campaign credit): a payment that exceeds the raw remaining but stays
|
|
// within the discounted remaining is covered by the discount — it is NOT an
|
|
// overflow into tip territory.
|
|
if req.PaymentType != "tip" {
|
|
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get remaining balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
discountedRemainingPence := remainingPence + eligibleDiscountPence
|
|
if req.Amount > discountedRemainingPence {
|
|
var bookingStartTime time.Time
|
|
if sErr := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); sErr != nil {
|
|
log.Printf("Failed to get booking start time: %v", sErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !req.ConfirmOverflowTip && !bookingStartTime.Before(clock.Now()) {
|
|
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingPence, bookingID)
|
|
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "The extra amount will be recorded as a tip. Confirm to continue.",
|
|
"code": "overflow_tip_confirmation_required",
|
|
})
|
|
return
|
|
}
|
|
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingPence, bookingID, req.ConfirmOverflowTip)
|
|
}
|
|
}
|
|
|
|
// A4: the amount actually charged at Square. The frontend's full/balance
|
|
// payments already subtract the campaign credit client-side (handlePayFull
|
|
// sends amount_due minus the discount preview), so re-subtracting here
|
|
// would double-discount those — and the full discounted payment must keep
|
|
// the full record amount so bookingIsFullyPaid (real money + discount
|
|
// row == booking total) still completes. A deposit payment, however, is
|
|
// charged RAW by the frontend (handlePayDeposit sends the deposit amount
|
|
// with no discount), so the campaign credit is applied to the deposit
|
|
// charge here: the deposit is charged at req.Amount minus the discount and
|
|
// the residual balance payment settles the rest, so the total across the
|
|
// deposit→balance flow is the discounted price.
|
|
//
|
|
// ADMIN-FLOW ASYMMETRY (F1): the admin "Take Payment" PaymentModal sends
|
|
// payment_type='full' with the FULL amount (subtotal minus discounts
|
|
// ALREADY applied, no client-side campaign preview) — it does NOT
|
|
// pre-subtract an eligible campaign. That means a full admin charge is NOT
|
|
// reduced below, and applyEligibleCampaignsAtPayment would auto-apply the
|
|
// campaign → ledger £55 vs £50 total, orphaned £5 credit. The frontend
|
|
// PaymentModal MUST therefore send the discounted amount exactly like the
|
|
// customer modal (amount_due minus the eligible-campaign preview) so the
|
|
// full ledger reconciles to the booking total; the server-side over-credit
|
|
// guard (capDiscountToRemainingObligation in completion.go) protects
|
|
// against a client that does not.
|
|
chargeAmount := req.Amount
|
|
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
|
|
chargeAmount = req.Amount - eligibleDiscountPence
|
|
// A6: when the eligible discount is >= the deposit itself, chargeAmount
|
|
// clamps UP to the full (undiscounted) deposit. The customer still pays
|
|
// the full deposit up front — the discount credit applies to the
|
|
// residual balance via the discount row created by
|
|
// applyEligibleCampaignsAtPayment (which runs regardless of
|
|
// chargeAmount), so no discount is ever lost and the ledger can never
|
|
// charge a negative amount.
|
|
if chargeAmount <= 0 {
|
|
chargeAmount = req.Amount
|
|
}
|
|
}
|
|
|
|
var sourceID string
|
|
var savedCardID *string
|
|
var savedCardCustomerID string
|
|
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
|
// enforced. New-card (nonce) charges are not gated.
|
|
if req.CardID != nil && *req.CardID != "" {
|
|
if !requireTwoFactorForCardAccess(w, r, service, userID) {
|
|
return
|
|
}
|
|
}
|
|
// 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,
|
|
SquareSourceID: &sourceID,
|
|
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)
|
|
} else {
|
|
// Reused pending row. The stored square_request_snapshot is the FIRST
|
|
// attempt's charge body and MUST remain immutable across nonce-changing
|
|
// retries: if that original charge actually landed at Square (the row
|
|
// is pending only because the post-charge outcome is unknown), the
|
|
// by-key sweep replay must match the original body so Square's
|
|
// idempotency dedup returns the landed payment and the sweep rescues
|
|
// the row. Overwriting the snapshot's SourceID with this retry's fresh
|
|
// nonce — or refreshing the square_source_id column the sweep overrides
|
|
// the replay source with — would make the sweep replay the NEW source,
|
|
// Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge
|
|
// would never be rescued (stranded until the 24h blind-fail). A retry
|
|
// that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the
|
|
// sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the
|
|
// row's true state from the immutable first-attempt body instead. The
|
|
// column is refreshed ONLY for snapshot-less legacy rows, whose
|
|
// fallback replay body is rebuilt from it (and which get a fresh
|
|
// snapshot from the post-commit write below).
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
|
|
log.Printf("Failed to update square_source_id on reused payment %s: %v", paymentID, srcErr)
|
|
}
|
|
}
|
|
|
|
// 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: chargeAmount,
|
|
Currency: "GBP",
|
|
SourceID: sourceID,
|
|
CustomerID: savedCardCustomerID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
ReferenceID: bookingID,
|
|
Note: req.PaymentType,
|
|
BuyerEmail: bookingBuyerEmail,
|
|
VerificationToken: verificationToken,
|
|
// C3: every online charge here is cardholder-initiated — a saved-card
|
|
// (ccof) source MUST carry customer_details for Square's stored-
|
|
// credential rules, and a new-card (cnon) nonce is entered by the
|
|
// buyer present at the keyboard, so the flag is true either way.
|
|
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
|
}
|
|
|
|
// M1: store the verbatim request JSON so the sweep can replay the charge
|
|
// with an IDENTICAL body under the same key — Square compares the whole
|
|
// request on key reuse, and a reconstructed body returns
|
|
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is
|
|
// written ONLY when the row has none: it records the FIRST attempt's body,
|
|
// which stays immutable so a nonce-changing retry can never redirect the
|
|
// sweep's replay away from the original charge (see the reuse branch above).
|
|
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
|
log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr)
|
|
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
|
log.Printf("Failed to encrypt square_request_snapshot for payment %s: %v", paymentID, eErr)
|
|
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
|
|
log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr)
|
|
}
|
|
|
|
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
|
if err != nil {
|
|
log.Printf("Failed to create payment: %v (error_code=%q)", err, square.ErrorCode(err))
|
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
|
return
|
|
}
|
|
|
|
paymentAmount := float64(chargeAmount) / 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
|
|
}
|
|
|
|
// Apply eligible campaign discounts BEFORE the split records are inserted
|
|
// (C1): ComputeEligibleDiscounts refuses to apply NEW discounts once a
|
|
// booking has 2+ completed real payments, and the split below would
|
|
// otherwise count deposit + balance as exactly those 2 payments — a full
|
|
// discounted payment would never get its discount row and the booking
|
|
// would never auto-complete. Running the discount first means the guard
|
|
// only sees payments that existed before this transaction, so the
|
|
// discounted total is applied and bookingIsFullyPaid (which counts
|
|
// discount rows) completes the booking. The call is idempotent: discounts
|
|
// already recorded for the booking are skipped by the duplicate check.
|
|
applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID)
|
|
|
|
// 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(chargeAmount, "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(), fmt.Sprintf(`
|
|
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 * %f)
|
|
FROM booking_total bt, paid_total pt
|
|
`, depositPromotionMinPct), 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)
|
|
}
|
|
}
|
|
|
|
// Fully-paid completion: if total paid (excluding tips/discounts) now
|
|
// covers 100% of the booking total, transition an active booking to
|
|
// 'completed' so it leaves the admin's Current Appointment view. The
|
|
// completion side-effects (loyalty, campaigns, deposits_required) are the
|
|
// same as the admin progress endpoint.
|
|
if bookingIsFullyPaid(r.Context(), tx2, bookingID) {
|
|
completeActiveBookingFromPayment(r.Context(), tx2, bookingID)
|
|
}
|
|
|
|
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: chargeAmount,
|
|
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) {
|
|
// F1: never over-credit. This runs inside the post-charge transaction
|
|
// BEFORE the charge's split records are written, so the paid ledger
|
|
// visible here is real money + discounts already on the booking plus
|
|
// the in-flight charge (the pending row read inside
|
|
// discountHeadroomPence). Capping each discount to the uncovered
|
|
// obligation keeps the admin "Take Payment" full-amount flow from
|
|
// creating an orphaned credit when a campaign is eligible: the correct
|
|
// fix is the frontend PaymentModal sending the discounted amount (like
|
|
// the customer modal does); this guard is the server-side money-safety
|
|
// half.
|
|
capped, ok := capDiscountToRemainingObligation(ctx, q, bookingID, d.Amount)
|
|
if !ok {
|
|
log.Printf("Skipping %s discount %s for booking %s — booking obligation already covered by real money (would over-credit)", d.Source, d.SourceID, bookingID)
|
|
continue
|
|
}
|
|
d.Amount = capped
|
|
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, but an
|
|
// overpayment beyond the remaining booking value is still gratuity and must
|
|
// be carved out as its own payment_type='tip' record (F3) — mirroring
|
|
// buildTerminalSplitRecords' post-start carve. A post-start charge AT OR
|
|
// BELOW the remaining value records as a single entry with its original
|
|
// type, exactly as before; only the overflow becomes a tip. The tip record
|
|
// zeroes fees and derives a -split-tip idempotency key, and the records
|
|
// still partition paymentAmount exactly (booking portion + tip).
|
|
if clock.Now().After(info.StartTime) {
|
|
remaining := math.Max(0, info.TotalAmount-info.TotalPaid)
|
|
bookingPortion := math.Min(paymentAmount, remaining)
|
|
bookingPortion = math.Round(bookingPortion*100) / 100
|
|
tipPortion := math.Round((paymentAmount-bookingPortion)*100) / 100
|
|
if tipPortion > 0.004 {
|
|
records := []PaymentRecord{primary}
|
|
records[0].Amount = bookingPortion
|
|
tip := primary
|
|
tip.PaymentType = "tip"
|
|
tip.Amount = tipPortion
|
|
tip.Fees = 0
|
|
if primary.IdempotencyKey != nil {
|
|
k := *primary.IdempotencyKey + "-split-tip"
|
|
tip.IdempotencyKey = &k
|
|
}
|
|
return append(records, tip)
|
|
}
|
|
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
|
|
}
|
|
|
|
// buildTerminalSplitRecords splits a completed terminal checkout charge that
|
|
// included a tip into deposit + balance + tip payment records. Square charges
|
|
// a single amount (booking portion + tip); the booking portion is split like
|
|
// buildSplitRecords — deposit up to 50% of the booking total (minus already
|
|
// deposited), balance covering the rest — and the tip becomes its own
|
|
// payment_type='tip' record with a derived -split-tip idempotency key. Unlike
|
|
// buildSplitRecords this always carves the deposit/balance split (the terminal
|
|
// flow runs after the booking started, where buildSplitRecords' start-time
|
|
// short-circuit would collapse everything into one record): only deposit +
|
|
// balance are refundable on cancellation, while the tip is recorded for
|
|
// accounting (total_tips) and excluded from the refund computation. The MONEY
|
|
// INVARIANT from buildSplitRecords holds: the records always partition
|
|
// bookingPortion + tipAmount exactly.
|
|
func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo, bookingPortion, tipAmount float64) []PaymentRecord {
|
|
maxDeposit := info.TotalAmount * ProtectedDepositMaxPct
|
|
remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid)
|
|
depositAmount := math.Min(bookingPortion, remainingDepositRoom)
|
|
depositAmount = math.Round(depositAmount*100) / 100
|
|
balancePortion := math.Round((bookingPortion-depositAmount)*100) / 100
|
|
|
|
var records []PaymentRecord
|
|
splitIdx := 0
|
|
|
|
if depositAmount > 0.004 {
|
|
dep := primary
|
|
dep.PaymentType = "deposit"
|
|
dep.Amount = depositAmount
|
|
records = append(records, dep)
|
|
splitIdx++
|
|
}
|
|
|
|
if balancePortion > 0.004 {
|
|
bal := primary
|
|
bal.PaymentType = "balance"
|
|
bal.Amount = balancePortion
|
|
bal.Fees = 0
|
|
if primary.IdempotencyKey != nil {
|
|
k := splitIdempotencyKey(*primary.IdempotencyKey, fmt.Sprintf("-split-%d", splitIdx))
|
|
bal.IdempotencyKey = &k
|
|
}
|
|
records = append(records, bal)
|
|
splitIdx++
|
|
}
|
|
|
|
if tipAmount > 0.004 {
|
|
tip := primary
|
|
tip.PaymentType = "tip"
|
|
tip.Amount = tipAmount
|
|
tip.Fees = 0
|
|
if primary.IdempotencyKey != nil {
|
|
k := splitIdempotencyKey(*primary.IdempotencyKey, "-split-tip")
|
|
tip.IdempotencyKey = &k
|
|
}
|
|
records = append(records, tip)
|
|
}
|
|
|
|
if len(records) == 0 {
|
|
primary.Fees = 0
|
|
records = append(records, primary)
|
|
}
|
|
return records
|
|
}
|
|
|
|
// splitIdempotencyKey derives a bounded-length idempotency key for a split
|
|
// record. The terminal base key (booking + amount + Square payment ID) can be
|
|
// long enough that appending a -split-tip suffix would exceed the
|
|
// payments.idempotency_key VARCHAR(64) limit (e.g. the dev mock's 28-char
|
|
// "pay_mock_<nanosecond>" payment IDs); the base is truncated so the suffix
|
|
// always fits. Uniqueness is preserved: the truncated base still embeds the
|
|
// booking ID and Square payment ID, and the suffix differs per split record.
|
|
func splitIdempotencyKey(base, suffix string) string {
|
|
maxBase := 64 - len(suffix)
|
|
if len(base) > maxBase {
|
|
base = base[:maxBase]
|
|
}
|
|
return base + suffix
|
|
}
|
|
|
|
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()
|
|
|
|
// 2FA gating (H4): persisting a card via the account "add card" endpoint
|
|
// requires 2FA when the feature is enforced — the same gate the booking
|
|
// and tip flows apply to req.SaveCard. Persisting a stored credential is
|
|
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
|
|
// endpoint must not be the un-gated side door.
|
|
if !requireTwoFactorForCardAccess(w, r, service, userID) {
|
|
return
|
|
}
|
|
|
|
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
|
|
if err != nil {
|
|
if isDefinitiveCardSaveFailure(err) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// isDefinitiveCardSaveFailure reports whether a CreatePaymentMethod error is a
|
|
// definitive client rejection — an expired/invalid/already-used card source or
|
|
// a declined card that can never be saved — as opposed to an ambiguous
|
|
// transport/server failure. It matches the structured Square error Code
|
|
// (square.ErrorCode) exactly against the codes this codebase already recognizes
|
|
// for card failures (till.go's definitivePaymentDeclineCodes via
|
|
// isDefinitiveChargeFailure, plus the card-on-file creation codes SOURCE_USED /
|
|
// CARD_TOKEN_USED / CARD_TOKEN_EXPIRED / INVALID_CARD), and additionally treats
|
|
// any error carrying Square's INVALID_REQUEST_ERROR CATEGORY as definitive —
|
|
// real 400 card-save failures (e.g. MISSING_REQUIRED_PARAMETER) arrive with
|
|
// that category and a specific code, so checking the category catches them all.
|
|
// INVALID_REQUEST_ERROR is a category, NOT a code: it must be matched via
|
|
// square.ErrorCategory, never as a code. Errors carrying no structured code
|
|
// (transport errors, the dev mock's plain errors, 5xx) are ambiguous and stay
|
|
// 500 — retrying with the same inputs might succeed.
|
|
func isDefinitiveCardSaveFailure(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if isDefinitiveChargeFailure(err) {
|
|
return true
|
|
}
|
|
switch square.ErrorCode(err) {
|
|
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
|
|
return true
|
|
}
|
|
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// N4: Square's refund-reason limit is 192 chars — a longer reason 400s at
|
|
// Square and would be misclassified as a definitive decline. Reject early.
|
|
if len(req.Reason) > 192 {
|
|
http.Error(w, "Refund reason must be 192 characters or less", 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
|
|
}
|
|
|
|
// Client-supplied idempotency key feeds Square /v2/refunds (45-char cap).
|
|
// This handler decodes into RefundRequest without running the struct
|
|
// validator, so enforce the limit explicitly — a longer key would 400 at
|
|
// Square and be misclassified as a definitive refund decline.
|
|
if len(req.IdempotencyKey) > maxIdempotencyKeyLength {
|
|
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", 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
|
|
}
|
|
|
|
// A discount/on_the_house payment row is a ledger entry, not real money
|
|
// (the customer never paid it). Refunding it would pay money out of
|
|
// nothing. The NULL-square_payment_id guard below would also catch it, but
|
|
// an explicit check is defense-in-depth: if a discount row ever gains a
|
|
// square_payment_id, this still blocks the refund.
|
|
if payment.PaymentMethod == "discount" || payment.PaymentMethod == "on_the_house" {
|
|
http.Error(w, "Cannot refund a discount or complimentary payment", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Money-safety guard (M7): a payments row with NO booking is a gift-card
|
|
// purchase (BuyGiftCard inserts without a booking — the same discriminator
|
|
// the sweep uses in sweep.go). Refunding such a payment at Square returns
|
|
// the cash while the issued gift card and its balance credit stay live:
|
|
// £N paid out with the £N card still spendable = money created from
|
|
// nothing. The reversal alternative (delete the card + debit the pooled
|
|
// balance) is unsafe: a self-purchase is auto-redeemed into the account
|
|
// balance which may already be partially spent, and gift_card_transactions
|
|
// rows reference the card. Reject with a clear message directing the admin
|
|
// to the gift-card section, BEFORE any Square call or pending-refund row
|
|
// (this also blocks the dedup/resume paths below, which re-issue at
|
|
// Square).
|
|
if payment.BookingID == "" {
|
|
http.Error(w, "Cannot refund a gift-card purchase via payment refund. Refund gift-card purchases by cancelling the card in the gift-card section.", 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 releasePaymentLock(pinConn, "crussell:refund:"+refundLockKey)
|
|
|
|
// 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":
|
|
// C4: same-key dedup must report the STORED refund, never the newly
|
|
// requested amount — echoing req.Amount on a different-amount retry
|
|
// misleads the admin into believing the new amount was refunded.
|
|
createdAt := clock.Now()
|
|
if existingRefundCreatedAt.Valid {
|
|
createdAt = existingRefundCreatedAt.Time
|
|
}
|
|
if err := json.NewEncoder(w).Encode(RefundResponse{
|
|
ID: existingRefundID.String,
|
|
PaymentID: paymentID,
|
|
Amount: int64(math.Round(existingRefundAmount.Float64 * 100)),
|
|
Status: "completed",
|
|
Reason: existingRefundReason.String,
|
|
CreatedAt: createdAt.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: resumeAmount,
|
|
Status: "completed",
|
|
Reason: existingRefundReason.String,
|
|
CreatedAt: existingRefundCreatedAt.Time.Format(time.RFC3339),
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
// Persist a fallback key (legacy NULL-key rows) before re-issuing so
|
|
// a lost-response retry reuses it — see ensureRefundKey.
|
|
reissueKey, keyErr := ensureRefundKey(r.Context(), existingRefundID.String, paymentID, resumeAmount, existingRefundKey.String)
|
|
if keyErr != nil {
|
|
log.Printf("Failed to ensure refund key for refund %s before re-issue: %v", existingRefundID.String, keyErr)
|
|
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
reissueReq := square.RefundPaymentReq{
|
|
PaymentID: refundSqPaymentID,
|
|
Amount: resumeAmount,
|
|
IdempotencyKey: reissueKey,
|
|
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: resumeAmount,
|
|
Status: reissueStatus,
|
|
Reason: existingRefundReason.String,
|
|
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: resumeAmount,
|
|
Status: "completed",
|
|
Reason: existingRefundReason.String,
|
|
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. A legacy
|
|
// NULL-key row gets a fallback key persisted to the row FIRST (ensureRefundKey),
|
|
// so a lost-response retry reuses it instead of double-refunding with a fresh
|
|
// random suffix.
|
|
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))
|
|
resumeKey, keyErr := ensureRefundKey(r.Context(), refundID, paymentID, resumeAmount, refundKey)
|
|
if keyErr != nil {
|
|
log.Printf("Failed to ensure refund key for refund %s before resume: %v", refundID, keyErr)
|
|
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
resumeReq := square.RefundPaymentReq{
|
|
PaymentID: *payment.SquarePaymentID,
|
|
Amount: resumeAmount,
|
|
IdempotencyKey: resumeKey,
|
|
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)
|
|
}
|
|
}
|
|
|
|
// AdminBookingRefundRequest is the request body for
|
|
// POST /api/admin/bookings/{id}/refund.
|
|
type AdminBookingRefundRequest struct {
|
|
Amount int64 `json:"amount" validate:"required,gt=0"`
|
|
Reason string `json:"reason" validate:"required"`
|
|
}
|
|
|
|
// AdminBookingRefundResponse reports the result of an admin-initiated
|
|
// booking-level refund: the total amount refunded and one entry per affected
|
|
// payment.
|
|
type AdminBookingRefundResponse struct {
|
|
RefundedAmount int64 `json:"refunded_amount"`
|
|
Refunds []RefundResponse `json:"refunds"`
|
|
}
|
|
|
|
// AdminRefundBooking is the admin-initiated booking-level refund endpoint
|
|
// (separate from cancellation refunds). It refunds up to req.Amount against
|
|
// the booking's completed non-tip payments, oldest first, after validating the
|
|
// amount against the booking's refundable total (paid minus already refunded,
|
|
// excluding tips). Used for post-service refunds (bad application, etc.) at
|
|
// admin discretion.
|
|
func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
|
|
// Defense-in-depth admin check (S-1) — the route is mounted under
|
|
// mw.RequireAdmin; this keeps booking-level refunds 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 AdminBookingRefundRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode admin booking refund 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
|
|
}
|
|
// Square's refund-reason limit is 192 chars (N4) — a longer reason 400s at
|
|
// Square and would be misclassified as a definitive decline.
|
|
if len(req.Reason) > 192 {
|
|
http.Error(w, "Refund reason must be 192 characters or less", 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
|
|
}
|
|
|
|
service := NewPaymentService()
|
|
|
|
// Booking user lookup also proves the booking exists.
|
|
var bookingUserID string
|
|
var isGuest bool
|
|
if err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
|
|
FROM bookings b
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
WHERE b.id = $1
|
|
`, bookingID).Scan(&bookingUserID, &isGuest); 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
|
|
}
|
|
|
|
// Cap: the refund amount must not exceed the refundable total (completed
|
|
// non-tip payments minus already refunded). Tips are not refundable.
|
|
refundablePence, err := service.GetBookingRefundableAmountPence(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get refundable amount for booking %s: %v", bookingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if req.Amount > refundablePence {
|
|
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundablePence, bookingID)
|
|
http.Error(w, "Refund amount exceeds the refundable amount for this booking", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Fetch the booking's completed non-tip payments, oldest first, so the
|
|
// requested amount is refunded against the earliest money first.
|
|
rows, err := db.Conn.Query(r.Context(), `
|
|
SELECT id, amount, payment_method, square_payment_id, gift_card_id
|
|
FROM payments
|
|
WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'
|
|
AND payment_method NOT IN ('discount', 'on_the_house')
|
|
ORDER BY created_at ASC
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch payments for admin booking refund: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var payments []paymentRow
|
|
for rows.Next() {
|
|
var p paymentRow
|
|
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
|
|
log.Printf("Failed to scan payment row: %v", err)
|
|
continue
|
|
}
|
|
payments = append(payments, p)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Payment row iteration error: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(payments) == 0 {
|
|
http.Error(w, "No refundable payments found for this booking", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Serialize against the cancellation refund path and the per-payment manual
|
|
// RefundPayment handler — both hold the same crussell:refund:<payment_id>
|
|
// locks — so the residual computation below cannot race an in-flight refund.
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction for admin booking 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 admin booking refund transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
if err := lockCancellationPayments(r.Context(), tx, payments); err != nil {
|
|
log.Printf("Failed to acquire refund locks for booking %s: %v", bookingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Prior refunds per payment (completed + pending) so a payment is never
|
|
// refunded past its residual.
|
|
priorRefunds := make(map[string]float64)
|
|
prRows, prErr := tx.Query(r.Context(), `
|
|
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
|
|
WHERE booking_id = $1 AND status IN ('completed', 'pending')
|
|
GROUP BY payment_id`, bookingID)
|
|
if prErr != nil {
|
|
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
|
|
} else {
|
|
for prRows.Next() {
|
|
var pid string
|
|
var amt float64
|
|
if err := prRows.Scan(&pid, &amt); err == nil {
|
|
priorRefunds[pid] = amt
|
|
}
|
|
}
|
|
prRows.Close()
|
|
}
|
|
|
|
// cardRefunds tracks card refunds that need a post-commit Square call.
|
|
type cardRefund struct {
|
|
refundID string
|
|
amountPence int64
|
|
squareID string
|
|
reason string
|
|
key string
|
|
}
|
|
var cardRefunds []cardRefund
|
|
var refunds []RefundResponse
|
|
remaining := float64(req.Amount) / 100.0
|
|
|
|
for _, p := range payments {
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
already := priorRefunds[p.ID]
|
|
residual := math.Round((p.Amount-already)*100) / 100
|
|
if residual <= 0 {
|
|
continue
|
|
}
|
|
portion := math.Round(math.Min(residual, remaining)*100) / 100
|
|
remaining -= portion
|
|
|
|
var refundStatus string
|
|
var refundKey *string
|
|
|
|
switch p.PaymentMethod {
|
|
case "online_square", "in_person_card":
|
|
if p.SquarePaymentID == nil || *p.SquarePaymentID == "" {
|
|
log.Printf("Admin booking refund: card payment %s has no Square reference — marking failed; refund must be arranged manually", p.ID)
|
|
refundStatus = "failed"
|
|
break
|
|
}
|
|
// Unique per-attempt key (same shape as RefundPayment's no-client-key
|
|
// fallback): distinct refunds never collide on the UNIQUE constraint.
|
|
key := p.ID + "-refund-" + strconv.FormatInt(int64(math.Round(portion*100)), 10) + "-" + randomHexSuffix(6)
|
|
refundKey = &key
|
|
refundStatus = "pending"
|
|
case "giftcard":
|
|
if p.GiftCardID != nil && *p.GiftCardID != "" {
|
|
var expired bool
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
|
|
FROM gift_cards WHERE id = $1
|
|
`, *p.GiftCardID).Scan(&expired); err != nil {
|
|
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *p.GiftCardID, err)
|
|
} else if expired {
|
|
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *p.GiftCardID, bookingID)
|
|
continue
|
|
}
|
|
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
|
|
if expiryErr != nil {
|
|
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
|
gcExpiryMonths = defaultGiftCardExpiryMonths
|
|
}
|
|
if _, gcErr := tx.Exec(r.Context(), `
|
|
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
|
WHERE id = $2
|
|
`, portion, *p.GiftCardID, gcExpiryMonths); gcErr != nil {
|
|
log.Printf("Failed to refund £%.2f to gift card %s: %v", portion, *p.GiftCardID, gcErr)
|
|
continue
|
|
}
|
|
if _, gcErr := tx.Exec(r.Context(), `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
|
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
|
|
`, *p.GiftCardID, portion, bookingID, bookingUserID, "Refund from admin booking refund"); gcErr != nil {
|
|
log.Printf("Failed to create gift card transaction for refund: %v", gcErr)
|
|
}
|
|
} else {
|
|
if bookingUserID == "" {
|
|
log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", p.ID)
|
|
continue
|
|
}
|
|
if _, balErr := tx.Exec(r.Context(), `
|
|
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
|
updated_at = NOW()
|
|
`, bookingUserID, portion); balErr != nil {
|
|
log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
|
|
continue
|
|
}
|
|
}
|
|
refundStatus = "completed"
|
|
case "cash":
|
|
if bookingUserID == "" || isGuest {
|
|
log.Printf("Cash refund: booking %s payment %s amount £%.2f — admin must process cash refund at till", bookingID, p.ID, portion)
|
|
} else {
|
|
if _, balErr := tx.Exec(r.Context(), `
|
|
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
|
updated_at = NOW()
|
|
`, bookingUserID, portion); balErr != nil {
|
|
log.Printf("Failed to credit user %s balance for cash refund of booking %s: %v", bookingUserID, bookingID, balErr)
|
|
continue
|
|
}
|
|
}
|
|
refundStatus = "completed"
|
|
default:
|
|
log.Printf("Skipping admin booking refund for payment %s with method %q (no money exchanged)", p.ID, p.PaymentMethod)
|
|
continue
|
|
}
|
|
|
|
var refundID string
|
|
if refundKey != 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, $4, $5, $6, $7, $8, 'manual')
|
|
RETURNING id
|
|
`, p.ID, bookingID, portion, refundStatus, req.Reason, refundKey, adminID, clock.Now()).Scan(&refundID)
|
|
} else {
|
|
err = tx.QueryRow(r.Context(), `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_by, created_at, origin)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual')
|
|
RETURNING id
|
|
`, p.ID, bookingID, portion, refundStatus, req.Reason, adminID, clock.Now()).Scan(&refundID)
|
|
}
|
|
if err != nil {
|
|
log.Printf("Failed to create refund record for payment %s: %v", p.ID, err)
|
|
continue
|
|
}
|
|
|
|
refunds = append(refunds, RefundResponse{
|
|
ID: refundID,
|
|
PaymentID: p.ID,
|
|
Amount: int64(math.Round(portion * 100)),
|
|
Status: refundStatus,
|
|
Reason: req.Reason,
|
|
CreatedAt: clock.Now().Format(time.RFC3339),
|
|
})
|
|
|
|
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
|
|
if refundKey != nil && p.SquarePaymentID != nil {
|
|
cardRefunds = append(cardRefunds, cardRefund{
|
|
refundID: refundID,
|
|
amountPence: int64(math.Round(portion * 100)),
|
|
squareID: *p.SquarePaymentID,
|
|
reason: req.Reason,
|
|
key: *refundKey,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit admin booking refund transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Post-commit Square pass for card refunds (mirrors RefundPayment's
|
|
// status resolution). A definitive decline marks the row failed; an
|
|
// ambiguous error leaves it pending for the manual-refund sweep.
|
|
for _, cf := range cardRefunds {
|
|
status := "completed"
|
|
result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{
|
|
PaymentID: cf.squareID,
|
|
Amount: cf.amountPence,
|
|
IdempotencyKey: cf.key,
|
|
Reason: cf.reason,
|
|
})
|
|
switch {
|
|
case rErr == nil:
|
|
if result.Status == "PENDING" {
|
|
status = "pending"
|
|
log.Printf("Square refund %s is PENDING (in flight) — leaving refund %s pending for the sweep", result.ID, cf.refundID)
|
|
} else if result.Status == "FAILED" || result.Status == "REJECTED" {
|
|
status = "failed"
|
|
log.Printf("Square refund %s FAILED — marking refund %s failed", result.ID, cf.refundID)
|
|
}
|
|
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, result.ID, cf.refundID); upErr != nil {
|
|
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", result.ID, cf.refundID, upErr)
|
|
}
|
|
case errors.Is(rErr, square.ErrRefundAlreadyProcessed):
|
|
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, cf.refundID); upErr != nil {
|
|
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", cf.refundID, upErr)
|
|
}
|
|
log.Printf("Refund %s already processed at Square — marked completed", cf.refundID)
|
|
case errors.Is(rErr, square.ErrRefundDeclined):
|
|
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, cf.refundID); upErr != nil {
|
|
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", cf.refundID, upErr)
|
|
}
|
|
log.Printf("Refund %s definitively declined by Square: %v", cf.refundID, rErr)
|
|
default:
|
|
log.Printf("Failed to refund payment (refund %s left pending): %v", cf.refundID, rErr)
|
|
}
|
|
for i := range refunds {
|
|
if refunds[i].ID == cf.refundID {
|
|
refunds[i].Status = status
|
|
}
|
|
}
|
|
}
|
|
|
|
var totalRefunded int64
|
|
for _, rf := range refunds {
|
|
totalRefunded += rf.Amount
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(AdminBookingRefundResponse{
|
|
RefundedAmount: totalRefunded,
|
|
Refunds: refunds,
|
|
}); 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
|
|
}
|
|
|
|
// Product rule (security): only verified accounts may save cards. An
|
|
// unverified/guest/affiliate user may still tip, but save_card=true is
|
|
// rejected here — before any charge source resolution or payment record.
|
|
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
|
|
return
|
|
}
|
|
|
|
service := NewPaymentService()
|
|
|
|
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
|
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// M4: tips are only accepted once the booking has started. A tip is
|
|
// gratuity for service already rendered; accepting it on a 'confirmed'
|
|
// booking whose appointment is still in the future would collect money for
|
|
// a service not yet performed and inflate the total_tips aggregation.
|
|
// Checked against the booking start time (not status) so an early-arriving
|
|
// booking still cannot tip until its slot opens.
|
|
var bookingStartTime time.Time
|
|
if err := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); err != nil {
|
|
log.Printf("Failed to get booking start time: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if bookingStartTime.After(clock.Now()) {
|
|
log.Printf("Tip rejected: booking %s starts at %s (not yet started)", bookingID, bookingStartTime.Format(time.RFC3339))
|
|
http.Error(w, "Tips can only be added after the booking has started", http.StatusBadRequest)
|
|
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). When the client sends NO key, a DETERMINISTIC fallback is derived
|
|
// inside the transaction below (the count query must see the committed
|
|
// rows) — never a random key: a random fallback meant a lost-response
|
|
// no-key retry minted a fresh key, a fresh pending row, and a SECOND
|
|
// Square charge (H2).
|
|
idempotencyKey := req.IdempotencyKey
|
|
|
|
// Resolve the card source ID — same pattern as CreateBookingPayment (see
|
|
// resolveChargeSource for the R6 rationale).
|
|
var sourceID string
|
|
var savedCardID *string
|
|
var savedCardCustomerID string
|
|
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
|
// enforced. New-card (nonce) charges are not gated.
|
|
if req.CardID != nil && *req.CardID != "" {
|
|
if !requireTwoFactorForCardAccess(w, r, service, userID) {
|
|
return
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
// No-client-key fallback: derive a deterministic key INSIDE the tx so the
|
|
// count query races no other tip attempt (the tip advisory lock serializes
|
|
// per booking). Money-safety: n counts COMPLETED tips only, so a
|
|
// lost-response retry of a charge whose pending row exists derives the SAME
|
|
// n → the same key → the dedup lookup below reuses the pending row instead
|
|
// of minting a second Square charge, while two genuinely distinct identical
|
|
// tips get n=1, n=2 and never collapse onto one key.
|
|
if idempotencyKey == "" {
|
|
var completedTips int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM payments
|
|
WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
|
|
`, bookingID).Scan(&completedTips); err != nil {
|
|
log.Printf("Failed to count completed tips for booking %s: %v", bookingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
cardPart := "new"
|
|
if req.CardID != nil && *req.CardID != "" {
|
|
cardPart = *req.CardID
|
|
}
|
|
idempotencyKey = truncateIdempotencyKey("tip", fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1))
|
|
}
|
|
|
|
// 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. First
|
|
// RE-VALIDATE the matched row's refund state (same guard as the
|
|
// CreateBookingPayment completed-dedup branches): a refunded payment's
|
|
// money is no longer live, so reporting it as success would let a
|
|
// same-key retry claim money that was already returned.
|
|
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
|
|
log.Printf("Failed to re-validate tip dedup hit %s against refunds: %v", existingID.String, rErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
} else if refunded {
|
|
log.Printf("Tip retry rejected: tip payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, idempotencyKey)
|
|
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
|
|
return
|
|
}
|
|
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,
|
|
SquareSourceID: &sourceID,
|
|
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)
|
|
} else {
|
|
// Reused pending row. The stored square_request_snapshot is the FIRST
|
|
// attempt's charge body and MUST remain immutable across nonce-changing
|
|
// retries: if that original charge actually landed at Square (the row
|
|
// is pending only because the post-charge outcome is unknown), the
|
|
// by-key sweep replay must match the original body so Square's
|
|
// idempotency dedup returns the landed payment and the sweep rescues
|
|
// the row. Overwriting the snapshot's SourceID with this retry's fresh
|
|
// nonce — or refreshing the square_source_id column the sweep overrides
|
|
// the replay source with — would make the sweep replay the NEW source,
|
|
// Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge
|
|
// would never be rescued (stranded until the 24h blind-fail). A retry
|
|
// that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the
|
|
// sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the
|
|
// row's true state from the immutable first-attempt body instead. The
|
|
// column is refreshed ONLY for snapshot-less legacy rows, whose
|
|
// fallback replay body is rebuilt from it (and which get a fresh
|
|
// snapshot from the post-commit write below).
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
|
|
log.Printf("Failed to update square_source_id on reused tip payment %s: %v", paymentID, srcErr)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
// C3: the tip charge is cardholder-initiated whether it uses a saved
|
|
// card (ccof — customer_details required) or a freshly entered card
|
|
// (cnon — buyer present), so the flag is true either way.
|
|
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
|
}
|
|
|
|
// M1: store the verbatim request JSON so the sweep can replay the charge
|
|
// with an IDENTICAL body under the same key — Square compares the whole
|
|
// request on key reuse, and a reconstructed body returns
|
|
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is
|
|
// written ONLY when the row has none: it records the FIRST attempt's body,
|
|
// which stays immutable so a nonce-changing retry can never redirect the
|
|
// sweep's replay away from the original charge (see the reuse branch above).
|
|
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
|
log.Printf("Failed to marshal square_request_snapshot for tip payment %s: %v", paymentID, mErr)
|
|
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
|
|
log.Printf("Failed to encrypt square_request_snapshot for tip payment %s: %v", paymentID, eErr)
|
|
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
|
|
log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr)
|
|
}
|
|
|
|
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
|
if err != nil {
|
|
log.Printf("Failed to create tip payment: %v (error_code=%q)", err, square.ErrorCode(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).
|
|
//
|
|
// The recheck and the status write run in ONE transaction so the
|
|
// FOR UPDATE row lock taken inside recheckBookingPayable persists to
|
|
// commit (C5) — a concurrent cancellation cannot commit a cancelled
|
|
// status between the recheck and the payments UPDATE.
|
|
recheckTx, reTxErr := db.Conn.Begin(r.Context())
|
|
if reTxErr != nil {
|
|
log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but opening the post-charge recheck transaction failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, reTxErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := recheckTx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback post-charge recheck transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
tipRecheckStatus, tipPayable, err := recheckBookingPayable(r.Context(), recheckTx, 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 := recheckTx.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)
|
|
}
|
|
if cErr := recheckTx.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square tip payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, tipRecheckStatus, bookingID, cErr)
|
|
}
|
|
http.Error(w, "This booking is no longer accepting tips", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Step 3: Square succeeded — update the payment record.
|
|
if _, upErr := recheckTx.Exec(r.Context(),
|
|
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
|
|
paymentResult.SquarePayID, paymentID,
|
|
); 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 cErr := recheckTx.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square tip payment %s (ID=%s) succeeded but committing the post-charge status update for payment %s failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, paymentID, cErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
|
ID: paymentID,
|
|
BookingID: bookingID,
|
|
PaymentType: "tip",
|
|
Status: "completed",
|
|
Amount: req.Amount,
|
|
CardBrand: paymentResult.CardBrand,
|
|
CardLast4: paymentResult.CardLast4,
|
|
ReceiptURL: paymentResult.ReceiptURL,
|
|
CreatedAt: clock.Now().Format(time.RFC3339),
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, _ := r.Context().Value(mw.UserIDKey).(string)
|
|
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
|
|
|
service := NewPaymentService()
|
|
|
|
// Fail closed: a non-admin request must carry a user ID. The previous
|
|
// `userID != ""` guard silently skipped the ownership check for requests
|
|
// with no user context, leaking another user's payment summary.
|
|
if userRole != "admin" {
|
|
if userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking user: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if bookingUserID != userID {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get payment summary: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
payments := make([]PaymentResponse, len(summary.Payments))
|
|
for i, p := range summary.Payments {
|
|
payments[i] = PaymentResponse{
|
|
ID: p.ID,
|
|
BookingID: p.BookingID,
|
|
PaymentType: p.PaymentType,
|
|
Status: p.Status,
|
|
Amount: int64(math.Round(p.Amount * 100)),
|
|
CardLast4: p.CardLast4,
|
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
refunds := make([]RefundResponse, len(summary.Refunds))
|
|
for i, rf := range summary.Refunds {
|
|
refunds[i] = RefundResponse{
|
|
ID: rf.ID,
|
|
PaymentID: rf.PaymentID,
|
|
Amount: int64(math.Round(rf.Amount * 100)),
|
|
Status: rf.Status,
|
|
Reason: rf.Reason,
|
|
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(PaymentSummaryResponse{
|
|
TotalAmount: int64(math.Round(summary.TotalAmount * 100)),
|
|
PaidAmount: int64(math.Round(summary.PaidAmount * 100)),
|
|
RefundedAmount: int64(math.Round(summary.RefundedAmount * 100)),
|
|
RemainingAmount: int64(math.Round(summary.RemainingAmount * 100)),
|
|
TotalVATAmount: int64(math.Round(summary.TotalVATAmount * 100)),
|
|
TotalNetAmount: int64(math.Round(summary.TotalNetAmount * 100)),
|
|
Payments: payments,
|
|
Refunds: refunds,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// PaymentLockDuration is the TTL for a payment-in-flight lock in minutes.
|
|
const PaymentLockDuration = 5
|
|
|
|
// AcquirePaymentLock creates or extends a 5-minute time_blocker for the
|
|
// booking's slot so that pending_release eviction is blocked during card
|
|
// entry and Square charge processing.
|
|
func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Verify the user owns this booking.
|
|
var bookingUserID string
|
|
if err := db.Conn.QueryRow(r.Context(),
|
|
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
|
|
).Scan(&bookingUserID); err != nil {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if bookingUserID != userID {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Before acquiring the lock, double-check the slot is still available.
|
|
// For confirmed/in_progress bookings this is a formality; for
|
|
// pending_release bookings it catches the eviction race before we
|
|
// create a time_blocker — the NOT EXISTS guard in eviction queries
|
|
// handles the sub-5-minute race, this catches the >5-minute gap.
|
|
var currentStatus string
|
|
var startTime time.Time
|
|
if err := db.Conn.QueryRow(r.Context(),
|
|
"SELECT status, start_time FROM bookings WHERE id = $1", bookingID,
|
|
).Scan(¤tStatus, &startTime); err != nil {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// If the booking has been evicted (deposit_lapsed) or reached a terminal
|
|
// state, reject the lock — payment cannot proceed.
|
|
if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" {
|
|
log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus)
|
|
http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Upsert the time_blocker atomically: delete old PAYMENT_IN_FLIGHT and insert
|
|
// a fresh one in a single transaction. Prevents lock loss if INSERT fails.
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction for payment lock: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
|
|
`, bookingID); err != nil {
|
|
log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES (NOW(), $1, $2, $3)
|
|
`, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil {
|
|
log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit payment lock transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "locked",
|
|
"ttl_min": PaymentLockDuration,
|
|
"bookingID": bookingID,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking.
|
|
func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Mirror AcquirePaymentLock's ownership check: releasing another user's
|
|
// PAYMENT_IN_FLIGHT blocker would evict their slot mid-payment. The
|
|
// booking's own user (or an admin) may release it.
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
|
var bookingUserID string
|
|
if err := db.Conn.QueryRow(r.Context(),
|
|
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
|
|
).Scan(&bookingUserID); err != nil {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if userRole != "admin" && bookingUserID != userID {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
|
|
`, bookingID); err != nil {
|
|
log.Printf("Failed to release payment lock for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// uniqueChargeKey generates a unique idempotency key under the given prefix
|
|
// (e.g. "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.
|
|
// Current callers (A7): the terminal-payment idempotency key in
|
|
// CreateTerminalPayment (handlers.go:343, fresh admin actions never
|
|
// network-retried) and the till cash/on_the_house no-client-key fallback
|
|
// (till.go:406, two identical keyless cash gift-card sales are distinct
|
|
// operations). The tip flow no longer uses it — its no-client-key fallback is
|
|
// derived deterministically from the completed-tip count (see CreateTipPayment).
|
|
func uniqueChargeKey(prefix string) string {
|
|
return prefix + rand.Text()
|
|
}
|
|
|
|
// deriveBookingPaymentIdempotencyKey returns the deterministic fallback
|
|
// idempotency key for a no-client-key booking payment:
|
|
// "pay-<bookingID>-<paymentType>-<amount>-<cardID>", sha256-truncated when the
|
|
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic,
|
|
// so a same-key retry still dedups).
|
|
//
|
|
// The key must distinguish "same live operation retried" (dedup) from "new
|
|
// operation that happens to have equal amount" (new charge). The candidate is
|
|
// the base key (seq 0) and then the base key with a "-<seq>" suffix (seq ≥ 1)
|
|
// until a slot without a COMPLETED payment is found; what makes a completed
|
|
// slot "spent" depends on the type:
|
|
//
|
|
// - 'partial' (repeatable type): a completed row ALWAYS advances the
|
|
// sequence — two genuine equal-amount partial payments are distinct
|
|
// operations and must diverge onto distinct keys (the dedup lookup would
|
|
// otherwise return the first as success and silently swallow the second).
|
|
// - non-repeatable types (deposit/full/balance): a completed row advances
|
|
// only when its money is no longer live (it has a completed/pending
|
|
// refund). A refunded payment must not be returned as "success" for a new
|
|
// equal-amount charge — the booking would show paid with no money
|
|
// collected (refund-then-repay). An UN-refunded completed row is the SAME
|
|
// live operation retried, so its key is reused and the dedup lookup
|
|
// returns it (paying the same 50% deposit twice on an un-refunded booking
|
|
// MUST still dedup — the double-charge protection).
|
|
//
|
|
// A PENDING row never occupies a slot (the scan only matches 'completed'), so
|
|
// a lost-response retry of an in-flight charge re-derives the same key and
|
|
// reuses the pending record. seq 0 is the historical un-sequenced key, so
|
|
// legacy deterministic-key rows are still matched.
|
|
//
|
|
// Must be called inside the transaction holding the per-booking advisory lock
|
|
// so the spent-slot scan races no concurrent charge (mirrors the tip flow's
|
|
// no-client-key fallback, which counts completed tips under the tip lock).
|
|
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
|
|
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
|
|
for seq := 0; ; seq++ {
|
|
// nextIdempotencyCandidate (idempotency_helpers.go) reproduces the
|
|
// historical candidate exactly: the base key at seq 0, "base-seq" at
|
|
// seq ≥ 1, sha256-truncated to the 45-char limit under the "pay-"
|
|
// prefix when the verbatim form overflows — the key stays deterministic
|
|
// so a same-key retry still dedups (A3).
|
|
candidate := nextIdempotencyCandidate(baseKey, seq)
|
|
var completedID string
|
|
err := q.QueryRow(ctx, `
|
|
SELECT id FROM payments
|
|
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
|
|
`, bookingID, candidate).Scan(&completedID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return candidate, nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
refunded, rErr := paymentHasLiveRefund(ctx, q, completedID)
|
|
if rErr != nil {
|
|
return "", rErr
|
|
}
|
|
if paymentType == "partial" || refunded {
|
|
continue
|
|
}
|
|
return candidate, nil
|
|
}
|
|
}
|
|
|
|
// paymentHasLiveRefund reports whether the payment has a refund in a state
|
|
// meaning its money is no longer fully live: a completed refund (money
|
|
// returned) or a pending refund (money in flight). Failed refunds never moved
|
|
// money and are excluded. Used to re-validate a dedup hit — a refunded payment
|
|
// must never be returned as "success" for a new equal-amount charge.
|
|
func paymentHasLiveRefund(ctx context.Context, q db.Querier, paymentID string) (bool, error) {
|
|
var exists bool
|
|
err := q.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM refunds WHERE payment_id = $1 AND status IN ('completed', 'pending')
|
|
)
|
|
`, paymentID).Scan(&exists)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|