Files
Crussell/backend/handlers/payments/handlers.go
T
popertots 5dae0bba08 feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
2026-08-22 00:34:50 +01:00

5256 lines
241 KiB
Go

package payments
import (
"context"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/internal/validators"
"crussell/mw"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"math"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// insertAdminAuditCharge records an admin-initiated saved-card charge in
// admin_audit_log (MEDIUM-3a). Mirrors the balance_check audit in
// giftcards.go:1239-1243 — same table, same columns, same best-effort
// non-fatal failure handling. The insert runs in its OWN transaction (a
// savepoint in the test harness) so an audit-write failure — e.g. a synthetic
// admin id in tests violating the admin_id FK — rolls back only the audit
// write and can never abort the caller's transaction or a completed charge.
func insertAdminAuditCharge(ctx context.Context, adminID, targetUserID, action string, details map[string]any) {
detailsJSON, err := json.Marshal(details)
if err != nil {
log.Printf("Failed to marshal admin_audit_log details (non-critical): %v", err)
return
}
var target any
if targetUserID != "" {
target = targetUserID
}
auditTx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
return
}
defer func() {
if err := auditTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback admin audit transaction", "err", err)
}
}()
if _, err := auditTx.Exec(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, $2, $3, $4::jsonb)
`, adminID, action, target, string(detailsJSON)); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
return
}
if err := auditTx.Commit(ctx); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
}
}
// insertTwoFAFallbackAudit records that a saved-card charge was authorized by
// the homegrown 2FA BACKUP because SCA was unavailable (the charge carried no
// Square verification_token). It is the strict-audit half of the SCA-primary /
// 2FA-backup decision model: every 2FA-fallback authorization of a saved-card
// charge must land an admin_audit_log row (action_type '2fa_fallback_charge',
// details {sca_performed:false, fallback_reason:"verification_unavailable"}) so
// the operator can distinguish SCA-authorized charges from fallback-authorized
// ones. Mirrors insertAdminAuditCharge's best-effort, own-transaction,
// non-fatal failure handling (a failed audit write can never abort a completed
// charge). For customer-initiated online charges the actor (adminID) is the
// customer's own userID; for admin surfaces it is the admin from request
// context — the caller passes accordingly. cardLast4 and referenceID are filled
// by the caller at charge success (paymentResult.CardLast4, booking/till/payment
// id), where they are actually known.
func insertTwoFAFallbackAudit(ctx context.Context, adminID, userID, cardLast4, referenceID, notes string) {
insertAdminAuditCharge(ctx, adminID, userID, "2fa_fallback_charge", map[string]any{
"sca_performed": false,
"fallback_reason": "verification_unavailable",
"card_last4": cardLast4,
"reference_id": referenceID,
"notes": notes,
})
}
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"`
// verification_code: the customer's current 2FA one-time code (B10). An
// enforced environment charges a saved card only when this matches the
// customer's pending code; the operator relays it from the [2FA] log/email.
VerificationCode string `json:"verification_code,omitempty"`
// verification_token: Square 3DS/SCA verification token returned by the
// frontend's buyer-verification flow (tokenizeWithVerification). When a
// saved-card charge carries one, SCA has been performed by the issuer and
// the homegrown 2FA gate is SKIPPED (SCA is primary). Forwarded verbatim
// to Square on the CreatePaymentReq.
VerificationToken *string `json:"verification_token,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"`
// VerificationCode is the customer's current 2FA one-time code (B10): an
// enforced environment charges a saved card only when this matches the
// customer's pending code.
VerificationCode string `json:"verification_code,omitempty"`
// ConfirmOverflowTip acknowledges that an overpayment beyond the booking's
// remaining balance will be recorded as a tip (M7). Tips cannot be paid in
// advance, so a pre-start overpayment is rejected with 400
// overflow_tip_confirmation_required unless the client sets this flag; the
// frontend prompts and resends with it. B12: post-start overpayments
// require the flag too.
ConfirmOverflowTip bool `json:"confirm_overflow_tip"`
}
type RefundRequest struct {
Amount int64 `json:"amount"`
Reason string `json:"reason"`
// Optional client-generated idempotency key. Two DISTINCT refunds of the
// same amount against the same payment must not collide on the default
// amount-derived key (the dedup lookup would swallow the second refund).
// The frontend sends a UUID generated per refund attempt and reuses it on
// retry, mirroring the tip-flow pattern. Cap ≤45 (Square's /v2/refunds limit).
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
}
type CreateTipPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
VerificationToken *string `json:"verification_token,omitempty"`
// VerificationCode is the customer's current 2FA one-time code (B10): an
// enforced environment charges a saved card only when this matches the
// customer's pending code.
VerificationCode string `json:"verification_code,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
}
// maxTerminalTipPence caps the gratuity portion of a tip-enabled terminal
// checkout (B3): the frontend embeds the tip in the charge amount, so the
// booking portion must still not exceed the remaining obligation. £50 is a
// generous single-tip bound for this business; the total charge is capped at
// remaining + this bound.
const maxTerminalTipPence = int64(5000) // £50
// clampTerminalChargeToRemainingBalance caps a requested terminal charge at the
// booking's remaining obligation (B3). The admin "Take Payment" PaymentModal
// sends subtotal - discounts - campaignDiscountPence, which ignores PRIOR
// payments; recording that verbatim would overcharge the customer (or carve
// the excess into an unintended tip). The clamp keeps the recorded/charged
// money within the actual obligation and returns whether the amount was
// reduced. Callers MUST have serialized the attempt (advisory lock or the
// booking FOR UPDATE row lock) so the remaining-balance read races no
// concurrent same-booking payment. The frontend must handle the discrepancy
// between the amount it displayed and the clamped amount that was charged.
//
// A fully-paid booking (remaining <= 0) is clamped to 0 (clamped=true,
// effective=0): no obligation remains, so recording the requested amount
// verbatim would overcharge a customer who already paid in full. The callers
// reject the resulting zero-charge with 400 "already fully paid" — the only
// legitimate money on a fully-paid booking is an EXPLICIT tip, which the
// tip-enabled terminal path handles separately (it caps the total at
// remaining + maxTerminalTipPence instead of clamping here).
func clampTerminalChargeToRemainingBalance(ctx context.Context, bookingID string, amount int64) (effective, remaining int64, clamped bool, err error) {
remaining, err = NewPaymentService().GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
return amount, 0, false, err
}
if amount > remaining && remaining > 0 {
return remaining, remaining, true, nil
}
if remaining <= 0 {
return 0, remaining, true, nil
}
return amount, remaining, false, nil
}
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
}
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
// enforces the M4 "tips only after the service starts" gate, and the
// tip-enabled terminal overflow carve (B3, tip_enabled) records explicit
// gratuity as its own payment_type='tip' row. A bare payment_type='tip'
// here would record the ENTIRE charge as a tip — and every "is paid"
// computation excludes tip rows (paid_total, GetBookingPaymentInfo,
// bookingIsFullyPaid, GetBookingRefundableAmountPence) — so the booking
// would never be credited and a later legitimate charge would double-collect.
// Reject it BEFORE any charge-path branch (cash/giftcard, saved_card,
// terminal checkout) so all four sub-paths are closed at once.
if req.PaymentType == "tip" {
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
return
}
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
}
// B3: clamp the recorded amount to the booking's remaining obligation
// unless the customer explicitly requested a tip (tip_enabled) — mirror
// the card-terminal tip bound below: the booking portion can never
// exceed what is owed, and the tip portion can never exceed
// maxTerminalTipPence. The booking row FOR UPDATE lock above serializes
// concurrent cash/giftcard payments on this booking, so this read races
// no same-method payment. A fully-paid no-tip booking is rejected below
// (nothing left to record).
var remaining int64
if req.TipEnabled {
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if remErr != nil {
log.Printf("Failed to compute remaining balance for tip-enabled terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, remErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
remaining = remainingPence
maxChargePence := remainingPence + maxTerminalTipPence
if amount > maxChargePence {
log.Printf("Terminal %s payment for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", *req.PaymentMethod, bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
amount = maxChargePence
}
} else {
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject rather than record a phantom £0 payment —
// an overpayment is handled manually at the counter, not minted
// into the ledger.
log.Printf("Terminal %s payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", *req.PaymentMethod, bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped {
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
amount = effectiveAmount
}
}
// M4 (mirror of the card-terminal carve at sweep.go): when the customer
// explicitly requested a tip, any part of the charged amount beyond the
// remaining booking value is gratuity and must be recorded as its own
// payment_type='tip' row — never absorbed into the booking payment
// (which would over-credit the booking) nor rejected. The booking
// portion keeps the requested payment type, exactly as the non-tip
// cash/giftcard flow records it.
tipPortion := int64(0)
bookingPortion := amount
if req.TipEnabled && remaining < amount {
tipPortion = amount - remaining
bookingPortion = remaining
}
amountPounds := float64(amount) / 100.0
bookingPortionPounds := float64(bookingPortion) / 100.0
tipPounds := float64(tipPortion) / 100.0
var paymentID string
if *req.PaymentMethod == "cash" {
if bookingPortionPounds > 0.004 {
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, bookingPortionPounds, 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)
}
// The tip carve is recorded as its own 'tip' row so the booking
// portion is the only money that counts toward the obligation.
// When the charge is tip-only (fully-paid booking), the tip row is
// the ONLY record and its id is returned as the checkout id,
// mirroring the card-terminal carve (primary := records[0]).
if tipPounds > 0.004 {
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "cash",
Status: "completed",
Amount: tipPounds,
IdempotencyKey: &tipKey,
CreatedBy: &adminID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
}, nil)
if tipErr != nil {
log.Printf("Failed to create cash tip payment record: %v", tipErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
paymentID = tipID
}
}
} 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
}
// The gift-card source funds the full charged amount (booking
// portion + tip). The primary row records the booking portion; the
// tip carve is recorded as its own 'tip' row sourced from the same
// gift card (C3 source-of-funds tracking), so the booking portion
// is the only money that counts toward the obligation.
bookingPayID := ""
if bookingPortionPounds > 0.004 {
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, bookingPortionPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&bookingPayID)
if err != nil {
log.Printf("Failed to create giftcard payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
paymentID = bookingPayID
}
if tipPounds > 0.004 {
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "giftcard",
Status: "completed",
Amount: tipPounds,
IdempotencyKey: &tipKey,
CreatedBy: &adminID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
}, giftCardPaymentID)
if tipErr != nil {
log.Printf("Failed to create giftcard tip payment record: %v", tipErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
paymentID = tipID
}
}
// 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. Applied to the booking-portion
// payment only — a tip record is never VAT-applicable.
if bookingPayID != "" {
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)", bookingPayID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to giftcard payment %s: %v", bookingPayID, 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
}
// SCA verification token (if any) — extracted once, used both by the
// 2FA gate below (a present token skips the gate: SCA-primary) and
// forwarded to Square on the CreatePaymentReq.
terminalVerificationToken := ""
if req.VerificationToken != nil {
terminalVerificationToken = *req.VerificationToken
}
// 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).
//
// The fallback is derived from the REQUEST amount (before the B3 clamp):
// a retry sends the same request and must derive the same key to hit the
// dedup SELECT below, and the clamp runs AFTER that SELECT's
// short-circuits — so a retry of an already-completed payment on a now
// fully-paid booking still dedups instead of being clamped/rejected.
scKey := req.IdempotencyKey
if scKey == "" {
// The candidate is built verbatim, then routed through
// truncateIdempotencyKey so it can never exceed Square's 45-char
// /v2/payments limit (a 400 would strand the payment). The truncation
// is deterministic, so identical inputs still derive the SAME key and
// the dedup SELECT below keeps working; candidates at or under 45
// chars (the current bookingID+cardID shape) pass through byte-identical.
scKey = truncateIdempotencyKey("sc", bookingID+"-sc-"+req.PaymentType+"-"+strconv.FormatInt(amount, 10)+"-"+*req.UserSavedCardID)
}
// Idempotency switch inside the lock: completed → dedup; pending →
// reuse (re-attempt Square with the same key, which dedups Square-side);
// failed → clean rejection. Runs BEFORE the B3 clamp so a same-key
// retry of a completed payment (on a now fully-paid booking) returns
// the existing result instead of being clamped/rejected — the money
// already moved, so the amount is no longer material.
var existingID, existingStatus sql.NullString
var existingAmount sql.NullFloat64
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount FROM payments WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, scKey).Scan(&existingID, &existingStatus, &existingAmount)
paymentID := ""
switch {
case err == nil && existingStatus.String == "completed":
// Dedup — return the existing completed payment. First RE-VALIDATE
// the matched row's refund state (same guard as the CreateBookingPayment
// completed-dedup branches): a refunded payment's money is no longer
// live, so reporting it as "success" would let a same-key retry claim
// a payment that was already returned to the customer.
if refunded, rErr := paymentHasLiveRefund(r.Context(), db.Conn, existingID.String); rErr != nil {
log.Printf("Failed to re-validate saved-card dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: saved-card payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, scKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingID.String,
Status: "COMPLETED",
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingStatus.String == "pending":
// Reuse the pending record: a prior attempt's Square outcome is
// unknown. The amount-match guard runs below, AFTER the clamp, so
// the clamped retry amount is compared against the original record.
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
}
// B3: clamp the amount to the booking's remaining obligation. The
// advisory lock above serializes all same-booking payment attempts, so
// this read races no concurrent charge. Runs AFTER the idempotency
// short-circuits so a same-key retry of a completed payment (booking
// now fully paid) dedups above instead of being rejected here. A fully-
// paid booking is rejected below (nothing left to charge).
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for saved-card payment on booking %s: %v", bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject before any pending row or Square charge —
// charging £0 (or the requested overcharge) on a fully-paid booking
// is never legitimate.
log.Printf("Saved-card payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped {
log.Printf("Saved-card payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", bookingID, amount, effectiveAmount)
amount = effectiveAmount
}
// Pending-reuse amount-match guard (moved after the clamp so the
// CLAMPED retry amount is compared against the original pending record,
// which was itself created from the clamped amount): a retry with a
// different effective amount must not reuse the old record's charge.
if paymentID != "" {
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
}
}
// 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. Runs
// AFTER the idempotency dedup/reuse switch above: a same-key retry of
// an already-completed payment short-circuits there and returns the
// existing result WITHOUT demanding a fresh code — no new money moves,
// so no new authorization is needed. consume=!reusePendingRecord
// (finding 4): a FRESH charge verifies WITH consumption — the code is
// single-use at the gate, closing the TOCTOU where a verified-but-
// unconsumed code could authorize a second charge — and a pending-reuse
// retry verifies WITHOUT consuming, so a retry that fails again keeps
// its code for one more attempt (the completed-charge transaction
// consumes it on terminal success).
reusePendingRecord := paymentID != ""
// SCA-primary / 2FA-backup gate (C5): charging the customer's SAVED card
// requires authorization when the feature is enforced. Gate on the
// card's owner — the booking's user, not the admin. A charge carrying a
// Square verification_token (SCA performed) passes without 2FA; a
// token-less charge falls back to the customer's 2FA code (single-use
// via consume) and the caller records a strict fallback audit row on
// the charge's success. New-card/terminal paths are not gated. Runs
// AFTER the idempotency dedup/reuse switch above: a same-key retry of
// an already-completed payment short-circuits there and returns the
// existing result WITHOUT demanding a fresh code — no new money moves,
// so no new authorization is needed. consume=!reusePendingRecord
// (finding 4): a FRESH charge verifies WITH consumption — the code is
// single-use at the gate, closing the TOCTOU where a verified-but-
// unconsumed code could authorize a second charge — and a pending-reuse
// retry verifies WITHOUT consuming, so a retry that fails again keeps
// its code for one more attempt (the completed-charge transaction
// consumes it on terminal success).
twoFAFallbackUsed := false
if bookingUserID.Valid {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, terminalVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
}
// B13: the pre-charge discount SET for the post-charge apply-time
// re-check. The online booking path (CreateBookingPayment) keeps the set
// computed BEFORE the charge so applyEligibleCampaignsAtPayment can
// detect a campaign exhausted by a concurrent redemption between the
// frontend's preview and the apply-time re-check; the saved-card path
// snapshots it here, under the same advisory lock, before the Square
// charge.
var preChargeDiscounts []EligibleDiscount
// Pending-first: insert a pending payment record, commit, then charge.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if paymentID == "" {
record := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
PaymentMethod: "online_square",
Status: "pending",
Amount: float64(amount) / 100.0,
IdempotencyKey: &scKey,
UserSavedCardID: req.UserSavedCardID,
SquareSourceID: &sourceID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &adminID,
}
// M4: the service's record function sets every column the inline
// INSERT previously left to defaults (fees, VAT fields, etc.), so
// the pending row is created the same way every other flow creates
// its payment records.
var insertErr error
paymentID, insertErr = service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
if insertErr != nil {
log.Printf("Failed to insert pending saved-card payment: %v", insertErr)
_ = tx.Rollback(r.Context())
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
} else {
// Reused pending row: same immutability rule as the booking/tip
// reuse paths. square_source_id is refreshed ONLY for snapshot-less
// legacy rows; when the row already carries the original
// square_request_snapshot it is left untouched so the sweep's
// by-key replay keeps matching the FIRST attempt's body. Saved-card
// ccof sources are stable, so this is mostly latent, but keeping
// the snapshot immutable is money-safe (see the booking reuse
// comment above).
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
log.Printf("Failed to update square_source_id on reused saved-card payment %s: %v", paymentID, srcErr)
}
}
// B13: snapshot the pre-charge discount set (read-only, under the
// advisory lock) so the post-charge re-check can surface a campaign
// exhausted by a concurrent redemption (see the declaration above).
var bookingTotal float64
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to load booking total for discount computation: %v", err)
}
preChargeDiscounts = ComputeEligibleDiscounts(r.Context(), tx, bookingID, bookingUserID.String, bookingTotal)
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,
VerificationToken: terminalVerificationToken,
// MIT (merchant-initiated): the admin charging the customer's SAVED
// card (admin "Charge Saved Card") is a merchant-initiated stored-
// credential charge — NOT the cardholder. customer_initiated=false
// classifies it MIT for Square: no SCA is demanded and no liability
// shift applies, which is the correct treatment for an operator-
// initiated charge (any issuer challenge is handled via
// verification_token when the frontend performs one).
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
// request on key reuse, and a reconstructed body returns
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot
// is written ONLY when the row has none: it records the FIRST attempt's
// body, which stays immutable so a reused pending row never redirects
// the sweep's replay away from the original charge (same rule as the
// booking/tip paths — see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for saved-card payment %s: %v", paymentID, mErr)
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for saved-card payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr)
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to process saved-card payment: %v (error_code=%q)", err, square.ErrorCode(err))
// The gate consumed the 2FA code for a fresh saved-card charge —
// re-issue so the same-key retry has a live code to verify
// (mirrors CreateBookingPayment's post-failure re-issue, finding
// 4). Only runs when the gate actually ran (the booking's user is
// known); a pending-reuse retry verified WITHOUT consuming, so a
// fresh code never invalidates anything that still needs verifying.
if bookingUserID.Valid {
reissueTwoFACodeAfterFailedCharge(r.Context(), bookingUserID.String)
}
// SCA-required failures (Square demands buyer verification) must
// surface the structured verification_required body so the frontend
// triggers the 3DS challenge instead of treating the payment as a
// plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
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
}
// B13: apply eligible campaign discounts at charge time (mirroring the
// online booking path at CreateBookingPayment). This runs INSIDE the
// same transaction as the completed flip, BEFORE the flip, so the
// capDiscountToRemainingObligation headroom still counts the in-flight
// charge as the pending row (F1 — an over-credit can never be minted)
// and ComputeEligibleDiscounts' 2+-payments guard sees the same
// completed-payment count the online path sees. The apply is idempotent
// (ComputeEligibleDiscounts excludes already-recorded sources). A
// campaign exhausted by a concurrent redemption between the frontend's
// preview and this apply-time re-check surfaces the same
// campaignExhaustedAtApplyError → campaign_fully_redeemed path the
// booking path returns, instead of silently skipping the discount and
// leaving the booking underpaid. The completion side-effects
// (completeFullyPaidBooking → ApplyBookingCompletionSideEffects) skip
// re-application via their already-recorded guards.
campaignLostPence := int64(0)
var campaignLostID string
if applyErr := applyEligibleCampaignsAtPayment(r.Context(), recheckTx, bookingID, bookingUserID.String, preChargeDiscounts); applyErr != nil {
var exErr *campaignExhaustedAtApplyError
if errors.As(applyErr, &exErr) {
campaignLostPence = exErr.lostPence
campaignLostID = exErr.campaignID
log.Printf("B13: campaign %s exhausted between preview and apply for booking %s — lost discount %d pence; payment will complete and the difference will be returned to the customer", campaignLostID, bookingID, campaignLostPence)
} else {
log.Printf("Failed to apply eligible campaigns for booking %s: %v", bookingID, applyErr)
}
}
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
}
// MEDIUM-2: the charge reached its terminal SUCCESS state — consume the
// verified 2FA code now, INSIDE the transaction that records the
// completed charge (the gate verified without consuming so a failed
// charge would not burn the code). A failure here fails the whole
// transaction (the row stays pending and the sweep reconciles), which is
// the same known failure mode as any other post-charge tx error.
if bookingUserID.Valid {
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, bookingUserID.String); consErr != nil {
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, bookingUserID.String, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// B14: apply VAT to the saved-card terminal charge, inside the same
// transaction as the completed flip (like the booking path at 2021-2028
// and the cash path at 397). Without this the saved-card branch never
// called apply_vat_to_payment and the row kept is_vat_applicable=FALSE
// with no vat_rate/vat_amount/net_amount — a real VAT-reporting loss for
// a VAT-registered business. ApplyVATToBookingPayment reads config and
// skips discount/on_the_house/tip rows defensively.
ApplyVATToBookingPayment(r.Context(), recheckTx, paymentID)
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
}
// MEDIUM-3a: record the admin-initiated saved-card charge in
// admin_audit_log (mirroring giftcards.go's balance_check audit). Runs
// best-effort AFTER the money transaction commits so an audit-write
// failure can never roll back a completed charge.
if bookingUserID.Valid {
insertAdminAuditCharge(r.Context(), adminID, bookingUserID.String, "saved_card_charge", map[string]any{
"booking_id": bookingID,
"payment_id": paymentID,
"amount": float64(amount) / 100.0,
"card_last4": paymentResult.CardLast4,
"square_payment_id": paymentResult.SquarePayID,
})
// The 2FA BACKUP authorized this token-less saved-card charge
// (SCA was unavailable) — record the strict fallback audit row.
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), adminID, bookingUserID.String, paymentResult.CardLast4, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)")
}
}
// F6: a fully-paid saved-card charge completes the booking exactly like
// the terminal path (recordTerminalPaymentTx → completeFullyPaidBooking,
// sweep.go:1632). Runs in its OWN transaction after the status commit
// above, so the completion side-effects (loyalty, deposits_required) are
// atomic and a booking paid in full by a saved-card charge leaves the
// admin's Current Appointment view. Campaign discounts were already
// applied at charge time above (B13); the completion side-effects skip
// re-application via their already-recorded guards.
completeFullyPaidBooking(r.Context(), bookingID)
// B13: a campaign the frontend showed as eligible at preview was
// exhausted by a concurrent redemption before this charge applied it.
// The charge already succeeded at Square and the payment is committed —
// mirror the online booking path: honour the promised discount (a
// gift-card balance credit when the full price was charged) and return
// campaign_fully_redeemed so the frontend does not show the discount as
// applied. The booking-completion flow above ran regardless, exactly
// like the online path's in-transaction completion.
if campaignLostPence > 0 {
credited := refundLostCampaignAsBalanceCredit(r.Context(), bookingID, bookingUserID.String, campaignLostPence)
log.Printf("B13: campaign %s fully redeemed before payment %s applied it — lost discount %d pence (%s), returning 400 campaign_fully_redeemed to the frontend", campaignLostID, paymentID, campaignLostPence, credited)
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
"error": "The discount campaign has been fully redeemed. The full amount applies.",
"code": "campaign_fully_redeemed",
})
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
// B3: clamp the terminal checkout amount to the booking's remaining
// obligation UNLESS the customer explicitly requested a tip (tip_enabled).
// An accidental overpayment must never be presented to the card reader as a
// charge that the record path would later carve into an unintended tip. The
// advisory lock above serializes this read against concurrent same-booking
// payments. A fully-paid booking is rejected below (nothing left to charge).
checkoutAmount := amount
if req.TipEnabled {
// The tip is embedded in the amount (totalWithTip) and its value is
// unknown server-side, so cap the TOTAL at the remaining obligation
// plus a generous max tip bound: the booking portion can never exceed
// what is owed, and the tip portion can never exceed £50.
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if remErr != nil {
log.Printf("Failed to compute remaining balance for tip-enabled terminal checkout on booking %s: %v", bookingID, remErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
maxChargePence := remainingPence + maxTerminalTipPence
if checkoutAmount > maxChargePence {
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
checkoutAmount = maxChargePence
}
} else {
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for terminal checkout on booking %s: %v", bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject rather than present a £0 (or overpaid)
// checkout to the card reader.
log.Printf("Terminal checkout for booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped {
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the card reader will present the remaining obligation only", bookingID, amount, effectiveAmount)
checkoutAmount = effectiveAmount
}
}
// tip_enabled is persisted on the checkout row so recordTerminalPaymentTx
// knows whether an overflow beyond the remaining value was an EXPLICIT tip
// (split into a tip record) or an accidental overpayment (kept on the
// booking record, refundable).
if _, err := db.Conn.Exec(r.Context(), `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, tip_enabled)
VALUES ($1, $2, $3, 'PENDING', $4, $5)
`, provisionalID, bookingID, req.PaymentType, float64(checkoutAmount)/100.0, req.TipEnabled); err != nil {
log.Printf("Failed to record provisional terminal checkout for booking %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Attach the booking customer's Square customer id (if any) so the terminal
// checkout is associated with their Square profile. Read-only lookup
// mirroring ensureSquareCustomer's read, but NEVER provisioning — a
// terminal checkout also serves walk-ins, and minting a customer profile
// for a terminal tap would create an unowned customer. No id → empty.
var checkoutCustomerID string
var bookingUserID sql.NullString
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err == nil && bookingUserID.Valid {
_ = db.Conn.QueryRow(r.Context(), `
SELECT square_customer_id FROM user_saved_cards
WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> ''
ORDER BY created_at DESC LIMIT 1
`, bookingUserID.String).Scan(&checkoutCustomerID)
}
checkoutReq := square.CreateCheckoutReq{
Amount: checkoutAmount,
Currency: "GBP",
IdempotencyKey: idempotencyKey,
ReferenceID: bookingID,
// The tip (if any) is already embedded in `amount` by the frontend
// (totalWithTip), so the terminal must NOT prompt for a second tip —
// setting AllowTipping here would double-count the tip in production.
AllowTipping: false,
CustomerID: checkoutCustomerID,
}
checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq)
if err != nil {
log.Printf("Failed to create checkout: %v", err)
// The provisional row is pre-Square and can never produce a charge —
// mark it failed so a retry can proceed (best-effort; log CRITICAL if
// the row update itself fails, since the row would then wedge the
// booking's in-flight guard).
if _, upErr := db.Conn.Exec(r.Context(), `
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW()
WHERE checkout_id = $1 AND status = 'PENDING'
`, provisionalID); upErr != nil {
log.Printf("CRITICAL: failed to mark provisional terminal checkout %s failed after CreateCheckout error (%v): %v — MANUAL RECONCILIATION REQUIRED", provisionalID, err, upErr)
}
http.Error(w, "Failed to create payment", http.StatusInternalServerError)
return
}
// Attach the real Square checkout id to the tracked row (the provisional
// id was never seen by the client, so no poller can race this).
tag, upErr := db.Conn.Exec(r.Context(), `
UPDATE terminal_checkouts SET checkout_id = $1, updated_at = NOW()
WHERE checkout_id = $2
`, checkout.ID, provisionalID)
if upErr != nil {
log.Printf("CRITICAL: terminal checkout %s was created at Square but the tracking UPDATE (from provisional %s) failed: %v — manual reconciliation required", checkout.ID, provisionalID, upErr)
// The checkout is live at Square but untracked — best-effort cancel so
// a customer cannot complete a charge the backend can't record.
if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil {
log.Printf("CRITICAL: failed to cancel orphaned terminal checkout %s after the tracking UPDATE failed: %v — MANUAL RECONCILIATION REQUIRED: the checkout may still be live at Square", checkout.ID, cErr)
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
// The provisional row vanished while the Square call was in flight
// (the sweep resolved it as stale) — the checkout is now live at
// Square but untracked.
log.Printf("CRITICAL: terminal checkout %s was created at Square but provisional row %s was already resolved — the checkout is untracked; MANUAL RECONCILIATION REQUIRED", checkout.ID, provisionalID)
if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil {
log.Printf("CRITICAL: failed to cancel untracked terminal checkout %s: %v — MANUAL RECONCILIATION REQUIRED", checkout.ID, cErr)
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: checkout.ID,
Status: checkout.Status,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// activeTerminalCheckoutID returns the checkout_id of an in-flight terminal
// checkout for the booking, or "" if none. Called under the
// crussell:payment:<booking> advisory lock. A PENDING/IN_PROGRESS row is
// resolved against Square: an already-completed checkout must not block a new
// charge, while one still live at Square is returned so a lost-response retry
// reuses it instead of creating a second live checkout.
//
// A checkout in a definitively terminal state (CANCELED / CANCEL_REQUESTED, or
// NOT_FOUND for an expired checkout) is ALSO resolved: it can never complete,
// so it must not wedge the booking. Such a checkout surfaces as a GetCheckout
// error (the HTTP client returns an error for any non-COMPLETED, non-PENDING
// status) and would otherwise be treated as "still in flight" forever, blocking
// every future terminal charge on the booking. Only ErrCheckoutPending and
// ambiguous transport errors keep the checkout in flight — a second live
// checkout must never be created while the first one's money state is unknown.
func activeTerminalCheckoutID(ctx context.Context, bookingID string) string {
var checkoutID string
if err := db.Conn.QueryRow(ctx, `
SELECT checkout_id FROM terminal_checkouts
WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
ORDER BY created_at ASC LIMIT 1
`, bookingID).Scan(&checkoutID); err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to query active terminal checkout for booking %s: %v", bookingID, err)
}
return ""
}
// A provisional (pre-Square) row carries a synthetic "tmp-" checkout_id (or
// an empty one for legacy rows). It is no longer PROVABLY not live (H4): a
// hard crash between the terminal_checkouts insert and the provisional→real
// UPDATE leaves a LIVE checkout at Square (created under the idempotency
// key embedded in the tmp id) while the row still carries the synthetic id.
// Resolving it to failed unconditionally would let a lost-response retry
// create a SECOND live checkout (C2) while C1 can still complete at the
// terminal into an untracked charge. Query Square first to disambiguate.
if checkoutID == "" || strings.HasPrefix(checkoutID, "tmp-") {
if checkoutID != "" {
result, err := SquareClient.GetCheckout(ctx, checkoutID)
switch {
case err == nil && result.Status == "COMPLETED":
// C1 actually completed at the terminal. The payment MUST be
// recorded now — the old code only marked the row COMPLETED and
// relied on GetCheckoutStatus (the poll path) to record it, but a
// checkout that is never polled (abandoned booking / lost poll)
// would leave the charge permanently untracked:
// SweepStaleTerminalCheckouts only re-examines PENDING/IN_PROGRESS
// rows, so a row already marked COMPLETED here is never revisited
// and the money stays unrecorded and unrefundable via the app.
// Mirror the sweep's recordUntrackedTerminalPayment: the same
// crussell:terminal:<squarePayID> advisory lock (serializes
// against a concurrent poll), the same dedup by booking_id +
// square_payment_id, the same PaymentRecord shape, the same
// deposit/balance/tip split, and the same fully-paid completion.
recorded := recordUntrackedTerminalPayment(ctx, checkoutID, bookingID, result)
if recorded {
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — payment recorded", checkoutID, bookingID)
return ""
}
// Recording failed (transient DB/lock contention) — keep the
// in-flight guard UP so a second live checkout is never created
// while the charge is unrecorded. The row stays PENDING, so the
// stale-terminal sweep re-runs recordUntrackedTerminalPayment on
// it; once recorded, this guard releases on the next attempt.
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square but payment recording failed — keeping it in flight; no second checkout until the charge is recorded", checkoutID, bookingID)
return checkoutID
case errors.Is(err, square.ErrCheckoutPending):
// C1 is still live at Square — reuse it instead of creating C2.
log.Printf("Provisional terminal checkout %s for booking %s is live at Square — reusing it", checkoutID, bookingID)
return checkoutID
case isTerminalCheckoutError(err):
// NOT_FOUND (no checkout was ever created — the crash happened
// before the Square call) or CANCELED — safe to resolve failed
// and create a fresh checkout.
log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, bookingID)
if _, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
`, checkoutID); upErr != nil {
log.Printf("Failed to mark provisional terminal checkout %s failed: %v", checkoutID, upErr)
}
return ""
default:
// Ambiguous error — the checkout's money state at Square is
// unknown. Keep it in flight rather than spawning a second live
// checkout.
return checkoutID
}
}
log.Printf("Legacy empty checkout_id row for booking %s resolved as failed — no live checkout at Square", bookingID)
return ""
}
// Resolve against Square: once the terminal charge finished, the checkout
// is COMPLETED and its payment may already be recorded — it must not
// block a subsequent charge on the same booking.
result, err := SquareClient.GetCheckout(ctx, checkoutID)
if err == nil && result.Status == "COMPLETED" {
if _, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1
`, checkoutID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr)
}
return ""
}
// A definitively terminal checkout (cancelled / cancel-requested /
// expired-NOT_FOUND) can never complete — mark the row failed and allow a
// new checkout instead of wedging the booking forever.
if isTerminalCheckoutError(err) {
log.Printf("Terminal checkout %s is definitively terminal at Square (%v) — allowing a new checkout for booking %s", checkoutID, err, bookingID)
if _, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
`, checkoutID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s failed after terminal state: %v", checkoutID, upErr)
}
return ""
}
// ErrCheckoutPending or any ambiguous error: treat the checkout as still
// in flight. Never create a second live checkout while the first one's
// money state at Square is unknown.
return checkoutID
}
func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
// Defense-in-depth admin check (S-1) — terminal completion records a
// payment, so it must stay admin-only.
if !isAdminRequest(r) {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
return
}
if !validators.IsValidSquareCheckoutID(checkoutID) {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Reject provisional "tmp-" checkout ids: CreateTerminalPayment stores a
// synthetic "tmp-<idempotency key>" id in terminal_checkouts until Square
// returns the real checkout id (provisional-row design), so such an id was
// never a real checkout — resolving it against Square would come back
// NOT_FOUND and surface as a 500. Answer 404 instead (mirrors the guard in
// CreateTerminalPayment and the sweep).
if strings.HasPrefix(checkoutID, "tmp-") {
http.Error(w, "Checkout not found", http.StatusNotFound)
return
}
bookingID := r.URL.Query().Get("booking_id")
if bookingID == "" {
http.Error(w, "booking_id query parameter is required", http.StatusBadRequest)
return
}
if !validators.IsValidID(bookingID) {
http.Error(w, "Invalid booking ID", http.StatusBadRequest)
return
}
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
if err != nil {
if errors.Is(err, square.ErrCheckoutPending) {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
log.Printf("Failed to get checkout status: %v", err)
http.Error(w, "Failed to get checkout status", http.StatusInternalServerError)
return
}
// Ownership check: the terminal checkout must reference THIS booking.
// CreateTerminalPayment sets reference_id = bookingID; without this check,
// polling the wrong checkout ID would attach its payment to a different
// booking (admin-only route, but a mis-scoped charge is a data-integrity
// bug worth rejecting). An EMPTY reference_id is also rejected: a checkout
// created outside this app with no reference must not be attachable to a
// booking (S-1) — fail closed on anything that is not exactly this booking.
if paymentResult.ReferenceID == "" || paymentResult.ReferenceID != bookingID {
log.Printf("Checkout %s does not reference booking %s (reference_id=%q) — refusing to record", checkoutID, bookingID, paymentResult.ReferenceID)
http.Error(w, "Checkout does not belong to this booking", http.StatusBadRequest)
return
}
if paymentResult.Status == "COMPLETED" {
// Serialize terminal-completion records per Square payment ID. Two
// concurrent polls of the same checkout could otherwise BOTH pass the
// dedup SELECT and BOTH INSERT, with the second dying on the
// idempotency_key UNIQUE constraint after the customer already paid —
// the same double-record race every other payment path guards against.
// Bounded try-lock (R6) so a contended lock never blocks the pool.
terminalLockKey := paymentResult.SquarePayID
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for terminal-completion lock: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:terminal:"+terminalLockKey)
if err != nil {
log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", terminalLockKey, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !lockOK {
log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", terminalLockKey)
http.Error(w, "Payment in progress, try again", http.StatusConflict)
return
}
defer releasePaymentLock(pinConn, "crussell:terminal:"+terminalLockKey)
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
// payment insert. The shared money-recording core
// (recordTerminalPaymentTx, sweep.go) runs inside this transaction,
// commits it, and completes a now-fully-paid booking; this handler
// maps the result to the HTTP response.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
paymentID, recErr := recordTerminalPaymentTx(r.Context(), tx, checkoutID, bookingID, paymentResult)
if recErr != nil {
if errors.Is(recErr, errTerminalBookingNotPayable) {
// The core already committed the checkout's 'failed' mark:
// money was taken at Square on a booking that is no longer
// payable and MUST be refunded manually.
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
return
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: paymentID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
// No non-COMPLETED fallthrough here: GetCheckout (via getCheckoutHTTP)
// only returns a nil error for a COMPLETED checkout — a non-COMPLETED
// status or an expired/cancelled checkout surfaces as an error, which was
// already handled above (ErrCheckoutPending → PENDING, everything else →
// 500). The previous trailing `http.Error(w, "Payment failed", 402)` was
// unreachable dead code and has been removed.
}
// IsValidBookingStatusForPayment returns true if the booking status allows
// accepting payments. This guard prevents racing with CleanupExpiredDeposits —
// once a booking's slot has been released (deposit_lapsed, etc.),
// we must reject the payment before hitting Square's API.
func IsValidBookingStatusForPayment(status string) bool {
switch status {
case "confirmed", "pending", "pending_release", "in_progress":
return true
default:
return false
}
}
// bookingStatusAllowsCompletedPayment reports whether a charge that already
// went through Square can still be recorded as a completed payment. It differs
// from IsValidBookingStatusForPayment: a booking that legitimately completed
// ('completed') must still accept the recorded payment, while a cancelled,
// lapsed, or no-show booking must NOT — the money would bypass the
// cancellation refund system, which computes refunds from completed payments.
func bookingStatusAllowsCompletedPayment(status string) bool {
switch status {
case "confirmed", "pending", "pending_release", "in_progress", "completed":
return true
default:
return false
}
}
func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req CreateBookingPaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode booking payment request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Product rule (security): only verified accounts may save cards. An
// unverified/guest/affiliate user may still pay, but save_card=true is
// rejected here — before any charge source resolution or payment record.
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
return
}
service := NewPaymentService()
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
var bookingBuyerEmail string
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
if err := ValidateAmount(req.Amount); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidatePaymentType(req.PaymentType); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// A3: tips have a dedicated endpoint (POST /api/bookings/{id}/tip,
// CreateTipPayment) which enforces the M4 "tips only after the service
// starts" gate. A 'tip' payment_type on the booking payment endpoint would
// bypass that gate — the overflow/tip guard below explicitly skips tip-type
// requests and buildSplitRecords would carve the charge as a deposit or
// balance (or silently overflow into a tip record) — so it is rejected
// outright here, before any charge source resolution or payment record.
if req.PaymentType == "tip" {
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
return
}
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// M1: when the client sends NO idempotency key, a DETERMINISTIC fallback
// is derived (booking_id + payment_type + amount + card_id) so a
// lost-response no-key retry reuses the same key instead of minting a
// second charge. The derivation deliberately runs INSIDE the transaction
// under the per-booking advisory lock (below): the helper advances a
// sequence past "spent" key slots, and that scan must not race a
// concurrent same-booking charge. See deriveBookingPaymentIdempotencyKey
// for how the fallback distinguishes "same live operation retried" (dedup)
// from "new operation that happens to have equal amount" (new charge).
if req.PaymentType == "partial" {
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
}
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
// Serialize payment attempts for this booking to prevent concurrent payments
// across browser tabs or duplicate requests. Uses a PostgreSQL session-level
// advisory lock so that only one goroutine processes payment for a given
// booking at a time, even if two requests pass the optimistic status check below.
//
// We acquire a dedicated connection from the pool and hold it for the
// duration of the handler so that lock and unlock use the same connection.
// Using db.Conn.Exec() for both would be unsafe — each call may get a
// different pool connection, and pg_advisory_unlock on a different session
// is a silent no-op, leaking the lock.
//
// R6: the lock is acquired with a bounded try-lock loop rather than the
// blocking pg_advisory_lock. A blocking lock would pin the pool connection
// for the whole Square round-trip (~30s), so ~4 concurrent same-booking
// payments would exhaust the default pool and hang every request.
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again")
if !lockOK {
return
}
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
// Now that we hold the serialization lock, begin a transaction and re-check
// the booking status inside it. If another request (e.g. from a different tab)
// already processed a payment and promoted the booking while we were waiting,
// we see that here.
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction: %v", txErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// M1: derive the deterministic no-client-key fallback INSIDE the
// transaction under the advisory lock so the spent-slot scan below races
// no concurrent charge (two equal partials must get distinct keys even
// when they arrive back-to-back). The scan itself does the idempotency
// re-validation: a completed row that has been refunded never blocks a new
// equal-amount charge, while an un-refunded completed row keeps its key so
// the dedup lookup below returns it (double-charge protection).
if req.IdempotencyKey == "" {
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
key, keyErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart)
if keyErr != nil {
log.Printf("Failed to derive deterministic idempotency key for booking %s: %v", bookingID, keyErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
req.IdempotencyKey = key
}
var status string
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status for payment check: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !IsValidBookingStatusForPayment(status) {
// A fully-paid booking auto-completes ('completed') and can no longer
// accept new payments. But a same-key retry of a payment that already
// went through must still dedup to the existing completed row —
// otherwise a client retrying after a lost response gets a 409 even
// though the charge succeeded. Any other payment attempt on a
// completed booking falls through and is rejected below.
if status == "completed" {
var existingID sql.NullString
var existingBookingID sql.NullString
var existingPaymentType sql.NullString
var existingStatus sql.NullString
var existingAmount sql.NullFloat64
var existingCreatedAt sql.NullTime
if err := tx.QueryRow(r.Context(), `
SELECT id, booking_id, payment_type, status, amount, created_at
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
// A12: re-validate the matched row's refund state exactly like
// the general dedup path below. A refunded payment's money is
// no longer live, so reporting it as success here would let a
// same-key retry claim a payment that was already returned to
// the customer (money collected for the booking was refunded,
// yet the retry shows paid).
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate completed-booking dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: completed-booking payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
PaymentType: existingPaymentType.String,
Status: existingStatus.String,
Amount: int64(math.Round(existingAmount.Float64 * 100)),
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
}
log.Printf("Payment rejected: booking %s is in status %q (no longer accepting payments)", bookingID, status)
http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict)
return
}
if status == "pending" {
log.Printf("Payment rejected: booking %s is 'pending' — must be confirmed first", bookingID)
http.Error(w, "This booking has not been confirmed yet. Please wait for the booking to be confirmed before making a payment.", http.StatusConflict)
return
}
// Authoritative remaining-balance re-check for 'partial' payments, inside
// the advisory lock. The cheap pre-lock ValidatePartialAmount above can
// race a concurrent partial payment on the same booking: both pass against
// the same remaining balance, then both charge at Square, and the overflow
// is silently recorded as a tip by buildSplitRecords. The lock serializes
// payment attempts, so by the time we re-read here a competing payment has
// already committed — reject before any pending record is inserted or
// Square is hit.
if req.PaymentType == "partial" {
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
log.Printf("Payment rejected: %v", err)
http.Error(w, "Partial amount exceeds remaining balance", http.StatusConflict)
return
}
}
// Check idempotency inside the transaction.
// Only short-circuit when the existing record is 'completed'. A 'pending'
// record means the previous Square call failed — returning it as 200 would
// show a success toast without ever charging. Re-attempt the charge below
// with the same idempotency key (Square dedups safely) and reuse the
// existing record. This mirrors CreateTipPayment exactly.
var existingID sql.NullString
var existingBookingID sql.NullString
var existingPaymentType sql.NullString
var existingStatus sql.NullString
var existingAmount sql.NullFloat64
var existingCreatedAt sql.NullTime
err = tx.QueryRow(r.Context(), `
SELECT id, booking_id, payment_type, status, amount, created_at
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt)
paymentID := ""
reusePendingRecord := false
switch {
case err == nil && existingStatus.String == "completed":
// Idempotent dedup — return the already-completed payment. First
// RE-VALIDATE the matched row's state: a refunded completed payment's
// money is no longer live, so returning it as "success" would silently
// swallow a new equal-amount charge (the booking shows paid with no
// money collected). The no-client-key deterministic path already
// rotates the key past refunded rows (deriveBookingPaymentIdempotencyKey),
// so this guard primarily covers client-keyed retries and is
// defense-in-depth for the deterministic path.
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
PaymentType: existingPaymentType.String,
Status: existingStatus.String,
Amount: int64(math.Round(existingAmount.Float64 * 100)),
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingStatus.String == "pending":
// Previous Square call failed — reuse the pending record and re-attempt.
// Guard the amount: a retry with a different amount must not mutate the
// original record or charge the new amount against the old key. Compare
// in pence via math.Round — int64(pounds*100) truncation would reject
// legitimate same-amount retries for non-exact values (see CreateTipPayment).
if int64(math.Round(existingAmount.Float64*100)) != req.Amount {
log.Printf("Payment retry amount mismatch: pending record %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), req.Amount)
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
return
}
paymentID = existingID.String
reusePendingRecord = true
case err == nil && existingStatus.String == "failed":
// Swept as stale (>24h) or definitively rejected — a retry would risk a
// second Square charge. Reject cleanly instead of 500-ing on the
// idempotency_key UNIQUE constraint (R2).
log.Printf("Payment retry rejected: record %s was marked failed", existingID.String)
http.Error(w, "This payment previously failed and can no longer be retried", http.StatusConflict)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check idempotency: %v", err)
}
// 2FA gating (C5): persisting a card requires 2FA when the feature is
// enforced. This runs AFTER the idempotency dedup's completed
// short-circuit (Loop B MEDIUM): a same-key lost-response retry returns the
// already-completed payment above without re-entering the gate, so its
// single-use code (already consumed by the original attempt) is never
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
// new charge may move at Square. The gate also runs before
// resolveChargeSource below, so an un-2FA'd request never persists a card.
// SCA-primary: a request carrying a Square verification_token (SCA
// performed) skips the gate; a token-less card-save/charge falls back to
// the customer's 2FA code, and twoFAFallbackUsed records that the 2FA
// BACKUP authorized the operation (the caller audits the charge on success).
twoFAFallbackUsed := false
bookingVerificationToken := ""
if req.VerificationToken != nil {
bookingVerificationToken = *req.VerificationToken
}
if req.SaveCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true)
if !gateOK {
return
}
}
// After the idempotency check (which handles same-key retries), verify
// that no completed payment of the same non-partial type already exists.
// buildSplitRecords converts 'full' and 'deposit' input types into a
// 'deposit' PB record, so we also check for an existing deposit when the
// incoming type is 'full' or 'deposit'. Together with the advisory lock,
// this prevents the two-tab race where different idempotency keys allow
// concurrent payments of the same type.
if req.PaymentType != "partial" {
var existingCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1
AND status = 'completed'
AND payment_method NOT IN ('discount', 'on_the_house')
AND (
payment_type = $2
OR ($2 IN ('full', 'deposit') AND payment_type = 'deposit')
)
`, bookingID, req.PaymentType).Scan(&existingCount); err == nil && existingCount > 0 {
log.Printf("Payment rejected: booking %s already has a completed %q payment", bookingID, req.PaymentType)
http.Error(w, "A payment of this type has already been processed for this booking", http.StatusConflict)
return
}
}
// A4: compute the campaign credit this payment will receive. Running the
// read-only ComputeEligibleDiscounts here (before the pending insert,
// under the advisory lock) returns exactly the discounts
// applyEligibleCampaignsAtPayment will create for the booking inside the
// post-charge transaction — no completed payment or booking_discounts row
// exists yet, so both runs see the same state. The credit is used below to
// (a) keep the overflow→tip guard honest about what the customer actually
// owes and (b) reduce the amount charged for a deposit payment, which the
// frontend always sends RAW (no client-side discount).
var bookingTotal float64
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to load booking total for discount computation: %v", err)
}
// B13: keep the pre-charge discount SET (not just the sum) so
// applyEligibleCampaignsAtPayment can detect a campaign exhausted by a
// concurrent redemption between this computation and the apply-time re-run.
preChargeDiscounts := ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal)
var eligibleDiscountPence int64
for _, d := range preChargeDiscounts {
eligibleDiscountPence += int64(math.Round(d.Amount * 100))
}
// M4/M7: cap pay-early at 100%. A payment that exceeds the booking's
// remaining balance overflows into a tip record via buildSplitRecords, but a
// tip is gratuity for service already rendered — an unconfirmed pre-start
// overpayment is therefore rejected instead of silently becoming a tip.
// Post-start overpayments proceed (gratuity is legitimate once the service
// has started), and a pre-start overpayment with ConfirmOverflowTip set
// proceeds after the frontend's explicit confirmation prompt. Placed AFTER
// the idempotency dedup: a same-key retry of an already-completed payment
// short-circuits above and must not hit this guard (the booking is fully
// paid by then). 'tip'-type requests are excluded — tips are charged via
// CreateTipPayment (which enforces its own start-time gate). The overflow
// comparison uses the DISCOUNTED remaining (raw remaining + this payment's
// campaign credit): a payment that exceeds the raw remaining but stays
// within the discounted remaining is covered by the discount — it is NOT an
// overflow into tip territory.
// remainingPence is the booking's tip-excluded outstanding balance
// (total - completed real payments, refunds re-open capacity). It is
// computed once here — before any pending row exists — and reused by both
// the overflow guard below and the A6 clamp-up cap on the deposit charge.
var remainingPence int64
if req.PaymentType != "tip" {
var err error
remainingPence, err = service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
discountedRemainingPence := remainingPence + eligibleDiscountPence
if req.Amount > discountedRemainingPence {
// B12: an overflow that would become a tip ALWAYS requires the
// customer's explicit confirmation (confirm_overflow_tip) — both
// pre-start AND post-start. Previously only a pre-start overflow
// required the flag and a post-start overflow became a tip
// silently; the frontend's stale amount_due + discount preview
// could then mint an unintended tip. When the flag is absent the
// request is rejected with overflow_tip_confirmation_required so
// the frontend can prompt, regardless of booking state.
if !req.ConfirmOverflowTip {
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s", req.Amount, discountedRemainingPence, bookingID)
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
"error": "The extra amount will be recorded as a tip. Confirm to continue.",
"code": "overflow_tip_confirmation_required",
})
return
}
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingPence, bookingID, req.ConfirmOverflowTip)
}
}
// A4: the amount actually charged at Square. The frontend's full/balance
// payments already subtract the campaign credit client-side (handlePayFull
// sends amount_due minus the discount preview), so re-subtracting here
// would double-discount those — and the full discounted payment must keep
// the full record amount so bookingIsFullyPaid (real money + discount
// row == booking total) still completes. A deposit payment, however, is
// charged RAW by the frontend (handlePayDeposit sends the deposit amount
// with no discount), so the campaign credit is applied to the deposit
// charge here: the deposit is charged at req.Amount minus the discount and
// the residual balance payment settles the rest, so the total across the
// deposit→balance flow is the discounted price.
//
// ADMIN-FLOW ASYMMETRY (F1): the admin "Take Payment" PaymentModal sends
// payment_type='full' with the FULL amount (subtotal minus discounts
// ALREADY applied, no client-side campaign preview) — it does NOT
// pre-subtract an eligible campaign. That means a full admin charge is NOT
// reduced below, and applyEligibleCampaignsAtPayment would auto-apply the
// campaign → ledger £55 vs £50 total, orphaned £5 credit. The frontend
// PaymentModal MUST therefore send the discounted amount exactly like the
// customer modal (amount_due minus the eligible-campaign preview) so the
// full ledger reconciles to the booking total; the server-side over-credit
// guard (capDiscountToRemainingObligation in completion.go) protects
// against a client that does not.
chargeAmount := req.Amount
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
chargeAmount = req.Amount - eligibleDiscountPence
// A6: when the eligible discount is >= the deposit itself, chargeAmount
// clamps UP — the customer still pays something up front — but NEVER
// beyond the discounted obligation: the cap max(0, (totalPence -
// eligibleDiscountPence) - realPaidPence) equals remainingPence -
// eligibleDiscountPence (remainingPence is the tip-excluded unpaid
// balance, total - realPaid). Without the cap, chargeAmount clamps to
// the full undiscounted deposit and the headroom computation
// (discountHeadroomPence, which counts this pending charge) truncates
// the discount — the booking auto-completes with the customer overpaying
// by the truncated difference.
if chargeAmount <= 0 {
chargeAmount = req.Amount
cap := remainingPence - eligibleDiscountPence
if cap < 0 {
cap = 0
}
if chargeAmount > cap {
chargeAmount = cap
}
}
}
// A6 (money): when the eligible campaign credit covers the ENTIRE
// remaining obligation, the deposit charge clamps to £0. Charging £0 at
// Square is a provable INVALID_REQUEST_ERROR in production (the pending
// row + Square call would fail forever and block the flow), and the dev
// mock used to ACCEPT £0 and mint a completed £0 deposit that consumed
// the discount — leaving the booking unpaid and the 'full' balance
// charge to overcharge later. There is nothing to charge, so skip the
// Square call entirely and report the discount-covered deposit; the
// flow completes without moving any money. The discount rows themselves
// are applied by the next real charge or at booking completion
// (applyEligibleCampaignsAtPayment).
if chargeAmount <= 0 {
log.Printf("Deposit for booking %s fully covered by %d pence of eligible campaign credit — skipping the Square charge", bookingID, eligibleDiscountPence)
mw.RespondJSON(w, http.StatusOK, map[string]any{
"id": "",
"booking_id": bookingID,
"payment_type": req.PaymentType,
"status": "completed",
"amount": 0,
"deposit_covered_by_discount": true,
})
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. consume=!reusePendingRecord
// (LOW 6a): a FRESH charge verifies WITH consumption — the code is single-use
// at the gate, closing the TOCTOU where a verified-but-unconsumed code could
// authorize a second charge within its lifetime — and a failed Square charge
// re-issues a fresh code (reissueTwoFACodeAfterFailedCharge). A pending-reuse
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
// retry and the completed-charge transaction consumes it on terminal success,
// so a retry that fails again keeps its code for one more attempt. A charge
// carrying a Square verification_token (SCA performed) skips the gate; a
// token-less charge falls back to 2FA and twoFAFallbackUsed is set for the
// charge-success audit.
if req.CardID != nil && *req.CardID != "" {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
if !gateOK {
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 {
// MEDIUM-HIGH: the pending row stores the CHARGE amount, not the
// requested amount — a deposit-with-discount charge (chargeAmount =
// req.Amount - eligibleDiscountPence) differs from req.Amount, and the
// sweep's replayMatchesRowAmount (sweep.go) compares the replayed
// Square charge against this column. Storing req.Amount here would
// misclassify the ORIGINAL charge as a new expired-key replay and
// auto-refund the customer's legitimate payment (B1).
fees := service.CalculateFees(req.Amount, "online")
pendingRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
PaymentMethod: "online_square",
Status: "pending",
Amount: float64(chargeAmount) / 100.0,
IdempotencyKey: &req.IdempotencyKey,
Fees: float64(fees) / 100.0,
UserSavedCardID: savedCardID,
SquareSourceID: &sourceID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &userID,
}
paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, pendingRecord, nil)
if err != nil {
log.Printf("Failed to create pending payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Apply VAT to the pending record inside the same transaction — same
// pattern as CreateTipPayment.
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
} else {
// Reused pending row. The stored square_request_snapshot is the FIRST
// attempt's charge body and MUST remain immutable across nonce-changing
// retries: if that original charge actually landed at Square (the row
// is pending only because the post-charge outcome is unknown), the
// by-key sweep replay must match the original body so Square's
// idempotency dedup returns the landed payment and the sweep rescues
// the row. Overwriting the snapshot's SourceID with this retry's fresh
// nonce — or refreshing the square_source_id column the sweep overrides
// the replay source with — would make the sweep replay the NEW source,
// Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge
// would never be rescued (stranded until the 24h blind-fail). A retry
// that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the
// sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the
// row's true state from the immutable first-attempt body instead. The
// column is refreshed ONLY for snapshot-less legacy rows, whose
// fallback replay body is rebuilt from it (and which get a fresh
// snapshot from the post-commit write below).
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
log.Printf("Failed to update square_source_id on reused payment %s: %v", paymentID, srcErr)
}
}
// Always commit the transaction. In the reuse path no rows were written,
// but the commit is required in the test harness: there the context carries
// an outer test tx, so Begin creates a nested savepoint whose deferred
// rollback would otherwise undo the post-charge UPDATE executed later on
// the same connection. In production Begin is a plain tx and this commit is
// a harmless no-op that keeps both paths identical.
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Step 2: DB transaction committed — safe to call Square now. If Square
// fails, the record stays 'pending' and a same-key retry reuses it.
paymentReq := square.CreatePaymentReq{
Amount: chargeAmount,
Currency: "GBP",
SourceID: sourceID,
CustomerID: savedCardCustomerID,
IdempotencyKey: req.IdempotencyKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: bookingBuyerEmail,
VerificationToken: bookingVerificationToken,
// C3: every online charge here is cardholder-initiated — a saved-card
// (ccof) source MUST carry customer_details for Square's stored-
// credential rules, and a new-card (cnon) nonce is entered by the
// buyer present at the keyboard, so the flag is true either way.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
// request on key reuse, and a reconstructed body returns
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is
// written ONLY when the row has none: it records the FIRST attempt's body,
// which stays immutable so a nonce-changing retry can never redirect the
// sweep's replay away from the original charge (see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr)
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr)
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create payment: %v (error_code=%q)", err, square.ErrorCode(err))
// The gate consumed the 2FA code for a fresh saved-card charge —
// re-issue so the same-key retry has a live code to verify. A NEW-CARD
// (cnon) charge never gated and involves no code: re-issuing here would
// overwrite the customer's standing pending code with a fresh
// undelivered one, silently burning the code the operator relayed
// (finding 5). Pending-reuse saved-card retries verified WITHOUT
// consuming, so re-issuing keeps a live code available for the retry
// (the completed-charge transaction burns it on terminal success).
if req.CardID != nil && *req.CardID != "" {
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
}
// SCA-required failures must surface the structured verification_required
// body so the frontend triggers the 3DS challenge, not a plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
paymentAmount := float64(chargeAmount) / 100.0
// Step 3: Square succeeded — record the completed payment state in a NEW
// transaction (split records, VAT, deposit promotion, campaigns). The
// pending row committed in step 1 already holds the primary idempotency
// key, so it IS the primary record: update it to 'completed' with the
// Square payment ID, then insert only the additional -split-N records.
tx2, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but opening the post-charge transaction failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, txErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx2.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback post-charge transaction", "err", err)
}
}()
// Re-check the booking status under the advisory lock: a concurrent
// cancellation/eviction can move the booking out of a payable state between
// the pending commit (step 1) and the Square charge completing. A charge
// landing on a cancelled/lapsed/no-show booking must not be recorded as a
// completed payment — the cancellation refund path computes refunds from
// completed payments and would silently exclude this deposit. Mark it
// failed and alert ops: money was taken at Square and MUST be refunded
// manually. The pending row is marked 'failed' in the tx below, so
// idempotency dedup still blocks a second Square charge, but the row no
// longer shows pending — the frontend's retry gets a 409 Conflict.
recheckStatus, payable, err := recheckBookingPayable(r.Context(), tx2, bookingID)
if err != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !payable {
log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking payment failed; money taken at Square MUST be refunded manually",
paymentResult.Status, paymentResult.SquarePayID, bookingID, recheckStatus)
if _, upErr := tx2.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking payment %s failed errored: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, paymentID, upErr)
}
if cErr := tx2.Commit(r.Context()); cErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, cErr)
}
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
return
}
// Apply eligible campaign discounts BEFORE the split records are inserted
// (C1): ComputeEligibleDiscounts refuses to apply NEW discounts once a
// booking has 2+ completed real payments, and the split below would
// otherwise count deposit + balance as exactly those 2 payments — a full
// discounted payment would never get its discount row and the booking
// would never auto-complete. Running the discount first means the guard
// only sees payments that existed before this transaction, so the
// discounted total is applied and bookingIsFullyPaid (which counts
// discount rows) completes the booking. The call is idempotent: discounts
// already recorded for the booking are skipped by the duplicate check.
// B13: the apply-time re-check can discover that a campaign the customer
// was promised at preview was exhausted by a concurrent redemption — the
// lost discount must not be silently swallowed (see the error handling
// after the commit below).
campaignLostPence := int64(0)
var campaignLostID string
if applyErr := applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID, preChargeDiscounts); applyErr != nil {
var exErr *campaignExhaustedAtApplyError
if errors.As(applyErr, &exErr) {
campaignLostPence = exErr.lostPence
campaignLostID = exErr.campaignID
log.Printf("B13: campaign %s exhausted between preview and apply for booking %s — lost discount %d pence; payment will complete and the difference will be returned to the customer", campaignLostID, bookingID, campaignLostPence)
} else {
log.Printf("Failed to apply eligible campaigns for booking %s: %v", bookingID, applyErr)
}
}
// Build payment records — may split a single Square charge into
// a deposit portion (up to 50% of booking total) plus a balance
// portion, so the refund system can correctly track deposit vs
// non-deposit money per the deposit protection policy.
bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID)
fees := service.CalculateFees(chargeAmount, "online")
primaryRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
PaymentMethod: "online_square",
Status: "completed",
Amount: paymentAmount,
SquarePaymentID: &paymentResult.SquarePayID,
IdempotencyKey: &req.IdempotencyKey,
Fees: float64(fees) / 100.0,
UserSavedCardID: savedCardID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &userID,
}
var records []PaymentRecord
if bErr == nil && bookingInfo != nil {
records = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount)
} else {
if bErr != nil {
log.Printf("Failed to get booking info for split: %v — using single record", bErr)
}
records = []PaymentRecord{primaryRecord}
}
// The primary split record (records[0]) is the committed pending row. Its
// amount/payment_type may differ from the pending insert (deposit carving
// in buildSplitRecords), so align the row to the computed values. The VAT
// fields are cleared so apply_vat_to_payment recomputes on the final amount
// — the pending record had VAT applied at the pre-split amount.
primary := records[0]
if _, upErr := tx2.Exec(r.Context(), `
UPDATE payments SET
status = 'completed',
square_payment_id = $1,
amount = $2,
payment_type = $3,
fees = $4,
is_vat_applicable = FALSE,
vat_rate = NULL,
vat_amount = NULL,
net_amount = NULL,
updated_at = NOW()
WHERE id = $5
`, paymentResult.SquarePayID, primary.Amount, primary.PaymentType, primary.Fees, paymentID); upErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but updating payment %s to completed failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, paymentID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// MEDIUM-2: a saved-card charge reached its terminal SUCCESS state —
// consume the verified 2FA code now, inside the same transaction that
// records the completed charge. For a FRESH charge the gate already
// consumed the code, so this is an idempotent no-op safety net; for a
// pending-reuse retry (gate passed consume=false — the code was re-issued
// for this retry) this is where it is burned, so a retry that fails again
// keeps its code for one more attempt. Only runs for saved-card (CardID)
// charges — the gate only ran for those, and new-card charges have no code
// to consume.
if req.CardID != nil && *req.CardID != "" {
if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
// Insert the additional split records. They carry the derived -split-N
// idempotency keys, which are new rows; if the split produced only one
// record, there is nothing more to insert.
var paymentIDs []string
for i, rec := range records[1:] {
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx2, rec, nil)
if cErr != nil {
log.Printf("Failed to create split payment record %d/%d: %v", i+2, len(records), cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
paymentIDs = append(paymentIDs, pid)
}
// Apply VAT to all split records if the business is VAT-registered.
// Must be inside the transaction so VAT updates are atomic with inserts.
vatCfg, vatErr := GetVATConfig(r.Context(), tx2)
if vatErr == nil && vatCfg.IsVATRegistered {
vatIDs := append([]string{paymentID}, paymentIDs...)
for _, pid := range vatIDs {
if _, execErr := tx2.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", pid, vatCfg.DefaultVATRate); execErr != nil {
log.Printf("Failed to apply VAT to payment %s: %v", pid, execErr)
}
}
}
// Promote deposit to confirmed if total paid meets the 20% threshold.
// Check is inside the transaction so it sees the just-completed primary.
// Tip rows are excluded (they are gratuity, not payment toward the booking)
// as are discount/on_the_house rows (no real money moved).
var depositMet bool
if err := tx2.QueryRow(r.Context(), fmt.Sprintf(`
WITH booking_total AS (
SELECT total_amount * 100 AS total_pence FROM bookings WHERE id = $1
),
paid_total AS (
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_pence
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_pence >= ROUND(bt.total_pence * %f)
FROM booking_total bt, paid_total pt
`, depositPromotionMinPct), bookingID).Scan(&depositMet); err != nil {
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
}
if depositMet {
if _, err := tx2.Exec(r.Context(), `
UPDATE bookings SET status = 'confirmed', updated_at = NOW()
WHERE id = $1 AND status = 'pending_release'
`, bookingID); err != nil {
log.Printf("ALERT: payment recorded but failed to promote booking %s from pending_release: %v", bookingID, err)
}
}
// Fully-paid completion: if total paid (excluding tips/discounts) now
// covers 100% of the booking total, transition an active booking to
// 'completed' so it leaves the admin's Current Appointment view. The
// completion side-effects (loyalty, campaigns, deposits_required) are the
// same as the admin progress endpoint.
if bookingIsFullyPaid(r.Context(), tx2, bookingID) {
completeActiveBookingFromPayment(r.Context(), tx2, bookingID)
}
if cErr := tx2.Commit(r.Context()); cErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// The 2FA BACKUP authorized this token-less saved-card charge (SCA was
// unavailable) — record the strict fallback audit row AFTER the money
// transaction commits (a failed audit write must never roll back a
// completed charge). The actor is the customer's own userID
// (customer-initiated online charge).
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)")
}
// B13: a campaign was exhausted between the preview and the apply-time
// re-check. The charge already succeeded at Square and the payment record
// is committed, so the customer's promised discount must not silently
// vanish. If the real money now covers the full booking obligation (the
// full price was charged), return the lost discount value to the customer
// as a gift-card account balance credit — the merchant honours the
// discount it quoted. If the booking is NOT fully covered (the customer
// was charged the discounted amount), no credit is due: the shortfall stays
// on the booking and the 400 below tells the frontend the campaign ended so
// it can prompt for the difference. In both cases the 400
// (campaign_fully_redeemed) prevents the frontend from showing the discount
// as applied.
if campaignLostPence > 0 {
credited := refundLostCampaignAsBalanceCredit(r.Context(), bookingID, userID, campaignLostPence)
log.Printf("B13: campaign %s fully redeemed before payment %s applied it — lost discount %d pence (%s), returning 400 campaign_fully_redeemed to the frontend", campaignLostID, paymentID, campaignLostPence, credited)
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
"error": "The discount campaign has been fully redeemed. The full amount applies.",
"code": "campaign_fully_redeemed",
})
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: paymentID,
BookingID: bookingID,
PaymentType: req.PaymentType,
Status: "completed",
Amount: chargeAmount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// campaignExhaustedAtApplyError reports that a discount campaign the customer
// was shown as eligible at preview time was exhausted (times_redeemed reached
// max_redemptions) by the time the payment applied it (B13). lostPence is the
// discount the customer was promised but can no longer receive.
type campaignExhaustedAtApplyError struct {
campaignID string
lostPence int64
}
func (e *campaignExhaustedAtApplyError) Error() string {
return fmt.Sprintf("discount campaign %s was fully redeemed before the payment applied it (lost %d pence)", e.campaignID, e.lostPence)
}
// refundLostCampaignAsBalanceCredit honours a discount the customer was
// promised but a concurrently-exhausted campaign could not apply (B13): when
// the booking's real-money ledger already covers the full obligation (the full
// price was charged at Square), the lost discount value is credited to the
// user's gift-card account balance so the merchant keeps the price it quoted.
// When the booking is NOT fully covered (the customer was charged the
// discounted amount), no credit is due — the shortfall stays on the booking.
// Returns a human-readable outcome for the caller's log line.
func refundLostCampaignAsBalanceCredit(ctx context.Context, bookingID, userID string, lostPence int64) string {
var totalPence, realPaidPence int64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0)
`, bookingID).Scan(&totalPence, &realPaidPence)
if err != nil {
log.Printf("B13: failed to read booking ledger for campaign-loss credit on booking %s: %v", bookingID, err)
return "no credit (ledger unreadable)"
}
if realPaidPence < totalPence {
return "no credit (booking not fully paid by real money)"
}
creditPounds := float64(lostPence) / 100.0
if _, err := db.Conn.Exec(ctx, `
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()
`, userID, creditPounds); err != nil {
log.Printf("CRITICAL: B13 campaign-loss credit of £%.2f to user %s (booking %s) failed: %v — MANUAL RECONCILIATION REQUIRED", creditPounds, userID, bookingID, err)
insertCriticalPaymentNotification(ctx, &bookingID, &userID)
return fmt.Sprintf("credit of £%.2f FAILED (manual reconciliation required)", creditPounds)
}
// Track the credit in gift_card_transactions (reference_type
// 'b13_campaign_loss', reference_id = booking) so a later cancellation can
// reverse it (clawbackB13CampaignCredit). The row is anchored to a real
// gift card of the user because the table requires one; a user with no gift
// card still gets the balance credit but no audit row — the clawback then
// has nothing to reverse.
var anchorCardID string
if err := db.Conn.QueryRow(ctx, `
SELECT id FROM gift_cards
WHERE created_by = $1 OR redeemed_by = $1
ORDER BY COALESCE(redeemed_at, created_at) DESC, created_at DESC
LIMIT 1
`, userID).Scan(&anchorCardID); err == nil && anchorCardID != "" {
if _, err := db.Conn.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'balance_credit', $2, 'b13_campaign_loss', $3, $4, $5)
`, anchorCardID, creditPounds, bookingID, userID, "B13 campaign-loss balance credit"); err != nil {
log.Printf("B13: failed to record gift_card_transactions credit for user %s (booking %s): %v", userID, bookingID, err)
}
}
return fmt.Sprintf("credited £%.2f to gift-card account balance", creditPounds)
}
// 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.
//
// expected is the set of discounts the caller computed BEFORE the charge (under
// the same booking advisory lock). If any of those campaigns has since been
// exhausted by a CONCURRENT redemption on another booking (times_redeemed hit
// max_redemptions between the preview computation and the apply-time re-check —
// the max_redemptions race), the customer would be charged full price with no
// discount row and the booking would silently not complete. In that case a
// *campaignExhaustedAtApplyError is returned so the handler can surface a clear
// "campaign fully redeemed" error and return the promised discount value.
// The pre-check loop below is a fast-fail only; the RACE is closed inside
// ApplyEligibleDiscount, whose atomic conditional increment (guarded by
// max_redemptions) is the real enforcement point — the loser of a concurrent
// same-campaign redemption gets a zero-row result there and the same error
// surfaces from the apply loop.
func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID, userID string, expected []EligibleDiscount) error {
for _, d := range expected {
if d.Source != "campaign" {
continue
}
var exhausted bool
err := q.QueryRow(ctx, `SELECT COALESCE(times_redeemed >= max_redemptions, FALSE) FROM discount_campaigns WHERE id = $1`, d.SourceID).Scan(&exhausted)
if err == nil && exhausted {
return &campaignExhaustedAtApplyError{campaignID: d.SourceID, lostPence: int64(math.Round(d.Amount * 100))}
}
}
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 nil
}
for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) {
// F1: never over-credit. This runs inside the post-charge transaction
// BEFORE the charge's split records are written, so the paid ledger
// visible here is real money + discounts already on the booking plus
// the in-flight charge (the pending row read inside
// discountHeadroomPence). Capping each discount to the uncovered
// obligation keeps the admin "Take Payment" full-amount flow from
// creating an orphaned credit when a campaign is eligible: the correct
// fix is the frontend PaymentModal sending the discounted amount (like
// the customer modal does); this guard is the server-side money-safety
// half.
capped, ok := capDiscountToRemainingObligation(ctx, q, bookingID, d.Amount)
if !ok {
log.Printf("Skipping %s discount %s for booking %s — booking obligation already covered by real money (would over-credit)", d.Source, d.SourceID, bookingID)
continue
}
d.Amount = capped
if applyErr := ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d); applyErr != nil {
var exErr *campaignExhaustedAtApplyError
if errors.As(applyErr, &exErr) {
return applyErr
}
log.Printf("Failed to apply %s discount %s for booking %s: %v", d.Source, d.SourceID, bookingID, applyErr)
}
}
return nil
}
// 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 pence and the parts are derived from one
// another, so no rounding residue exists). The sum of the split records can
// therefore never exceed the amount actually charged at Square. When deposit
// AND balance are both zero (booking already fully paid) the tip record alone
// carries the whole payment — the primary must NOT be appended as well, or the
// amount would be recorded twice (see the tip block below).
//
// Discounts do NOT change this: a discount is applied at payment time as a
// SEPARATE ledger payment row (payment_method='discount'), and GetBookingPaymentInfo
// excludes those rows from TotalPaid (as do the refund and deposit-threshold
// computations). buildSplitRecords therefore runs against the full booking
// total and the REAL money already paid, so a discounted booking can at worst
// over-allocate toward balance and under-allocate toward tip (a bookkeeping
// simplification, not an overcharge) — the partition still equals the charged
// amount. See TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge.
func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord {
// After the booking starts there is no deposit protection window, but an
// overpayment beyond the remaining booking value is still gratuity and must
// be carved out as its own payment_type='tip' record (F3) — mirroring
// buildTerminalSplitRecords' post-start carve. A post-start charge AT OR
// BELOW the remaining value records as a single entry with its original
// type, exactly as before; only the overflow becomes a tip. The tip record
// zeroes fees and derives a -split-tip idempotency key, and the records
// still partition paymentAmount exactly (booking portion + tip).
if clock.Now().After(info.StartTime) {
remaining := math.Max(0, info.TotalAmount-info.TotalPaid)
bookingPortion := math.Min(paymentAmount, remaining)
bookingPortion = math.Round(bookingPortion*100) / 100
tipPortion := math.Round((paymentAmount-bookingPortion)*100) / 100
if tipPortion > 0.004 {
records := []PaymentRecord{primary}
records[0].Amount = bookingPortion
tip := primary
tip.PaymentType = "tip"
tip.Amount = tipPortion
tip.Fees = 0
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + "-split-tip"
tip.IdempotencyKey = &k
}
return append(records, tip)
}
return []PaymentRecord{primary}
}
// 1. Deposit portion: up to 50% of total, minus what's already been paid.
maxDeposit := info.TotalAmount * ProtectedDepositMaxPct
remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid)
depositAmount := math.Min(paymentAmount, remainingDepositRoom)
depositAmount = math.Round(depositAmount*100) / 100
// 2. Remaining after deposit.
remainingAfterDeposit := math.Round((paymentAmount-depositAmount)*100) / 100
// 3. Balance portion: covers whatever is still owed on the booking.
bookingRemaining := math.Max(0, info.TotalAmount-info.TotalPaid-depositAmount)
balancePortion := math.Min(remainingAfterDeposit, bookingRemaining)
balancePortion = math.Round(balancePortion*100) / 100
// 4. Tip: anything beyond the booking total.
tipPortion := math.Round((remainingAfterDeposit-balancePortion)*100) / 100
var records []PaymentRecord
splitIdx := 0
// 1. Deposit portion (always present when there's deposit room left).
if depositAmount > 0.004 {
dep := primary
dep.PaymentType = "deposit"
dep.Amount = depositAmount
records = append(records, dep)
splitIdx++
}
// 2. Balance / partial / full record — covers the remaining booking total.
if balancePortion > 0.004 {
bal := primary
bal.Amount = balancePortion
bal.Fees = 0
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
bal.IdempotencyKey = &k
}
totalPaidAfterBalance := info.TotalPaid + depositAmount + balancePortion
switch {
case totalPaidAfterBalance >= info.TotalAmount && totalPaidAfterBalance-balancePortion > 0:
bal.PaymentType = "balance"
case totalPaidAfterBalance >= info.TotalAmount:
bal.PaymentType = "full"
default:
bal.PaymentType = "partial"
}
records = append(records, bal)
splitIdx++
}
// 3. Tip record — overflow beyond the booking total. Appended BEFORE the
// primary fallback below: when BOTH the deposit and balance portions are
// zero (deposit room exhausted AND the booking already fully paid — e.g. a
// discounted booking whose TotalPaid, which excludes discount rows, has
// reached the full total), the tip record carries the ENTIRE payment.
// Appending the primary first would double-count the charged amount
// (primary at the full amount + tip at the same full amount).
if tipPortion > 0.004 {
tip := primary
tip.PaymentType = "tip"
tip.Amount = tipPortion
tip.Fees = 0
splitIdx++
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
tip.IdempotencyKey = &k
}
records = append(records, tip)
}
// Defensive fallback: nothing was appended (deposit, balance, AND tip all
// zero — impossible given paymentAmount is validated > 0 upstream, so this
// is a pure safety net). The primary is still a valid single record.
if len(records) == 0 {
primary.Fees = 0
records = append(records, primary)
}
return records
}
// buildTerminalSplitRecords splits a completed terminal checkout charge that
// included a tip into deposit + balance + tip payment records. Square charges
// a single amount (booking portion + tip); the booking portion is split like
// buildSplitRecords — deposit up to 50% of the booking total (minus already
// deposited), balance covering the rest — and the tip becomes its own
// payment_type='tip' record with a derived -split-tip idempotency key. Unlike
// buildSplitRecords this always carves the deposit/balance split (the terminal
// flow runs after the booking started, where buildSplitRecords' start-time
// short-circuit would collapse everything into one record): only deposit +
// balance are refundable on cancellation, while the tip is recorded for
// accounting (total_tips) and excluded from the refund computation. The MONEY
// INVARIANT from buildSplitRecords holds: the records always partition
// bookingPortion + tipAmount exactly.
func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo, bookingPortion, tipAmount float64) []PaymentRecord {
maxDeposit := info.TotalAmount * ProtectedDepositMaxPct
remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid)
depositAmount := math.Min(bookingPortion, remainingDepositRoom)
depositAmount = math.Round(depositAmount*100) / 100
balancePortion := math.Round((bookingPortion-depositAmount)*100) / 100
var records []PaymentRecord
splitIdx := 0
if depositAmount > 0.004 {
dep := primary
dep.PaymentType = "deposit"
dep.Amount = depositAmount
records = append(records, dep)
splitIdx++
}
if balancePortion > 0.004 {
bal := primary
bal.PaymentType = "balance"
bal.Amount = balancePortion
bal.Fees = 0
if primary.IdempotencyKey != nil {
k := splitIdempotencyKey(*primary.IdempotencyKey, fmt.Sprintf("-split-%d", splitIdx))
bal.IdempotencyKey = &k
}
records = append(records, bal)
splitIdx++
}
if tipAmount > 0.004 {
tip := primary
tip.PaymentType = "tip"
tip.Amount = tipAmount
tip.Fees = 0
if primary.IdempotencyKey != nil {
k := splitIdempotencyKey(*primary.IdempotencyKey, "-split-tip")
tip.IdempotencyKey = &k
}
records = append(records, tip)
}
if len(records) == 0 {
primary.Fees = 0
records = append(records, primary)
}
return records
}
// splitIdempotencyKey derives a bounded-length idempotency key for a split
// record. The terminal base key (booking + amount + Square payment ID) can be
// long enough that appending a -split-tip suffix would exceed the
// payments.idempotency_key VARCHAR(64) limit (e.g. the dev mock's 28-char
// "pay_mock_<nanosecond>" payment IDs); the base is truncated so the suffix
// always fits. Uniqueness is preserved: the truncated base still embeds the
// booking ID and Square payment ID, and the suffix differs per split record.
func splitIdempotencyKey(base, suffix string) string {
maxBase := 64 - len(suffix)
if len(base) > maxBase {
base = base[:maxBase]
}
return base + suffix
}
func GetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
service := NewPaymentService()
cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil {
log.Printf("Failed to get payment methods: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(cards); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func AdminGetUserPaymentMethods(w http.ResponseWriter, r *http.Request) {
// Defense-in-depth admin check (S-1) — exposing another user's saved cards
// must stay admin-only.
if !isAdminRequest(r) {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
service := NewPaymentService()
cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil {
log.Printf("Failed to get payment methods for user %s: %v", userID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(cards); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
cardID := chi.URLParam(r, "id")
if cardID == "" || !validators.IsValidID(cardID) {
http.Error(w, "Payment method not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
service := NewPaymentService()
err := service.DeletePaymentMethod(r.Context(), cardID, userID)
if err != nil {
log.Printf("Failed to delete payment method: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
type CreatePaymentMethodRequest struct {
CardToken string `json:"card_token" validate:"required"`
// VerificationCode is the customer's current 2FA one-time code (B10): an
// enforced environment persists a card only when this matches the
// customer's pending code.
VerificationCode string `json:"verification_code,omitempty"`
}
func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req CreatePaymentMethodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.CardToken == "" {
http.Error(w, "card_token is required — use a Square Web Payments nonce", http.StatusBadRequest)
return
}
service := NewPaymentService()
// 2FA gating (H4): persisting a card via the account "add card" endpoint
// requires 2FA when the feature is enforced — the same gate the booking
// and tip flows apply to req.SaveCard. Persisting a stored credential is
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
// endpoint must not be the un-gated side door. consume=true: saving a card
// is a terminal operation with no downstream charge to attach consumption
// to (MEDIUM-2). This request type carries no verification_token field, so
// the 2FA fallback is the only authorization path; a fallback success is
// recorded by the strict audit below.
twoFAFallbackUsed := false
{
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, "", true)
if !gateOK {
return
}
}
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
if err != nil {
if isDefinitiveCardSaveFailure(err) {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
log.Printf("Failed to create payment method: %v", err)
http.Error(w, "Failed to add card", http.StatusInternalServerError)
return
}
// The 2FA BACKUP authorized persisting this card (no SCA was performed on
// the add-card endpoint); the actor is the customer's own userID.
if twoFAFallbackUsed && card != nil {
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)")
}
if err := json.NewEncoder(w).Encode(card); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// isDefinitiveCardSaveFailure reports whether a CreatePaymentMethod error is a
// definitive client rejection — an expired/invalid/already-used card source or
// a declined card that can never be saved — as opposed to an ambiguous
// transport/server failure. It matches the structured Square error Code
// (square.ErrorCode) exactly against the codes this codebase already recognizes
// for card failures (till.go's definitivePaymentDeclineCodes via
// isDefinitiveChargeFailure, plus the card-on-file creation codes SOURCE_USED /
// CARD_TOKEN_USED / CARD_TOKEN_EXPIRED / INVALID_CARD), and additionally treats
// any error carrying Square's INVALID_REQUEST_ERROR CATEGORY as definitive —
// real 400 card-save failures (e.g. MISSING_REQUIRED_PARAMETER) arrive with
// that category and a specific code, so checking the category catches them all.
// INVALID_REQUEST_ERROR is a category, NOT a code: it must be matched via
// square.ErrorCategory, never as a code. Errors carrying no structured code
// (transport errors, the dev mock's plain errors, 5xx) are ambiguous and stay
// 500 — retrying with the same inputs might succeed.
func isDefinitiveCardSaveFailure(err error) bool {
if err == nil {
return false
}
if isDefinitiveChargeFailure(err) {
return true
}
switch square.ErrorCode(err) {
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
return true
}
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" {
return true
}
return false
}
func RefundPayment(w http.ResponseWriter, r *http.Request) {
// Defense-in-depth: the route is mounted under mw.RequireAdmin, but this
// in-handler check keeps refund access admin-only even if the route is ever
// re-registered on a non-admin router (S-1).
if !isAdminRequest(r) {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
paymentID := chi.URLParam(r, "payment_id")
if paymentID == "" || !validators.IsValidID(paymentID) {
http.Error(w, "Payment not found", http.StatusNotFound)
return
}
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req RefundRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode refund request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// N4: Square's refund-reason limit is 192 chars — a longer reason 400s at
// Square and would be misclassified as a definitive decline. Reject early.
if len(req.Reason) > 192 {
http.Error(w, "Refund reason must be 192 characters or less", http.StatusBadRequest)
return
}
if err := ValidateAmount(req.Amount); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidateRefundReason(req.Reason); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Client-supplied idempotency key feeds Square /v2/refunds (45-char cap).
// This handler decodes into RefundRequest without running the struct
// validator, so enforce the limit explicitly — a longer key would 400 at
// Square and be misclassified as a definitive refund decline.
if len(req.IdempotencyKey) > maxIdempotencyKeyLength {
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", http.StatusBadRequest)
return
}
service := NewPaymentService()
payment, err := service.GetPaymentByID(r.Context(), paymentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Payment not found", http.StatusNotFound)
return
}
log.Printf("Failed to get payment: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if payment.Status != "completed" {
http.Error(w, "Can only refund completed payments", http.StatusBadRequest)
return
}
// A discount/on_the_house payment row is a ledger entry, not real money
// (the customer never paid it). Refunding it would pay money out of
// nothing. The NULL-square_payment_id guard below would also catch it, but
// an explicit check is defense-in-depth: if a discount row ever gains a
// square_payment_id, this still blocks the refund.
if payment.PaymentMethod == "discount" || payment.PaymentMethod == "on_the_house" {
http.Error(w, "Cannot refund a discount or complimentary payment", http.StatusBadRequest)
return
}
// A tip payment is gratuity, not booking money: every refund computation
// excludes tip rows (GetBookingRefundableAmountPence, refunds.go, and the
// AdminRefundBooking query at handlers.go:3597). Refunding a tip here would
// pay the gratuity back while GetBookingRemainingBalancePence's refunded
// total re-opens booking charge capacity (a tip refund counts as "returned
// money") — a fully-paid booking would accept a second legitimate charge,
// double-collecting the balance. Tips are deliberately not refundable via
// this handler. Tip split rows share the charge's square_payment_id, so the
// Square reference guard below cannot catch them — this explicit check must
// run before it.
if payment.PaymentType == "tip" {
http.Error(w, "Cannot refund a tip payment", http.StatusBadRequest)
return
}
// Money-safety guard (M7): a payments row with NO booking is a gift-card
// purchase (BuyGiftCard inserts without a booking — the same discriminator
// the sweep uses in sweep.go). Refunding such a payment at Square returns
// the cash while the issued gift card and its balance credit stay live:
// £N paid out with the £N card still spendable = money created from
// nothing. The reversal alternative (delete the card + debit the pooled
// balance) is unsafe: a self-purchase is auto-redeemed into the account
// balance which may already be partially spent, and gift_card_transactions
// rows reference the card. Reject with a clear message directing the admin
// to the gift-card section, BEFORE any Square call or pending-refund row
// (this also blocks the dedup/resume paths below, which re-issue at
// Square).
if payment.BookingID == "" {
http.Error(w, "Cannot refund a gift-card purchase via payment refund. Refund gift-card purchases by cancelling the card in the gift-card section.", http.StatusBadRequest)
return
}
if payment.SquarePaymentID == nil {
http.Error(w, "Payment has no Square reference", http.StatusBadRequest)
return
}
// Idempotency key for the refund. When the client supplies one (a UUID
// generated per distinct refund attempt and REUSED on retry), the key is
// hashed and truncated: Square's idempotency-key limit is 45 chars, and
// paymentID (12) + "-refund-" (8) + a full 36-char UUID (56 total) would
// be rejected with a 400. The hash stays deterministic, so a same-key
// retry still dedups.
//
// When the client sends NO key, the fallback must be UNIQUE per refund
// attempt: the old amount-derived key (paymentID + "-refund-" + amount)
// collided on two DISTINCT partial refunds of the same amount, and the
// dedup lookup silently swallowed the second. The fallback appends a fresh
// crypto/rand hex suffix so distinct same-amount refunds never collide;
// a lost-response no-key retry still resumes via the (payment_id, amount)
// pending fallback below. 6 random bytes (12 hex chars) keeps the full key
// ≤45 chars even for a 9-digit pence amount.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10) + "-" + randomHexSuffix(6)
if req.IdempotencyKey != "" {
ikHash := sha256.Sum256([]byte(req.IdempotencyKey))
idempotencyKey = paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24]
}
// Serialize refund attempts per payment to prevent two concurrent refunds
// both passing the over-refund guard and both charging Square. Mirrors the
// tip/gift-card advisory-lock pattern. Bounded try-lock (R6) so a
// contended lock never blocks the pool across the Square round-trip.
refundLockKey := paymentID
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for refund lock: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:refund:"+refundLockKey)
if err != nil {
log.Printf("Failed to acquire refund serialization lock for %s: %v", paymentID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !lockOK {
log.Printf("Refund serialization lock for %s not acquired within bound — a refund is already in progress", paymentID)
http.Error(w, "Refund in progress, try again", http.StatusConflict)
return
}
defer releasePaymentLock(pinConn, "crussell:refund:"+refundLockKey)
// Dedup/resume (inside the lock): a same-key retry of a completed or
// in-flight (pending) refund must not create a second Square refund. Runs
// BEFORE the over-refund guard so a resuming refund never evaluates its own
// pending row against the guard.
var existingRefundID sql.NullString
var existingRefundStatus sql.NullString
var existingRefundAmount sql.NullFloat64
var existingRefundOrigin sql.NullString
var existingRefundReason sql.NullString
var existingRefundCreatedAt sql.NullTime
var existingRefundKey sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
switch {
case err == nil && existingRefundStatus.String == "completed":
// C4: same-key dedup must report the STORED refund, never the newly
// requested amount — echoing req.Amount on a different-amount retry
// misleads the admin into believing the new amount was refunded.
createdAt := clock.Now()
if existingRefundCreatedAt.Valid {
createdAt = existingRefundCreatedAt.Time
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: int64(math.Round(existingRefundAmount.Float64 * 100)),
Status: "completed",
Reason: existingRefundReason.String,
CreatedAt: createdAt.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingRefundStatus.String == "pending":
// Resume the in-flight refund: the DB row was committed but the Square
// call never completed (network timeout, crash, etc.). Retry Square with
// the row's OWN stored idempotency key so Square returns the original
// refund if one exists, never a second one.
resumeManualPendingRefund(w, r, paymentID, payment, existingRefundID.String, existingRefundAmount.Float64, existingRefundReason.String, existingRefundKey.String)
return
case err == nil && existingRefundStatus.String == "failed":
// A failed row with origin='manual' may actually have moved money at
// Square (response loss after a definitive decline). Reconcile FIRST —
// an exact-amount COMPLETED refund resolves the row to completed.
// Otherwise the reconcile proves money did NOT move, so re-issuing with
// the stored key/amount is safe (Square dedups same-key retries).
if existingRefundOrigin.String == "manual" {
refundSqPaymentID := *payment.SquarePaymentID
resumeAmount := int64(math.Round(existingRefundAmount.Float64 * 100))
var reconcileTime time.Time
if existingRefundCreatedAt.Valid {
reconcileTime = existingRefundCreatedAt.Time
}
sqRefundID, rcErr := reconcileRefundAtSquare(r.Context(), refundSqPaymentID, resumeAmount, reconcileTime)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Do NOT
// re-issue on an unknown state: re-issuing would be safe
// against Square's key dedup, but if money already moved the
// over-refund guard would lose sight of it. Surface a retry.
log.Printf("Failed to reconcile refund %s against Square before re-issue (%v) — not re-issuing, ask the admin to retry", existingRefundID.String, rcErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, *sqRefundID, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s completed after Square reconcile: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: resumeAmount,
Status: "completed",
Reason: existingRefundReason.String,
CreatedAt: existingRefundCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
// Persist a fallback key (legacy NULL-key rows) before re-issuing so
// a lost-response retry reuses it — see ensureRefundKey.
reissueKey, keyErr := ensureRefundKey(r.Context(), existingRefundID.String, paymentID, resumeAmount, existingRefundKey.String)
if keyErr != nil {
log.Printf("Failed to ensure refund key for refund %s before re-issue: %v", existingRefundID.String, keyErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
}
reissueReq := square.RefundPaymentReq{
PaymentID: refundSqPaymentID,
Amount: resumeAmount,
IdempotencyKey: reissueKey,
Reason: existingRefundReason.String,
}
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
switch {
case reissueErr == nil:
// Resolve by Square's status: PENDING stays pending (sweep
// reconciles), FAILED/REJECTED is definitive, COMPLETED resolves.
reissueStatus := "completed"
if reissueResult.Status == "PENDING" {
reissueStatus = "pending"
log.Printf("Square reissue %s is PENDING — leaving refund %s pending for the sweep", reissueResult.ID, existingRefundID.String)
} else if reissueResult.Status == "FAILED" || reissueResult.Status == "REJECTED" {
reissueStatus = "failed"
log.Printf("Square reissue %s FAILED — marking refund %s failed", reissueResult.ID, existingRefundID.String)
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
reissueStatus, reissueResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: resumeAmount,
Status: reissueStatus,
Reason: existingRefundReason.String,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", existingRefundID.String, upErr)
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: resumeAmount,
Status: "completed",
Reason: existingRefundReason.String,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case errors.Is(reissueErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s failed after re-issue rejection: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issued with stored key definitively declined by Square: %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
default:
// Ambiguous re-issue — put the row back to 'pending' so the
// sweep's manual retry pass can re-attempt it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'pending' WHERE id = $1`, existingRefundID.String); upErr != nil {
log.Printf("Failed to mark refund %s pending after ambiguous re-issue: %v", existingRefundID.String, upErr)
}
log.Printf("Refund %s re-issue left pending (ambiguous): %v", existingRefundID.String, reissueErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
}
// Previously definitively rejected (non-manual) — a same-key retry cannot
// succeed and the UNIQUE key would block re-insertion. Surface the
// failure instead of 500-ing on a duplicate.
log.Printf("Refund %s was previously marked failed — same-key retry rejected", existingRefundID.String)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check refund idempotency: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Pending-resume fallback: the exact-key lookup missed, but a pending
// refund for this (payment, amount) may exist from a prior attempt whose
// Square call failed ambiguously. If the admin reopened the refund modal,
// the frontend generated a NEW idempotency key, so the exact-key dedup
// above cannot find the row. A pending row means the prior attempt's money
// state at Square is UNKNOWN — re-issuing with a fresh key would double-
// refund once the sweep processes both pending rows. Resume the existing
// pending row with ITS OWN stored key instead, never creating a second one
// while money state is unknown. Distinct COMPLETED refunds of the same
// amount (the P2 equal-partial case) are untouched — they are not pending.
var pendingResumeID sql.NullString
var pendingResumeAmount sql.NullFloat64
var pendingResumeReason sql.NullString
var pendingResumeKey sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, amount, reason, idempotency_key FROM refunds
WHERE payment_id = $1 AND amount = $2 AND status = 'pending'
ORDER BY created_at LIMIT 1
`, paymentID, float64(req.Amount)/100.0).Scan(&pendingResumeID, &pendingResumeAmount, &pendingResumeReason, &pendingResumeKey)
if err == nil {
log.Printf("Refund exact-key lookup missed but found pending row %s for payment %s amount %.2f — resuming with its stored key", pendingResumeID.String, paymentID, pendingResumeAmount.Float64)
resumeManualPendingRefund(w, r, paymentID, payment, pendingResumeID.String, pendingResumeAmount.Float64, pendingResumeReason.String, pendingResumeKey.String)
return
}
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check pending-refund fallback: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Different-amount pending guard: no pending row matches this amount, but a
// pending refund for a DIFFERENT amount may still be in flight. Creating a
// second pending row would let the sweep process both (e.g. pending £20,
// retry £30 on a £50 payment → £50 moves when the admin intended £30). A
// pending row means the payment's money state at Square is unknown, so any
// new refund of any amount is unsafe until it resolves. Reject with 409 —
// the same policy as the tip-flow amount-mismatch guard.
var anyPendingID string
err = db.Conn.QueryRow(r.Context(), `
SELECT id FROM refunds
WHERE payment_id = $1 AND status = 'pending'
LIMIT 1
`, paymentID).Scan(&anyPendingID)
if err == nil {
log.Printf("Refund %s rejected: payment %s has an in-flight pending refund (row %s) for a different amount — refusing a second pending row", req.IdempotencyKey, paymentID, anyPendingID)
http.Error(w, "A refund is already being processed for this payment — please wait for it to complete", http.StatusConflict)
return
}
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check in-flight pending refund: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Over-refund guard (inside the lock so concurrent refunds can't both pass).
// GetAlreadyRefundedAmount counts completed AND pending refunds.
alreadyRefunded, err := service.GetAlreadyRefundedAmount(r.Context(), paymentID)
if err != nil {
log.Printf("Failed to get already refunded amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount+alreadyRefunded > int64(math.Round(payment.Amount*100)) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
// Begin a transaction. Insert the refund record as 'pending' first, commit,
// then call Square — so a Square failure leaves a retryable pending refund
// (reprocessed by the scheduler in refunds.go).
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction for refund: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
var refundID string
// booking_id is NULL for non-booking payments (gift-card purchase refunds);
// payments without a booking leave it NULL rather than inserting an empty
// string that violates the refunds.booking_id FK/NOT NULL.
var refundBookingID any = payment.BookingID
if payment.BookingID == "" {
refundBookingID = nil
}
err = tx.QueryRow(r.Context(), `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7, 'manual')
RETURNING id
`,
paymentID,
refundBookingID,
float64(req.Amount)/100.0,
req.Reason,
idempotencyKey,
adminID,
clock.Now(),
).Scan(&refundID)
if err != nil {
log.Printf("Failed to create pending refund record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit refund transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
refundReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: req.Amount,
IdempotencyKey: idempotencyKey,
Reason: req.Reason,
}
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
if err != nil {
if errors.Is(err, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square. Resolve
// to completed (square_refund_id stays NULL) rather than failed so
// the over-refund guard can never issue money on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", refundID)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(err, square.ErrRefundDeclined) {
// Definitive rejection (declined / already refunded / invalid
// payment) — mark the refund failed so it never retries and never
// blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — refund record intentionally left as 'pending' for
// the scheduler to re-attempt (refunds.go ProcessPendingSquareRefunds).
log.Printf("Failed to refund payment (refund %s left pending): %v", refundID, err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Square succeeded — resolve the refund row by Square's status. A
// synchronous refund response can be PENDING (money in flight, e.g. an
// async card network): marking it completed while Square later fails it
// would permanently block that amount in the over-refund guard. Only a
// definitive COMPLETED resolves to completed; PENDING stays pending for the
// sweep to reconcile; FAILED/REJECTED is a real failure.
status := "completed"
if refundResult.Status == "PENDING" {
status = "pending"
log.Printf("Square refund %s is PENDING (in flight) — leaving refund %s pending for the sweep to resolve", refundResult.ID, refundID)
} else if refundResult.Status == "FAILED" || refundResult.Status == "REJECTED" {
status = "failed"
log.Printf("Square refund %s FAILED — marking refund %s failed", refundResult.ID, refundID)
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
status, refundResult.ID, refundID,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", refundResult.ID, refundID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: status,
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// resumeManualPendingRefund retries Square for a pending manual refund using
// the row's OWN stored idempotency key (never a fresh one), then resolves the
// row. Called from RefundPayment's exact-key dedup and the (payment, amount)
// pending fallback. Using the stored key lets Square return the original
// refund if the prior attempt actually completed (response loss), so no second
// refund can ever be issued for a row whose money state is unknown. A legacy
// NULL-key row gets a fallback key persisted to the row FIRST (ensureRefundKey),
// so a lost-response retry reuses it instead of double-refunding with a fresh
// random suffix.
func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID string, payment *PaymentRecord, refundID string, refundAmount float64, refundReason, refundKey string) {
resumeAmount := int64(math.Round(refundAmount * 100))
resumeKey, keyErr := ensureRefundKey(r.Context(), refundID, paymentID, resumeAmount, refundKey)
if keyErr != nil {
log.Printf("Failed to ensure refund key for refund %s before resume: %v", refundID, keyErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
}
resumeReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: resumeAmount,
IdempotencyKey: resumeKey,
Reason: refundReason,
}
resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq)
if resumeErr != nil {
if errors.Is(resumeErr, square.ErrRefundAlreadyProcessed) {
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
// Resolve the pending row to completed (square_refund_id stays
// NULL) so the guard can never over-refund on top of it.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", refundID, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", refundID)
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: resumeAmount,
Status: "completed",
Reason: refundReason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
if errors.Is(resumeErr, square.ErrRefundDeclined) {
// Definitive rejection — mark failed so it never retries and
// never blocks future refunds.
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, refundID); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", refundID, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", refundID, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Ambiguous error — leave pending for the scheduler to retry.
log.Printf("Failed to resume refund %s (left pending): %v", refundID, resumeErr)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
// Resolve by Square's status — a PENDING resume stays pending for the
// sweep (marking it completed while Square later fails it would block the
// amount in the over-refund guard forever); FAILED/REJECTED is definitive.
status := "completed"
if resumeResult.Status == "PENDING" {
status = "pending"
log.Printf("Square refund %s is PENDING — leaving refund %s pending for the sweep", resumeResult.ID, refundID)
} else if resumeResult.Status == "FAILED" || resumeResult.Status == "REJECTED" {
status = "failed"
log.Printf("Square refund %s FAILED — marking refund %s failed", resumeResult.ID, refundID)
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`,
status, resumeResult.ID, refundID,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, refundID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: resumeAmount,
Status: status,
Reason: refundReason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// AdminBookingRefundRequest is the request body for
// POST /api/admin/bookings/{id}/refund.
type AdminBookingRefundRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
Reason string `json:"reason" validate:"required"`
}
// AdminBookingRefundResponse reports the result of an admin-initiated
// booking-level refund: the total amount refunded and one entry per affected
// payment.
type AdminBookingRefundResponse struct {
RefundedAmount int64 `json:"refunded_amount"`
Refunds []RefundResponse `json:"refunds"`
}
// AdminRefundBooking is the admin-initiated booking-level refund endpoint
// (separate from cancellation refunds). It refunds up to req.Amount against
// the booking's completed non-tip payments, oldest first, after validating the
// amount against the booking's refundable total (paid minus already refunded,
// excluding tips). Used for post-service refunds (bad application, etc.) at
// admin discretion.
func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
// Defense-in-depth admin check (S-1) — the route is mounted under
// mw.RequireAdmin; this keeps booking-level refunds admin-only regardless.
if !isAdminRequest(r) {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req AdminBookingRefundRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode admin booking refund request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Square's refund-reason limit is 192 chars (N4) — a longer reason 400s at
// Square and would be misclassified as a definitive decline.
if len(req.Reason) > 192 {
http.Error(w, "Refund reason must be 192 characters or less", http.StatusBadRequest)
return
}
if err := ValidateAmount(req.Amount); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
service := NewPaymentService()
// Booking user lookup also proves the booking exists.
var bookingUserID string
var isGuest bool
if err := db.Conn.QueryRow(r.Context(), `
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id = $1
`, bookingID).Scan(&bookingUserID, &isGuest); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Cap: the refund amount must not exceed the refundable total (completed
// non-tip payments minus already refunded). Tips are not refundable.
refundablePence, err := service.GetBookingRefundableAmountPence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get refundable amount for booking %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount > refundablePence {
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundablePence, bookingID)
http.Error(w, "Refund amount exceeds the refundable amount for this booking", http.StatusBadRequest)
return
}
// Fetch the booking's completed non-tip payments, oldest first, so the
// requested amount is refunded against the earliest money first.
rows, err := db.Conn.Query(r.Context(), `
SELECT id, amount, payment_method, square_payment_id, gift_card_id
FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'
AND payment_method NOT IN ('discount', 'on_the_house')
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for admin booking refund: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
var payments []paymentRow
for rows.Next() {
var p paymentRow
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
payments = append(payments, p)
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("Payment row iteration error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if len(payments) == 0 {
http.Error(w, "No refundable payments found for this booking", http.StatusBadRequest)
return
}
// Serialize against the cancellation refund path and the per-payment manual
// RefundPayment handler — both hold the same crussell:refund:<payment_id>
// locks — so the residual computation below cannot race an in-flight refund.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction for admin booking refund: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback admin booking refund transaction", "err", err)
}
}()
if err := lockCancellationPayments(r.Context(), tx, payments); err != nil {
log.Printf("Failed to acquire refund locks for booking %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Prior refunds per payment (completed + pending) so a payment is never
// refunded past its residual.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(r.Context(), `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
WHERE booking_id = $1 AND status IN ('completed', 'pending')
GROUP BY payment_id`, bookingID)
if prErr != nil {
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
} else {
for prRows.Next() {
var pid string
var amt float64
if err := prRows.Scan(&pid, &amt); err == nil {
priorRefunds[pid] = amt
}
}
prRows.Close()
}
// cardRefunds tracks card refunds that need a post-commit Square call.
type cardRefund struct {
refundID string
amountPence int64
squareID string
reason string
key string
}
var cardRefunds []cardRefund
var refunds []RefundResponse
remaining := float64(req.Amount) / 100.0
for _, p := range payments {
if remaining <= 0 {
break
}
already := priorRefunds[p.ID]
residual := math.Round((p.Amount-already)*100) / 100
if residual <= 0 {
continue
}
portion := math.Round(math.Min(residual, remaining)*100) / 100
remaining -= portion
var refundStatus string
var refundKey *string
switch p.PaymentMethod {
case "online_square", "in_person_card":
if p.SquarePaymentID == nil || *p.SquarePaymentID == "" {
log.Printf("Admin booking refund: card payment %s has no Square reference — marking failed; refund must be arranged manually", p.ID)
refundStatus = "failed"
break
}
// Unique per-attempt key (same shape as RefundPayment's no-client-key
// fallback): distinct refunds never collide on the UNIQUE constraint.
key := p.ID + "-refund-" + strconv.FormatInt(int64(math.Round(portion*100)), 10) + "-" + randomHexSuffix(6)
refundKey = &key
refundStatus = "pending"
case "giftcard":
if p.GiftCardID != nil && *p.GiftCardID != "" {
var expired bool
if err := tx.QueryRow(r.Context(), `
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
FROM gift_cards WHERE id = $1
`, *p.GiftCardID).Scan(&expired); err != nil {
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *p.GiftCardID, err)
} else if expired {
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *p.GiftCardID, bookingID)
continue
}
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
if expiryErr != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
gcExpiryMonths = defaultGiftCardExpiryMonths
}
if _, gcErr := tx.Exec(r.Context(), `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, portion, *p.GiftCardID, gcExpiryMonths); gcErr != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", portion, *p.GiftCardID, gcErr)
continue
}
if _, gcErr := tx.Exec(r.Context(), `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
`, *p.GiftCardID, portion, bookingID, bookingUserID, "Refund from admin booking refund"); gcErr != nil {
log.Printf("Failed to create gift card transaction for refund: %v", gcErr)
}
} else {
if bookingUserID == "" {
log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", p.ID)
continue
}
if _, balErr := tx.Exec(r.Context(), `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, bookingUserID, portion); balErr != nil {
log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
continue
}
}
refundStatus = "completed"
case "cash":
if bookingUserID == "" || isGuest {
log.Printf("Cash refund: booking %s payment %s amount £%.2f — admin must process cash refund at till", bookingID, p.ID, portion)
} else {
if _, balErr := tx.Exec(r.Context(), `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, bookingUserID, portion); balErr != nil {
log.Printf("Failed to credit user %s balance for cash refund of booking %s: %v", bookingUserID, bookingID, balErr)
continue
}
}
refundStatus = "completed"
default:
log.Printf("Skipping admin booking refund for payment %s with method %q (no money exchanged)", p.ID, p.PaymentMethod)
continue
}
var refundID string
if refundKey != nil {
err = tx.QueryRow(r.Context(), `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual')
RETURNING id
`, p.ID, bookingID, portion, refundStatus, req.Reason, refundKey, adminID, clock.Now()).Scan(&refundID)
} else {
err = tx.QueryRow(r.Context(), `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_by, created_at, origin)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual')
RETURNING id
`, p.ID, bookingID, portion, refundStatus, req.Reason, adminID, clock.Now()).Scan(&refundID)
}
if err != nil {
log.Printf("Failed to create refund record for payment %s: %v", p.ID, err)
continue
}
refunds = append(refunds, RefundResponse{
ID: refundID,
PaymentID: p.ID,
Amount: int64(math.Round(portion * 100)),
Status: refundStatus,
Reason: req.Reason,
CreatedAt: clock.Now().Format(time.RFC3339),
})
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
if refundKey != nil && p.SquarePaymentID != nil {
cardRefunds = append(cardRefunds, cardRefund{
refundID: refundID,
amountPence: int64(math.Round(portion * 100)),
squareID: *p.SquarePaymentID,
reason: req.Reason,
key: *refundKey,
})
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin booking refund transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Post-commit Square pass for card refunds (mirrors RefundPayment's
// status resolution). A definitive decline marks the row failed; an
// ambiguous error leaves it pending for the manual-refund sweep.
for _, cf := range cardRefunds {
status := "completed"
result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{
PaymentID: cf.squareID,
Amount: cf.amountPence,
IdempotencyKey: cf.key,
Reason: cf.reason,
})
switch {
case rErr == nil:
if result.Status == "PENDING" {
status = "pending"
log.Printf("Square refund %s is PENDING (in flight) — leaving refund %s pending for the sweep", result.ID, cf.refundID)
} else if result.Status == "FAILED" || result.Status == "REJECTED" {
status = "failed"
log.Printf("Square refund %s FAILED — marking refund %s failed", result.ID, cf.refundID)
}
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, result.ID, cf.refundID); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", result.ID, cf.refundID, upErr)
}
case errors.Is(rErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, cf.refundID); upErr != nil {
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", cf.refundID, upErr)
}
log.Printf("Refund %s already processed at Square — marked completed", cf.refundID)
case errors.Is(rErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, cf.refundID); upErr != nil {
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", cf.refundID, upErr)
}
log.Printf("Refund %s definitively declined by Square: %v", cf.refundID, rErr)
default:
log.Printf("Failed to refund payment (refund %s left pending): %v", cf.refundID, rErr)
}
for i := range refunds {
if refunds[i].ID == cf.refundID {
refunds[i].Status = status
}
}
}
var totalRefunded int64
for _, rf := range refunds {
totalRefunded += rf.Amount
}
if err := json.NewEncoder(w).Encode(AdminBookingRefundResponse{
RefundedAmount: totalRefunded,
Refunds: refunds,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req CreateTipPaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode tip payment request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Product rule (security): only verified accounts may save cards. An
// unverified/guest/affiliate user may still tip, but save_card=true is
// rejected here — before any charge source resolution or payment record.
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
return
}
service := NewPaymentService()
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
// consume=true: saving a card is a terminal operation (the card row is
// created right here), so the verified code is single-use immediately —
// unlike the saved-card CHARGE gate below, which defers consumption to the
// charge's terminal success (MEDIUM-2). A request carrying a Square
// verification_token (SCA performed) skips the gate; a token-less save falls
// back to 2FA and twoFAFallbackUsed is set for the charge-success audit.
twoFAFallbackUsed := false
tipVerificationToken := ""
if req.VerificationToken != nil {
tipVerificationToken = *req.VerificationToken
}
if req.SaveCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, true)
if !gateOK {
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
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
if !sourceOK {
return
}
// Serialize tip attempts for this booking to prevent concurrent duplicate
// tip payments across browser tabs or retries. Uses a PostgreSQL session-level
// advisory lock scoped to the booking ID.
// Bounded try-lock (R6) so a contended lock never blocks the pool across
// the Square round-trip.
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:tip:"+bookingID, "Payment in progress, try again")
if !lockOK {
return
}
defer releaseBookingPaymentLock(pinConn, "crussell:tip:"+bookingID)
// Step 1: Insert payment record in 'pending' state inside a DB transaction.
// Square is NOT called yet — if the tx fails, no harm done.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// No-client-key fallback: derive a deterministic key INSIDE the tx so the
// count query races no other tip attempt (the tip advisory lock serializes
// per booking). Money-safety: n counts COMPLETED tips only, so a
// lost-response retry of a charge whose pending row exists derives the SAME
// n → the same key → the dedup lookup below reuses the pending row instead
// of minting a second Square charge, while two genuinely distinct identical
// tips get n=1, n=2 and never collapse onto one key.
if idempotencyKey == "" {
var completedTips int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
`, bookingID).Scan(&completedTips); err != nil {
log.Printf("Failed to count completed tips for booking %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
idempotencyKey = truncateIdempotencyKey("tip", fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1))
}
// Check idempotency inside the transaction.
// Only short-circuit when the existing record is 'completed'. A 'pending'
// record means the previous Square call failed — returning it as 200 would
// show a success toast without ever charging. Re-attempt the charge below
// with the same idempotency key (Square dedups safely) and reuse the
// existing record.
var existingID sql.NullString
var existingBookingID sql.NullString
var existingPaymentType sql.NullString
var existingStatus sql.NullString
var existingAmount sql.NullFloat64
var existingCreatedAt sql.NullTime
err = tx.QueryRow(r.Context(), `
SELECT id, booking_id, payment_type, status, amount, created_at
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt)
paymentID := ""
reusePendingRecord := false
switch {
case err == nil && existingStatus.String == "completed":
// Idempotent dedup — return the already-completed payment. First
// RE-VALIDATE the matched row's refund state (same guard as the
// CreateBookingPayment completed-dedup branches): a refunded payment's
// money is no longer live, so reporting it as success would let a
// same-key retry claim money that was already returned.
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate tip dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Tip retry rejected: tip payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, idempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
PaymentType: existingPaymentType.String,
Status: existingStatus.String,
Amount: int64(math.Round(existingAmount.Float64 * 100)),
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingStatus.String == "pending":
// Previous Square call failed — reuse the pending record and re-attempt.
// Guard the amount: a retry with a different amount must not mutate the
// original record (books, VAT, refund caps) or silently charge the new
// amount against the old record. Compare in pence via math.Round — the
// stored pounds value is float64, so int64(pounds*100) truncation would
// reject legitimate same-amount retries for non-exact values (e.g. £1.14
// stored as 1.1399999999999999 → int64 gives 113 ≠ 114).
if int64(math.Round(existingAmount.Float64*100)) != req.Amount {
log.Printf("Tip retry amount mismatch: pending record %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), req.Amount)
http.Error(w, "Amount does not match the pending tip payment", http.StatusBadRequest)
return
}
paymentID = existingID.String
reusePendingRecord = true
case err == nil && existingStatus.String == "failed":
// Swept as stale (>24h, past Square's key retention) or definitively
// rejected. A retry can no longer be replayed against Square without
// risking a second charge — reject cleanly instead of inserting a new
// pending row that 500s on the idempotency_key UNIQUE constraint (R2).
log.Printf("Tip retry rejected: pending record %s was marked failed", existingID.String)
http.Error(w, "This tip payment previously failed and can no longer be retried", http.StatusConflict)
return
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check tip idempotency: %v", err)
}
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated. This runs AFTER the
// idempotency dedup's completed short-circuit (Loop B MEDIUM): a same-key
// lost-response retry returns the already-completed payment above without
// re-entering the gate, so its single-use code (already consumed by the
// original attempt) is never re-rejected as "expired". consume=!reusePendingRecord
// (finding 4): a FRESH charge verifies WITH consumption — the code is
// single-use at the gate, closing the TOCTOU where a verified-but-
// unconsumed code could authorize a second charge — and a pending-reuse
// retry verifies WITHOUT consuming, so a retry that fails again keeps its
// code for one more attempt (the completed-charge transaction consumes it
// on terminal success). A charge carrying a Square verification_token (SCA
// performed) skips the gate; a token-less charge falls back to 2FA and
// twoFAFallbackUsed is set for the charge-success audit.
if req.CardID != nil && *req.CardID != "" {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
}
if !reusePendingRecord {
record := PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "online_square",
Status: "pending",
Amount: float64(req.Amount) / 100.0,
IdempotencyKey: &idempotencyKey,
Fees: 0,
UserSavedCardID: savedCardID,
SquareSourceID: &sourceID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &userID,
}
paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, record, nil)
if err != nil {
log.Printf("Failed to create payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
} else {
// Reused pending row. The stored square_request_snapshot is the FIRST
// attempt's charge body and MUST remain immutable across nonce-changing
// retries: if that original charge actually landed at Square (the row
// is pending only because the post-charge outcome is unknown), the
// by-key sweep replay must match the original body so Square's
// idempotency dedup returns the landed payment and the sweep rescues
// the row. Overwriting the snapshot's SourceID with this retry's fresh
// nonce — or refreshing the square_source_id column the sweep overrides
// the replay source with — would make the sweep replay the NEW source,
// Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge
// would never be rescued (stranded until the 24h blind-fail). A retry
// that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the
// sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the
// row's true state from the immutable first-attempt body instead. The
// column is refreshed ONLY for snapshot-less legacy rows, whose
// fallback replay body is rebuilt from it (and which get a fresh
// snapshot from the post-commit write below).
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil {
log.Printf("Failed to update square_source_id on reused tip payment %s: %v", paymentID, srcErr)
}
}
// Always commit the transaction. In the reuse path no rows were written,
// but the commit is required in the test harness: there the context carries
// an outer test tx, so Begin creates a nested savepoint whose deferred
// rollback would otherwise undo the status UPDATE executed later on the
// same connection. In production Begin is a plain tx and this commit is a
// harmless no-op that keeps both paths identical.
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Step 2: DB transaction committed — safe to call Square now.
// If Square fails, the record stays 'pending' for manual retry.
// Resolve the user's email for Square receipt delivery.
var buyerEmail string
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
CustomerID: savedCardCustomerID,
IdempotencyKey: idempotencyKey,
ReferenceID: bookingID,
Note: "tip",
BuyerEmail: buyerEmail,
VerificationToken: tipVerificationToken,
// C3: the tip charge is cardholder-initiated whether it uses a saved
// card (ccof — customer_details required) or a freshly entered card
// (cnon — buyer present), so the flag is true either way.
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
}
// M1: store the verbatim request JSON so the sweep can replay the charge
// with an IDENTICAL body under the same key — Square compares the whole
// request on key reuse, and a reconstructed body returns
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is
// written ONLY when the row has none: it records the FIRST attempt's body,
// which stays immutable so a nonce-changing retry can never redirect the
// sweep's replay away from the original charge (see the reuse branch above).
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for tip payment %s: %v", paymentID, mErr)
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for tip payment %s: %v", paymentID, eErr)
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(stored), paymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr)
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create tip payment: %v (error_code=%q)", err, square.ErrorCode(err))
// Payment record intentionally left as 'pending' for manual retry.
// The gate consumed the 2FA code for a fresh saved-card charge —
// re-issue so the same-key retry has a live code to verify (mirrors
// CreateBookingPayment's post-failure re-issue, finding 4). A NEW-CARD
// (cnon) charge never gated and involves no code — re-issuing would
// overwrite a standing pending code with an undelivered one (finding 5).
if req.CardID != nil && *req.CardID != "" {
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
}
// SCA-required failures must surface the structured verification_required
// body so the frontend triggers the 3DS challenge, not a plain decline.
if isVerificationRequiredError(err) {
writeVerificationRequiredResponse(w)
return
}
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
}
// MEDIUM-2: a saved-card tip charge reached its terminal SUCCESS state —
// consume the verified 2FA code now, inside the transaction that records
// the completed charge (the gate verified without consuming, so a failed
// charge did not burn the code and a same-key retry could reuse it).
if req.CardID != nil && *req.CardID != "" {
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, userID); consErr != nil {
log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
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
}
// The 2FA BACKUP authorized this token-less saved-card tip charge (SCA was
// unavailable) — record the strict fallback audit row. The actor is the
// customer's own userID (customer-initiated online charge).
if twoFAFallbackUsed {
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)")
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: paymentID,
BookingID: bookingID,
PaymentType: "tip",
Status: "completed",
Amount: req.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, _ := r.Context().Value(mw.UserIDKey).(string)
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
service := NewPaymentService()
// Fail closed: a non-admin request must carry a user ID. The previous
// `userID != ""` guard silently skipped the ownership check for requests
// with no user context, leaking another user's payment summary.
if userRole != "admin" {
if userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
}
summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get payment summary: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
payments := make([]PaymentResponse, len(summary.Payments))
for i, p := range summary.Payments {
payments[i] = PaymentResponse{
ID: p.ID,
BookingID: p.BookingID,
PaymentType: p.PaymentType,
Status: p.Status,
Amount: int64(math.Round(p.Amount * 100)),
CardLast4: p.CardLast4,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
}
}
refunds := make([]RefundResponse, len(summary.Refunds))
for i, rf := range summary.Refunds {
refunds[i] = RefundResponse{
ID: rf.ID,
PaymentID: rf.PaymentID,
Amount: int64(math.Round(rf.Amount * 100)),
Status: rf.Status,
Reason: rf.Reason,
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
}
}
if err := json.NewEncoder(w).Encode(PaymentSummaryResponse{
TotalAmount: int64(math.Round(summary.TotalAmount * 100)),
PaidAmount: int64(math.Round(summary.PaidAmount * 100)),
RefundedAmount: int64(math.Round(summary.RefundedAmount * 100)),
RemainingAmount: int64(math.Round(summary.RemainingAmount * 100)),
TotalVATAmount: int64(math.Round(summary.TotalVATAmount * 100)),
TotalNetAmount: int64(math.Round(summary.TotalNetAmount * 100)),
Payments: payments,
Refunds: refunds,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// PaymentLockDuration is the TTL for a payment-in-flight lock in minutes.
const PaymentLockDuration = 5
// AcquirePaymentLock creates or extends a 5-minute time_blocker for the
// booking's slot so that pending_release eviction is blocked during card
// entry and Square charge processing.
func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Verify the user owns this booking.
var bookingUserID string
if err := db.Conn.QueryRow(r.Context(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
).Scan(&bookingUserID); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
// Before acquiring the lock, double-check the slot is still available.
// For confirmed/in_progress bookings this is a formality; for
// pending_release bookings it catches the eviction race before we
// create a time_blocker — the NOT EXISTS guard in eviction queries
// handles the sub-5-minute race, this catches the >5-minute gap.
var currentStatus string
var startTime time.Time
if err := db.Conn.QueryRow(r.Context(),
"SELECT status, start_time FROM bookings WHERE id = $1", bookingID,
).Scan(&currentStatus, &startTime); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// If the booking has been evicted (deposit_lapsed) or reached a terminal
// state, reject the lock — payment cannot proceed.
if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" {
log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus)
http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict)
return
}
// Upsert the time_blocker atomically: delete old PAYMENT_IN_FLIGHT and insert
// a fresh one in a single transaction. Prevents lock loss if INSERT fails.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction for payment lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
`, bookingID); err != nil {
log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES (NOW(), $1, $2, $3)
`, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil {
log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err)
http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit payment lock transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "locked",
"ttl_min": PaymentLockDuration,
"bookingID": bookingID,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking.
func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// Mirror AcquirePaymentLock's ownership check: releasing another user's
// PAYMENT_IN_FLIGHT blocker would evict their slot mid-payment. The
// booking's own user (or an admin) may release it.
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
userRole, _ := r.Context().Value(mw.UserRoleKey).(string)
var bookingUserID string
if err := db.Conn.QueryRow(r.Context(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
).Scan(&bookingUserID); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
if userRole != "admin" && bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
`, bookingID); err != nil {
log.Printf("Failed to release payment lock for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// uniqueChargeKey generates a unique idempotency key under the given prefix
// (e.g. "till-") where the client did not supply one. Client-supplied keys
// handle retry dedup; this fallback only needs uniqueness so two legitimate
// identical requests never collapse on the same key. Deliberately NOT derived
// from request fields — two identical requests would hash to the same key.
// Current callers (A7): the terminal-payment idempotency key in
// CreateTerminalPayment (handlers.go:343, fresh admin actions never
// network-retried) and the till cash/on_the_house no-client-key fallback
// (till.go:406, two identical keyless cash gift-card sales are distinct
// operations). The tip flow no longer uses it — its no-client-key fallback is
// derived deterministically from the completed-tip count (see CreateTipPayment).
func uniqueChargeKey(prefix string) string {
return prefix + rand.Text()
}
// deriveBookingPaymentIdempotencyKey returns the deterministic fallback
// idempotency key for a no-client-key booking payment:
// "pay-<bookingID>-<paymentType>-<amount>-<cardID>", sha256-truncated when the
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic,
// so a same-key retry still dedups).
//
// The key must distinguish "same live operation retried" (dedup) from "new
// operation that happens to have equal amount" (new charge). The candidate is
// the base key (seq 0) and then the base key with a "-<seq>" suffix (seq ≥ 1)
// until a slot without a COMPLETED payment is found; what makes a completed
// slot "spent" depends on the type:
//
// - 'partial' (repeatable type): a completed row ALWAYS advances the
// sequence — two genuine equal-amount partial payments are distinct
// operations and must diverge onto distinct keys (the dedup lookup would
// otherwise return the first as success and silently swallow the second).
// - non-repeatable types (deposit/full/balance): a completed row advances
// only when its money is no longer live (it has a completed/pending
// refund). A refunded payment must not be returned as "success" for a new
// equal-amount charge — the booking would show paid with no money
// collected (refund-then-repay). An UN-refunded completed row is the SAME
// live operation retried, so its key is reused and the dedup lookup
// returns it (paying the same 50% deposit twice on an un-refunded booking
// MUST still dedup — the double-charge protection).
//
// A PENDING row never occupies a slot (the scan only matches 'completed'), so
// a lost-response retry of an in-flight charge re-derives the same key and
// reuses the pending record. seq 0 is the historical un-sequenced key, so
// legacy deterministic-key rows are still matched.
//
// Must be called inside the transaction holding the per-booking advisory lock
// so the spent-slot scan races no concurrent charge (mirrors the tip flow's
// no-client-key fallback, which counts completed tips under the tip lock).
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
for seq := 0; ; seq++ {
// nextIdempotencyCandidate (idempotency_helpers.go) reproduces the
// historical candidate exactly: the base key at seq 0, "base-seq" at
// seq ≥ 1, sha256-truncated to the 45-char limit under the "pay-"
// prefix when the verbatim form overflows — the key stays deterministic
// so a same-key retry still dedups (A3).
candidate := nextIdempotencyCandidate(baseKey, seq)
var completedID string
err := q.QueryRow(ctx, `
SELECT id FROM payments
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
`, bookingID, candidate).Scan(&completedID)
if errors.Is(err, pgx.ErrNoRows) {
return candidate, nil
}
if err != nil {
return "", err
}
refunded, rErr := paymentHasLiveRefund(ctx, q, completedID)
if rErr != nil {
return "", rErr
}
if paymentType == "partial" || refunded {
continue
}
return candidate, nil
}
}
// paymentHasLiveRefund reports whether the payment has a refund in a state
// meaning its money is no longer fully live: a completed refund (money
// returned) or a pending refund (money in flight). Failed refunds never moved
// money and are excluded. Used to re-validate a dedup hit — a refunded payment
// must never be returned as "success" for a new equal-amount charge.
func paymentHasLiveRefund(ctx context.Context, q db.Querier, paymentID string) (bool, error) {
var exists bool
err := q.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM refunds WHERE payment_id = $1 AND status IN ('completed', 'pending')
)
`, paymentID).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
// randomHexSuffix returns n random bytes hex-encoded (2n hex chars) from
// crypto/rand, used to disambiguate idempotency fallback keys that would
// otherwise collide on deterministic inputs (e.g. the no-client-key refund
// key). Falls back to a masked monotonic timestamp if the OS entropy source
// errors — effectively impossible on Linux (crypto/rand.Read blocks until
// entropy is available) — keeping the same width so the key stays within
// Square's 45-char idempotency-key limit.
func randomHexSuffix(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%0*x", 2*n, time.Now().UnixNano()&(int64(1)<<(8*int64(n))-1))
}
return fmt.Sprintf("%x", b)
}
// reissueTwoFACodeAfterFailedCharge mints a fresh 2FA code after a saved-card
// charge failed at Square. The charge gate consumes the verified code at gate
// time for fresh charges (single-use — closing the verify-then-consume TOCTOU
// where a verified-but-unconsumed code could authorize a second charge), so a
// failed charge leaves no live code for the same-key retry; this re-issues one
// with the same 10-minute lifetime and delivery behaviour as the user
// package's code issuance (dev/test logs the code for the operator to relay;
// production logs only with TWO_FACTOR_ALLOW_LOG_DELIVERY=true, matching the
// fail-closed delivery contract). Best-effort: a failure logs and the customer
// requests a fresh code through the normal 2FA flow. Idempotent by design — a
// pending-reuse retry whose code was NOT consumed also gets a fresh, longer-
// lived code, which never invalidates anything that still needed verifying.
func reissueTwoFACodeAfterFailedCharge(ctx context.Context, userID string) {
if userID == "" || !twoFactorEnforced() {
return
}
code, err := generatePaymentsTwoFACode()
if err != nil {
log.Printf("2FA: failed to generate a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1
`, userID, twofa.Hash(code), clock.Now().Add(twoFAPendingCodeLifetime)); err != nil {
log.Printf("2FA: failed to store a re-issued code for user %s after a failed charge: %v", userID, err)
return
}
// Delivery mirrors the user package's build-dependent behaviour (the
// operator relays the [2FA] log line). Production logs the plaintext code
// only when explicitly opted in; dev/test always.
if IsExplicitDevOrMockEnv() || os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
log.Printf("[2FA] code: %s", code)
}
}
// generatePaymentsTwoFACode returns a random 6-digit verification code,
// mirroring the user package's generator (crypto/rand, uniform 0-999999).
func generatePaymentsTwoFACode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// twoFAPendingCodeLifetime is how long a re-issued 2FA code stays valid,
// mirroring the user package's pending-code expiry.
const twoFAPendingCodeLifetime = 10 * time.Minute