Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
4021 lines
170 KiB
Go
4021 lines
170 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"`
|
|
}
|
|
|
|
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 == "" {
|
|
scKey = bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
|
|
}
|
|
|
|
// Idempotency switch inside the lock: completed → dedup; pending →
|
|
// reuse (re-attempt Square with the same key, which dedups Square-side);
|
|
// failed → clean rejection.
|
|
var existingID, existingStatus sql.NullString
|
|
var existingAmount sql.NullFloat64
|
|
err = db.Conn.QueryRow(r.Context(), `
|
|
SELECT id, status, amount FROM payments WHERE booking_id = $1 AND idempotency_key = $2
|
|
`, bookingID, scKey).Scan(&existingID, &existingStatus, &existingAmount)
|
|
|
|
paymentID := ""
|
|
switch {
|
|
case err == nil && existingStatus.String == "completed":
|
|
// Dedup — return the existing completed payment.
|
|
if err := json.NewEncoder(w).Encode(CheckoutResponse{
|
|
CheckoutID: existingID.String,
|
|
Status: "COMPLETED",
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
case err == nil && existingStatus.String == "pending":
|
|
// Reuse the pending record: a prior attempt's Square outcome is
|
|
// unknown. Guard the amount — a retry with a different amount must
|
|
// not reuse the old record's charge.
|
|
if int64(math.Round(existingAmount.Float64*100)) != amount {
|
|
log.Printf("Saved-card retry amount mismatch: pending %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), amount)
|
|
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
|
|
return
|
|
}
|
|
paymentID = existingID.String
|
|
case err == nil && existingStatus.String == "failed":
|
|
log.Printf("Saved-card payment %s was previously marked failed (swept) — refusing retry", existingID.String)
|
|
http.Error(w, "This payment previously failed and can no longer be retried", http.StatusConflict)
|
|
return
|
|
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
|
log.Printf("Failed to check saved-card idempotency: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Pending-first: insert a pending payment record, commit, then charge.
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if paymentID == "" {
|
|
record := PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: req.PaymentType,
|
|
PaymentMethod: "online_square",
|
|
Status: "pending",
|
|
Amount: float64(amount) / 100.0,
|
|
IdempotencyKey: &scKey,
|
|
UserSavedCardID: req.UserSavedCardID,
|
|
SquareSourceID: &sourceID,
|
|
CreatedAt: clock.Now(),
|
|
UpdatedAt: clock.Now(),
|
|
CreatedBy: &adminID,
|
|
}
|
|
if err := tx.QueryRow(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, square_source_id, created_by, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING id
|
|
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.SquareSourceID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
|
|
log.Printf("Failed to insert pending saved-card payment: %v", err)
|
|
_ = tx.Rollback(r.Context())
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
// Refresh square_source_id on a reused pending row — the sweep
|
|
// replays the charge from the stored source.
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, 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,
|
|
}
|
|
// 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.
|
|
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 _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), 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", 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
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. Mark the row COMPLETED
|
|
// so the booking's in-flight guard releases; the payment is
|
|
// recorded by GetCheckoutStatus on the poll path (mirrors the
|
|
// real-checkout COMPLETED handling below).
|
|
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — marking COMPLETED", checkoutID, bookingID)
|
|
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 provisional terminal checkout %s completed: %v", checkoutID, upErr)
|
|
}
|
|
return ""
|
|
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" {
|
|
service := NewPaymentService()
|
|
|
|
// Serialize terminal-completion records per Square payment ID. Two
|
|
// concurrent polls of the same checkout could otherwise BOTH pass the
|
|
// dedup SELECT and BOTH INSERT, with the second dying on the
|
|
// idempotency_key UNIQUE constraint after the customer already paid —
|
|
// the same double-record race every other payment path guards against.
|
|
// Bounded try-lock (R6) so a contended lock never blocks the pool.
|
|
terminalLockKey := paymentResult.SquarePayID
|
|
pinConn, err := db.Conn.Acquire(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to acquire connection for terminal-completion lock: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer pinConn.Release()
|
|
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:terminal:"+terminalLockKey)
|
|
if err != nil {
|
|
log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", terminalLockKey, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !lockOK {
|
|
log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", terminalLockKey)
|
|
http.Error(w, "Payment in progress, try again", http.StatusConflict)
|
|
return
|
|
}
|
|
defer func() {
|
|
if _, err := pinConn.Exec(context.Background(), `
|
|
SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1))
|
|
`, terminalLockKey); err != nil {
|
|
log.Printf("Failed to release terminal-completion serialization lock for %s: %v", terminalLockKey, err)
|
|
}
|
|
}()
|
|
|
|
// Deterministic idempotency key derived from booking + amount + Square
|
|
// payment ID. The Square payment ID disambiguates two distinct
|
|
// equal-amount charges on the same booking, so equal amounts never
|
|
// collide on the UNIQUE constraint.
|
|
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID
|
|
|
|
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
|
|
// payment insert.
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to begin transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// Dedup by Square payment ID: a double poll of the same terminal
|
|
// checkout must return the existing payment row instead of inserting a
|
|
// duplicate (which previously 500'd on the idempotency-key UNIQUE
|
|
// violation after the customer had already paid).
|
|
var existingID string
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT id FROM payments
|
|
WHERE booking_id = $1 AND square_payment_id = $2
|
|
`, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil {
|
|
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
|
Status: "COMPLETED",
|
|
PaymentID: existingID,
|
|
Amount: paymentResult.Amount,
|
|
CardBrand: paymentResult.CardBrand,
|
|
CardLast4: paymentResult.CardLast4,
|
|
ReceiptURL: paymentResult.ReceiptURL,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
log.Printf("Failed to check for existing payment: %v", err)
|
|
}
|
|
|
|
// Re-check the booking status after the advisory lock: a concurrent
|
|
// cancellation/eviction can move the booking out of a payable state
|
|
// between the terminal charge completing and this poll recording it. A
|
|
// charge landing on a cancelled/lapsed/no-show booking must not be
|
|
// recorded as a completed payment — the cancellation refund path
|
|
// computes refunds from completed payments and would silently exclude
|
|
// this charge. Mark the checkout failed and alert ops: money was taken
|
|
// at Square and MUST be refunded manually (mirrors CreateBookingPayment's
|
|
// post-charge recheck).
|
|
var recheckStatus string
|
|
// FOR UPDATE (C5): serializes against the cancellation path's lock on
|
|
// the same row so a concurrent cancellation cannot commit between this
|
|
// recheck and the transaction commit below.
|
|
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&recheckStatus); err != nil {
|
|
log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required",
|
|
paymentResult.SquarePayID, checkoutID, bookingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !bookingStatusAllowsCompletedPayment(recheckStatus) {
|
|
log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually",
|
|
paymentResult.SquarePayID, checkoutID, bookingID, recheckStatus)
|
|
if _, upErr := tx.Exec(r.Context(), `UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1`, checkoutID); upErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required",
|
|
paymentResult.SquarePayID, recheckStatus, bookingID, checkoutID, upErr)
|
|
}
|
|
if cErr := tx.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required",
|
|
paymentResult.SquarePayID, recheckStatus, bookingID, cErr)
|
|
}
|
|
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// The payment type the admin charged is recorded on the checkout row
|
|
// by CreateTerminalPayment. Fall back to 'full' for legacy checkouts
|
|
// created before that record existed.
|
|
var checkoutPaymentType string
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1
|
|
`, checkoutID).Scan(&checkoutPaymentType); err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err)
|
|
}
|
|
checkoutPaymentType = "full"
|
|
}
|
|
|
|
record := PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: checkoutPaymentType,
|
|
PaymentMethod: "in_person_card",
|
|
Status: "completed",
|
|
Amount: float64(paymentResult.Amount) / 100.0,
|
|
SquarePaymentID: &paymentResult.SquarePayID,
|
|
IdempotencyKey: &idempotencyKey,
|
|
Fees: float64(paymentResult.Fees) / 100.0,
|
|
CreatedAt: clock.Now(),
|
|
UpdatedAt: clock.Now(),
|
|
}
|
|
|
|
// M4: a terminal charge above the remaining booking value is a tip
|
|
// (e.g. £100 booking + £10 tip = one £110 Square charge). Split it into
|
|
// deposit + balance + tip records so only the booking portion is
|
|
// refundable while the tip is recorded for accounting (total_tips
|
|
// aggregation) and excluded from cancellation refunds. Without a tip
|
|
// the charge stays a single record. The tip is derived from the amount
|
|
// exceeding the remaining booking value ("after 100% is tips") — NOT
|
|
// from Square's TipAmount field, because the frontend already embeds
|
|
// any tip in the amount and AllowTipping is disabled (see
|
|
// CreateTerminalPayment), so Square reports TipAmount 0.
|
|
var records []PaymentRecord
|
|
bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID)
|
|
if bErr == nil && bookingInfo != nil {
|
|
charged := float64(paymentResult.Amount) / 100.0
|
|
remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid)
|
|
bookingPortion := math.Min(charged, remainingBookingValue)
|
|
bookingPortion = math.Round(bookingPortion*100) / 100
|
|
tipAmount := math.Round((charged-bookingPortion)*100) / 100
|
|
if tipAmount > 0.004 {
|
|
records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount)
|
|
}
|
|
}
|
|
if len(records) == 0 {
|
|
records = []PaymentRecord{record}
|
|
}
|
|
|
|
primary := records[0]
|
|
paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, primary, 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)
|
|
|
|
var splitIDs []string
|
|
for _, rec := range records[1:] {
|
|
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil)
|
|
if cErr != nil {
|
|
log.Printf("Failed to create terminal tip split record: %v", cErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
splitIDs = append(splitIDs, pid)
|
|
}
|
|
for _, pid := range splitIDs {
|
|
ApplyVATToBookingPayment(r.Context(), tx, pid)
|
|
}
|
|
|
|
// Release the in-flight guard: this checkout is done, so a subsequent
|
|
// charge on the same booking is allowed.
|
|
if _, err := tx.Exec(r.Context(), `
|
|
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1
|
|
`, checkoutID); err != nil {
|
|
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err)
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fully-paid completion: if this terminal charge (or the accumulated
|
|
// total) now covers 100% of the booking total, complete the booking so
|
|
// it leaves the admin's Current Appointment view. Runs in its own
|
|
// transaction because the payment-recording transaction above has
|
|
// already committed.
|
|
completeFullyPaidBooking(r.Context(), bookingID)
|
|
|
|
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
|
|
}
|
|
|
|
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: generate a deterministic idempotency key server-side if the client
|
|
// doesn't provide one. The key is based on booking_id + payment_type +
|
|
// amount + card_id (or "new" for new cards), ensuring retries of the same
|
|
// logical charge use the same key while distinct charges get different keys.
|
|
if req.IdempotencyKey == "" {
|
|
cardPart := "new"
|
|
if req.CardID != nil && *req.CardID != "" {
|
|
cardPart = *req.CardID
|
|
}
|
|
req.IdempotencyKey = fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, req.PaymentType, req.Amount, cardPart)
|
|
if len(req.IdempotencyKey) > 45 {
|
|
// Hash long keys to fit Square's 45-char limit
|
|
hash := sha256.Sum256([]byte(req.IdempotencyKey))
|
|
req.IdempotencyKey = fmt.Sprintf("pay-%x", hash[:16])
|
|
}
|
|
}
|
|
|
|
if req.PaymentType == "partial" {
|
|
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get remaining balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking user: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if bookingUserID != userID {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Serialize payment attempts for this booking to prevent concurrent payments
|
|
// across browser tabs or duplicate requests. Uses a PostgreSQL session-level
|
|
// advisory lock so that only one goroutine processes payment for a given
|
|
// booking at a time, even if two requests pass the optimistic status check below.
|
|
//
|
|
// We acquire a dedicated connection from the pool and hold it for the
|
|
// duration of the handler so that lock and unlock use the same connection.
|
|
// Using db.Conn.Exec() for both would be unsafe — each call may get a
|
|
// different pool connection, and pg_advisory_unlock on a different session
|
|
// is a silent no-op, leaking the lock.
|
|
//
|
|
// R6: the lock is acquired with a bounded try-lock loop rather than the
|
|
// blocking pg_advisory_lock. A blocking lock would pin the pool connection
|
|
// for the whole Square round-trip (~30s), so ~4 concurrent same-booking
|
|
// payments would exhaust the default pool and hang every request.
|
|
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again")
|
|
if !lockOK {
|
|
return
|
|
}
|
|
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
|
|
|
|
// Now that we hold the serialization lock, begin a transaction and re-check
|
|
// the booking status inside it. If another request (e.g. from a different tab)
|
|
// already processed a payment and promoted the booking while we were waiting,
|
|
// we see that here.
|
|
tx, txErr := db.Conn.Begin(r.Context())
|
|
if txErr != nil {
|
|
log.Printf("Failed to begin transaction: %v", txErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
var status string
|
|
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status for payment check: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !IsValidBookingStatusForPayment(status) {
|
|
// 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 {
|
|
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" {
|
|
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get remaining balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
|
|
log.Printf("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.
|
|
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
|
|
}
|
|
}
|
|
|
|
// M4: cap pay-early at 100% — a payment that exceeds the booking's remaining
|
|
// balance is rejected instead of silently becoming a tip via buildSplitRecords.
|
|
// A tip is gratuity for service already rendered and must be a deliberate
|
|
// separate action (the frontend shows a dedicated "tip" button once 100% is
|
|
// paid), so an overpayment is always a mistake. 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.
|
|
if req.PaymentType != "tip" {
|
|
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to get remaining balance: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if req.Amount > remainingCents {
|
|
log.Printf("Payment rejected: amount %d exceeds remaining balance %d for booking %s", req.Amount, remainingCents, bookingID)
|
|
http.Error(w, "Payment amount exceeds the remaining balance", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Refresh square_source_id on a reused pending row: this attempt may
|
|
// charge a different token than the failed attempt (one-time cnon:
|
|
// nonces are spent), and the sweep replays the charge from the stored
|
|
// source.
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, 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: req.Amount,
|
|
Currency: "GBP",
|
|
SourceID: sourceID,
|
|
CustomerID: savedCardCustomerID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
ReferenceID: bookingID,
|
|
Note: req.PaymentType,
|
|
BuyerEmail: bookingBuyerEmail,
|
|
VerificationToken: verificationToken,
|
|
}
|
|
|
|
// 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.
|
|
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
|
log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr)
|
|
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), 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", err)
|
|
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
|
return
|
|
}
|
|
|
|
paymentAmount := float64(req.Amount) / 100.0
|
|
|
|
// Step 3: Square succeeded — record the completed payment state in a NEW
|
|
// transaction (split records, VAT, deposit promotion, campaigns). The
|
|
// pending row committed in step 1 already holds the primary idempotency
|
|
// key, so it IS the primary record: update it to 'completed' with the
|
|
// Square payment ID, then insert only the additional -split-N records.
|
|
tx2, txErr := db.Conn.Begin(r.Context())
|
|
if txErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but opening the post-charge transaction failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, txErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := tx2.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback post-charge transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
// Re-check the booking status under the advisory lock: a concurrent
|
|
// cancellation/eviction can move the booking out of a payable state between
|
|
// the pending commit (step 1) and the Square charge completing. A charge
|
|
// landing on a cancelled/lapsed/no-show booking must not be recorded as a
|
|
// completed payment — the cancellation refund path computes refunds from
|
|
// completed payments and would silently exclude this deposit. Mark it
|
|
// failed and alert ops: money was taken at Square and MUST be refunded
|
|
// manually. The pending row is marked 'failed' in the tx below, so
|
|
// idempotency dedup still blocks a second Square charge, but the row no
|
|
// longer shows pending — the frontend's retry gets a 409 Conflict.
|
|
recheckStatus, payable, err := recheckBookingPayable(r.Context(), tx2, bookingID)
|
|
if err != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, bookingID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !payable {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking payment failed; money taken at Square MUST be refunded manually",
|
|
paymentResult.Status, paymentResult.SquarePayID, bookingID, recheckStatus)
|
|
if _, upErr := tx2.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking payment %s failed errored: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, paymentID, upErr)
|
|
}
|
|
if cErr := tx2.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, cErr)
|
|
}
|
|
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Build payment records — may split a single Square charge into
|
|
// a deposit portion (up to 50% of booking total) plus a balance
|
|
// portion, so the refund system can correctly track deposit vs
|
|
// non-deposit money per the deposit protection policy.
|
|
bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID)
|
|
fees := service.CalculateFees(req.Amount, "online")
|
|
primaryRecord := PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: req.PaymentType,
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: paymentAmount,
|
|
SquarePaymentID: &paymentResult.SquarePayID,
|
|
IdempotencyKey: &req.IdempotencyKey,
|
|
Fees: float64(fees) / 100.0,
|
|
UserSavedCardID: savedCardID,
|
|
CreatedAt: clock.Now(),
|
|
UpdatedAt: clock.Now(),
|
|
CreatedBy: &userID,
|
|
}
|
|
|
|
var records []PaymentRecord
|
|
if bErr == nil && bookingInfo != nil {
|
|
records = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount)
|
|
} else {
|
|
if bErr != nil {
|
|
log.Printf("Failed to get booking info for split: %v — using single record", bErr)
|
|
}
|
|
records = []PaymentRecord{primaryRecord}
|
|
}
|
|
|
|
// The primary split record (records[0]) is the committed pending row. Its
|
|
// amount/payment_type may differ from the pending insert (deposit carving
|
|
// in buildSplitRecords), so align the row to the computed values. The VAT
|
|
// fields are cleared so apply_vat_to_payment recomputes on the final amount
|
|
// — the pending record had VAT applied at the pre-split amount.
|
|
primary := records[0]
|
|
if _, upErr := tx2.Exec(r.Context(), `
|
|
UPDATE payments SET
|
|
status = 'completed',
|
|
square_payment_id = $1,
|
|
amount = $2,
|
|
payment_type = $3,
|
|
fees = $4,
|
|
is_vat_applicable = FALSE,
|
|
vat_rate = NULL,
|
|
vat_amount = NULL,
|
|
net_amount = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $5
|
|
`, paymentResult.SquarePayID, primary.Amount, primary.PaymentType, primary.Fees, paymentID); upErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but updating payment %s to completed failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, paymentID, upErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert the additional split records. They carry the derived -split-N
|
|
// idempotency keys, which are new rows; if the split produced only one
|
|
// record, there is nothing more to insert.
|
|
var paymentIDs []string
|
|
for i, rec := range records[1:] {
|
|
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx2, rec, nil)
|
|
if cErr != nil {
|
|
log.Printf("Failed to create split payment record %d/%d: %v", i+2, len(records), cErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
paymentIDs = append(paymentIDs, pid)
|
|
}
|
|
|
|
// Apply VAT to all split records if the business is VAT-registered.
|
|
// Must be inside the transaction so VAT updates are atomic with inserts.
|
|
vatCfg, vatErr := GetVATConfig(r.Context(), tx2)
|
|
if vatErr == nil && vatCfg.IsVATRegistered {
|
|
vatIDs := append([]string{paymentID}, paymentIDs...)
|
|
for _, pid := range vatIDs {
|
|
if _, execErr := tx2.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil {
|
|
log.Printf("Failed to apply VAT to payment %s: %v", pid, execErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Promote deposit to confirmed if total paid meets the 20% threshold.
|
|
// Check is inside the transaction so it sees the just-completed primary.
|
|
// Tip rows are excluded (they are gratuity, not payment toward the booking)
|
|
// as are discount/on_the_house rows (no real money moved).
|
|
var depositMet bool
|
|
if err := tx2.QueryRow(r.Context(), `
|
|
WITH booking_total AS (
|
|
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
|
|
),
|
|
paid_total AS (
|
|
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents
|
|
FROM payments
|
|
WHERE booking_id = $1 AND status = 'completed'
|
|
AND payment_type != 'tip'
|
|
AND payment_method NOT IN ('discount', 'on_the_house')
|
|
)
|
|
SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2)
|
|
FROM booking_total bt, paid_total pt
|
|
`, bookingID).Scan(&depositMet); err != nil {
|
|
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
if depositMet {
|
|
if _, err := tx2.Exec(r.Context(), `
|
|
UPDATE bookings SET status = 'confirmed', updated_at = NOW()
|
|
WHERE id = $1 AND status = 'pending_release'
|
|
`, bookingID); err != nil {
|
|
log.Printf("ALERT: payment recorded but failed to promote booking %s from pending_release: %v", bookingID, err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Apply eligible campaign discounts inside the payment transaction, so
|
|
// atomicity with the payment inserts is guaranteed. The call is idempotent
|
|
// — if discounts were already applied, the duplicate check skips them.
|
|
applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID)
|
|
|
|
if cErr := tx2.Commit(r.Context()); cErr != nil {
|
|
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required",
|
|
paymentResult.Status, paymentResult.SquarePayID, cErr)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
|
ID: paymentID,
|
|
BookingID: bookingID,
|
|
PaymentType: req.PaymentType,
|
|
Status: "completed",
|
|
Amount: req.Amount,
|
|
CardBrand: paymentResult.CardBrand,
|
|
CardLast4: paymentResult.CardLast4,
|
|
ReceiptURL: paymentResult.ReceiptURL,
|
|
CreatedAt: clock.Now().Format(time.RFC3339),
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// applyEligibleCampaignsAtPayment checks and applies any eligible discount
|
|
// campaigns to the booking. Uses the provided transaction so that discount
|
|
// writes are atomic with the caller's payment transaction — if the payment
|
|
// commit fails, the discount writes roll back with it.
|
|
// Skips if the booking already has 2+ completed non-discount payments — this
|
|
// prevents applying new discounts after a customer has already paid, which
|
|
// would create a credit balance or require a refund.
|
|
func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID string, userID string) {
|
|
var bookingTotal float64
|
|
if err := q.QueryRow(ctx, `
|
|
SELECT total_amount FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&bookingTotal); err != nil {
|
|
log.Printf("Failed to calculate booking total for campaign check: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) {
|
|
ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d)
|
|
}
|
|
}
|
|
|
|
// buildSplitRecords determines whether to split a single Square charge into
|
|
// multiple payment records. Before the booking start time, the first 50% of
|
|
// the total is recorded as 'deposit' (protected under the deposit policy) and
|
|
//
|
|
// The first 50% of the booking total (minus any already deposited) is always
|
|
// carved out as a 'deposit' record, regardless of the payment size. The
|
|
// remainder first covers the booking balance then overflows into a 'tip' record.
|
|
//
|
|
// The primary record carries the Square payment ID for refund routing; split
|
|
// records share the same SquarePaymentID so the refund loop can avoid duplicate
|
|
// Square API calls while still creating audit records.
|
|
//
|
|
// MONEY INVARIANT (deliberately kept exact): the returned records always
|
|
// partition paymentAmount — deposit + balance + tip === paymentAmount exactly
|
|
// (every component is rounded to the cent and the parts are derived from one
|
|
// another, so no rounding residue exists). The sum of the split records can
|
|
// therefore never exceed the amount actually charged at Square. When deposit
|
|
// AND balance are both zero (booking already fully paid) the tip record alone
|
|
// carries the whole payment — the primary must NOT be appended as well, or the
|
|
// amount would be recorded twice (see the tip block below).
|
|
//
|
|
// Discounts do NOT change this: a discount is applied at payment time as a
|
|
// SEPARATE ledger payment row (payment_method='discount'), and GetBookingPaymentInfo
|
|
// excludes those rows from TotalPaid (as do the refund and deposit-threshold
|
|
// computations). buildSplitRecords therefore runs against the full booking
|
|
// total and the REAL money already paid, so a discounted booking can at worst
|
|
// over-allocate toward balance and under-allocate toward tip (a bookkeeping
|
|
// simplification, not an overcharge) — the partition still equals the charged
|
|
// amount. See TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge.
|
|
func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord {
|
|
// After the booking starts there is no deposit protection window —
|
|
// record the payment as a single entry with its original type.
|
|
if clock.Now().After(info.StartTime) {
|
|
return []PaymentRecord{primary}
|
|
}
|
|
|
|
// 1. Deposit portion: up to 50% of total, minus what's already been paid.
|
|
maxDeposit := info.TotalAmount * ProtectedDepositMaxPct
|
|
remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid)
|
|
depositAmount := math.Min(paymentAmount, remainingDepositRoom)
|
|
depositAmount = math.Round(depositAmount*100) / 100
|
|
|
|
// 2. Remaining after deposit.
|
|
remainingAfterDeposit := math.Round((paymentAmount-depositAmount)*100) / 100
|
|
|
|
// 3. Balance portion: covers whatever is still owed on the booking.
|
|
bookingRemaining := math.Max(0, info.TotalAmount-info.TotalPaid-depositAmount)
|
|
balancePortion := math.Min(remainingAfterDeposit, bookingRemaining)
|
|
balancePortion = math.Round(balancePortion*100) / 100
|
|
|
|
// 4. Tip: anything beyond the booking total.
|
|
tipPortion := math.Round((remainingAfterDeposit-balancePortion)*100) / 100
|
|
|
|
var records []PaymentRecord
|
|
splitIdx := 0
|
|
|
|
// 1. Deposit portion (always present when there's deposit room left).
|
|
if depositAmount > 0.004 {
|
|
dep := primary
|
|
dep.PaymentType = "deposit"
|
|
dep.Amount = depositAmount
|
|
records = append(records, dep)
|
|
splitIdx++
|
|
}
|
|
|
|
// 2. Balance / partial / full record — covers the remaining booking total.
|
|
if balancePortion > 0.004 {
|
|
bal := primary
|
|
bal.Amount = balancePortion
|
|
bal.Fees = 0
|
|
if primary.IdempotencyKey != nil {
|
|
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
|
|
bal.IdempotencyKey = &k
|
|
}
|
|
totalPaidAfterBalance := info.TotalPaid + depositAmount + balancePortion
|
|
switch {
|
|
case totalPaidAfterBalance >= info.TotalAmount && totalPaidAfterBalance-balancePortion > 0:
|
|
bal.PaymentType = "balance"
|
|
case totalPaidAfterBalance >= info.TotalAmount:
|
|
bal.PaymentType = "full"
|
|
default:
|
|
bal.PaymentType = "partial"
|
|
}
|
|
records = append(records, bal)
|
|
splitIdx++
|
|
}
|
|
|
|
// 3. Tip record — overflow beyond the booking total. Appended BEFORE the
|
|
// primary fallback below: when BOTH the deposit and balance portions are
|
|
// zero (deposit room exhausted AND the booking already fully paid — e.g. a
|
|
// discounted booking whose TotalPaid, which excludes discount rows, has
|
|
// reached the full total), the tip record carries the ENTIRE payment.
|
|
// Appending the primary first would double-count the charged amount
|
|
// (primary at the full amount + tip at the same full amount).
|
|
if tipPortion > 0.004 {
|
|
tip := primary
|
|
tip.PaymentType = "tip"
|
|
tip.Amount = tipPortion
|
|
tip.Fees = 0
|
|
splitIdx++
|
|
if primary.IdempotencyKey != nil {
|
|
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
|
|
tip.IdempotencyKey = &k
|
|
}
|
|
records = append(records, tip)
|
|
}
|
|
|
|
// Defensive fallback: nothing was appended (deposit, balance, AND tip all
|
|
// zero — impossible given paymentAmount is validated > 0 upstream, so this
|
|
// is a pure safety net). The primary is still a valid single record.
|
|
if len(records) == 0 {
|
|
primary.Fees = 0
|
|
records = append(records, primary)
|
|
}
|
|
return records
|
|
}
|
|
|
|
// 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()
|
|
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
log.Printf("Failed to create payment method: %v", err)
|
|
http.Error(w, "Failed to add card", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(card); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
|
// Defense-in-depth: the route is mounted under mw.RequireAdmin, but this
|
|
// in-handler check keeps refund access admin-only even if the route is ever
|
|
// re-registered on a non-admin router (S-1).
|
|
if !isAdminRequest(r) {
|
|
http.Error(w, "Admin access required", http.StatusForbidden)
|
|
return
|
|
}
|
|
paymentID := chi.URLParam(r, "payment_id")
|
|
if paymentID == "" || !validators.IsValidID(paymentID) {
|
|
http.Error(w, "Payment not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || adminID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req RefundRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode refund request: %v", err)
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 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) > 45 {
|
|
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
|
|
}
|
|
|
|
if payment.SquarePaymentID == nil {
|
|
http.Error(w, "Payment has no Square reference", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Idempotency key for the refund. When the client supplies one (a UUID
|
|
// generated per distinct refund attempt and REUSED on retry), the key is
|
|
// hashed and truncated: Square's idempotency-key limit is 45 chars, and
|
|
// paymentID (12) + "-refund-" (8) + a full 36-char UUID (56 total) would
|
|
// be rejected with a 400. The hash stays deterministic, so a same-key
|
|
// retry still dedups.
|
|
//
|
|
// When the client sends NO key, the fallback must be UNIQUE per refund
|
|
// attempt: the old amount-derived key (paymentID + "-refund-" + amount)
|
|
// collided on two DISTINCT partial refunds of the same amount, and the
|
|
// dedup lookup silently swallowed the second. The fallback appends a fresh
|
|
// crypto/rand hex suffix so distinct same-amount refunds never collide;
|
|
// a lost-response no-key retry still resumes via the (payment_id, amount)
|
|
// pending fallback below. 6 random bytes (12 hex chars) keeps the full key
|
|
// ≤45 chars even for a 9-digit pence amount.
|
|
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10) + "-" + randomHexSuffix(6)
|
|
if req.IdempotencyKey != "" {
|
|
ikHash := sha256.Sum256([]byte(req.IdempotencyKey))
|
|
idempotencyKey = paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24]
|
|
}
|
|
|
|
// Serialize refund attempts per payment to prevent two concurrent refunds
|
|
// both passing the over-refund guard and both charging Square. Mirrors the
|
|
// tip/gift-card advisory-lock pattern. Bounded try-lock (R6) so a
|
|
// contended lock never blocks the pool across the Square round-trip.
|
|
refundLockKey := paymentID
|
|
pinConn, err := db.Conn.Acquire(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to acquire connection for refund lock: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer pinConn.Release()
|
|
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:refund:"+refundLockKey)
|
|
if err != nil {
|
|
log.Printf("Failed to acquire refund serialization lock for %s: %v", paymentID, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !lockOK {
|
|
log.Printf("Refund serialization lock for %s not acquired within bound — a refund is already in progress", paymentID)
|
|
http.Error(w, "Refund in progress, try again", http.StatusConflict)
|
|
return
|
|
}
|
|
defer func() {
|
|
if _, err := pinConn.Exec(context.Background(), `
|
|
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
|
|
`, refundLockKey); err != nil {
|
|
log.Printf("Failed to release refund serialization lock for %s: %v", paymentID, err)
|
|
}
|
|
}()
|
|
|
|
// Dedup/resume (inside the lock): a same-key retry of a completed or
|
|
// in-flight (pending) refund must not create a second Square refund. Runs
|
|
// BEFORE the over-refund guard so a resuming refund never evaluates its own
|
|
// pending row against the guard.
|
|
var existingRefundID sql.NullString
|
|
var existingRefundStatus sql.NullString
|
|
var existingRefundAmount sql.NullFloat64
|
|
var existingRefundOrigin sql.NullString
|
|
var existingRefundReason sql.NullString
|
|
var existingRefundCreatedAt sql.NullTime
|
|
var existingRefundKey sql.NullString
|
|
err = db.Conn.QueryRow(r.Context(), `
|
|
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
|
|
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
|
|
switch {
|
|
case err == nil && existingRefundStatus.String == "completed":
|
|
// 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.
|
|
refundableCents, err := service.GetBookingRefundableAmountCents(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 > refundableCents {
|
|
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundableCents, 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
|
|
amountCents 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,
|
|
amountCents: 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.amountCents,
|
|
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 = fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1)
|
|
if len(idempotencyKey) > 45 {
|
|
// Hash long keys to fit Square's 45-char limit — the hash stays
|
|
// deterministic, so a retry still derives the same key.
|
|
hash := sha256.Sum256([]byte(idempotencyKey))
|
|
idempotencyKey = fmt.Sprintf("tip-%x", hash[:16])
|
|
}
|
|
}
|
|
|
|
// Check idempotency inside the transaction.
|
|
// Only short-circuit when the existing record is 'completed'. A 'pending'
|
|
// record means the previous Square call failed — returning it as 200 would
|
|
// show a success toast without ever charging. Re-attempt the charge below
|
|
// with the same idempotency key (Square dedups safely) and reuse the
|
|
// existing record.
|
|
var existingID sql.NullString
|
|
var existingBookingID sql.NullString
|
|
var existingPaymentType sql.NullString
|
|
var existingStatus sql.NullString
|
|
var existingAmount sql.NullFloat64
|
|
var existingCreatedAt sql.NullTime
|
|
|
|
err = tx.QueryRow(r.Context(), `
|
|
SELECT id, booking_id, payment_type, status, amount, created_at
|
|
FROM payments
|
|
WHERE booking_id = $1 AND idempotency_key = $2
|
|
`, bookingID, idempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt)
|
|
|
|
paymentID := ""
|
|
reusePendingRecord := false
|
|
switch {
|
|
case err == nil && existingStatus.String == "completed":
|
|
// Idempotent dedup — return the already-completed payment.
|
|
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
|
ID: existingID.String,
|
|
BookingID: existingBookingID.String,
|
|
PaymentType: existingPaymentType.String,
|
|
Status: existingStatus.String,
|
|
Amount: int64(math.Round(existingAmount.Float64 * 100)),
|
|
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
|
|
}); err != nil {
|
|
log.Printf("Failed to encode JSON response: %v", err)
|
|
}
|
|
return
|
|
case err == nil && existingStatus.String == "pending":
|
|
// Previous Square call failed — reuse the pending record and re-attempt.
|
|
// Guard the amount: a retry with a different amount must not mutate the
|
|
// original record (books, VAT, refund caps) or silently charge the new
|
|
// amount against the old record. Compare in pence via math.Round — the
|
|
// stored pounds value is float64, so int64(pounds*100) truncation would
|
|
// reject legitimate same-amount retries for non-exact values (e.g. £1.14
|
|
// stored as 1.1399999999999999 → int64 gives 113 ≠ 114).
|
|
if int64(math.Round(existingAmount.Float64*100)) != req.Amount {
|
|
log.Printf("Tip retry amount mismatch: pending record %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), req.Amount)
|
|
http.Error(w, "Amount does not match the pending tip payment", http.StatusBadRequest)
|
|
return
|
|
}
|
|
paymentID = existingID.String
|
|
reusePendingRecord = true
|
|
case err == nil && existingStatus.String == "failed":
|
|
// Swept as stale (>24h, past Square's key retention) or definitively
|
|
// rejected. A retry can no longer be replayed against Square without
|
|
// risking a second charge — reject cleanly instead of inserting a new
|
|
// pending row that 500s on the idempotency_key UNIQUE constraint (R2).
|
|
log.Printf("Tip retry rejected: pending record %s was marked failed", existingID.String)
|
|
http.Error(w, "This tip payment previously failed and can no longer be retried", http.StatusConflict)
|
|
return
|
|
case err != nil && !errors.Is(err, pgx.ErrNoRows):
|
|
log.Printf("Failed to check tip idempotency: %v", err)
|
|
}
|
|
|
|
if !reusePendingRecord {
|
|
record := PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "tip",
|
|
PaymentMethod: "online_square",
|
|
Status: "pending",
|
|
Amount: float64(req.Amount) / 100.0,
|
|
IdempotencyKey: &idempotencyKey,
|
|
Fees: 0,
|
|
UserSavedCardID: savedCardID,
|
|
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 {
|
|
// Refresh square_source_id on a reused pending row: this attempt may
|
|
// charge a different token than the failed attempt (one-time cnon:
|
|
// nonces are spent), and the sweep replays the charge from the stored
|
|
// source.
|
|
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, 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,
|
|
}
|
|
|
|
// 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.
|
|
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 _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), 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", 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. "tip-", "till-") where the client did not supply one. Client-supplied
|
|
// keys handle retry dedup; this fallback only needs uniqueness so two
|
|
// legitimate identical requests never collapse on the same key. Deliberately
|
|
// NOT derived from request fields — two identical requests would hash to the
|
|
// same key (the "tip" fallback must not dedupe two distinct equal tips on one
|
|
// booking). Shared by the tip/till flows, which used to carry two identical
|
|
// copies (uniqueTipKey/uniqueTillKey) differing only in the prefix string.
|
|
func uniqueChargeKey(prefix string) string {
|
|
return prefix + rand.Text()
|
|
}
|
|
|
|
// randomHexSuffix returns n random bytes hex-encoded (2n hex chars) from
|
|
// crypto/rand, used to disambiguate idempotency fallback keys that would
|
|
// otherwise collide on deterministic inputs (e.g. the no-client-key refund
|
|
// key). Falls back to a masked monotonic timestamp if the OS entropy source
|
|
// errors — effectively impossible on Linux (crypto/rand.Read blocks until
|
|
// entropy is available) — keeping the same width so the key stays within
|
|
// Square's 45-char idempotency-key limit.
|
|
func randomHexSuffix(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return fmt.Sprintf("%0*x", 2*n, time.Now().UnixNano()&(int64(1)<<(8*int64(n))-1))
|
|
}
|
|
return fmt.Sprintf("%x", b)
|
|
}
|