Files
Crussell/backend/handlers/payments/handlers.go
T
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
2026-08-22 00:34:50 +01:00

5567 lines
259 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// InsertAdminAuditCharge records an admin-initiated money action 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.
// Exported so the user package (handlers/user/twofa.go) records admin 2FA
// code mints through this SAME helper instead of keeping a byte-identical
// cross-package copy.
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. Since C6 the row also captures the customer's versioned consent to the
// fallback (consent_version, consent_accepted, consent_versioned_at) — the
// exact values the server validated before the charge — so an operator/GDPR
// export can reconstruct which dialog version was shown, that it was accepted,
// when, and on which charge. 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, consentVersion string, consentAccepted bool) {
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,
"consent_version": consentVersion,
"consent_accepted": consentAccepted,
"consent_versioned_at": clock.Now().Format(time.RFC3339),
})
}
// logVerificationTokenProvenance records the charge context a legacy SCA
// verification_token arrived with (saved-card reference + booking) so an
// operator can correlate a minted token with the exact charge it authorized.
// The length-only ValidateVerificationToken is deliberately not extended:
// Square mints and validates these tokens server-side, binding them to the
// card + amount, and any stale/reused/mis-bound token is definitively rejected
// by Square with VERIFICATION_TOKEN_INVALID / CARD_DECLINED_VERIFICATION_REQUIRED
// (errors.go classifies those), so Square being the sole arbiter is acceptable
// — the charge fails closed on any mismatch. This log is the minimum provenance
// trace; the token is redacted to a prefix because it is a sensitive credential.
// A no-op for token-less charges (the SCA tokenize-result wire contract sends
// no verification_token at all).
func logVerificationTokenProvenance(flow, bookingID string, savedCardRef *string, token string) {
if token == "" {
return
}
ref := "(new-card)"
if savedCardRef != nil && *savedCardRef != "" {
ref = *savedCardRef
}
log.Printf("SCA verification_token present on %s charge for booking %s (saved card %s) — token %q forwarded to Square", flow, bookingID, ref, square.TokenPrefix(token))
}
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"`
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
// consent to the SCA-unavailable → 2FA fallback (C6). The frontend sends
// consent_version:"v1" + consent_accepted:true (scaFallbackConsentFields)
// when the ScaFallbackConsentDialog was accepted; the server enforces it
// (403 consent_required) before a fallback charge can reach Square and
// records it on the 2fa_fallback_charge audit row.
ConsentVersion *string `json:"consent_version,omitempty"`
ConsentAccepted bool `json:"consent_accepted"`
}
type CreateBookingPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
PaymentType string `json:"payment_type" validate:"required"`
CardID *string `json:"card_id,omitempty"`
// UserSavedCardID (saved_card_id) is the SCA path's reference to the stored
// saved card (user_saved_cards.id). It coexists with NewCardToken when the
// frontend sends the SCA tokenize-result — card.tokenize(verificationDetails,
// cardId) — as new_card_token: the tokenize-result token is a fresh
// one-time source_id and the saved-card row supplies the Square customer.
// Without a token it behaves exactly like card_id (legacy/2FA-fallback).
UserSavedCardID *string `json:"saved_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"`
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
// consent to the SCA-unavailable → 2FA fallback (C6), enforced server-side
// (403 consent_required) and recorded on the 2fa_fallback_charge audit row.
ConsentVersion *string `json:"consent_version,omitempty"`
ConsentAccepted bool `json:"consent_accepted"`
// 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"`
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
// consent to the SCA-unavailable → 2FA fallback (C6), enforced server-side
// (403 consent_required) and recorded on the 2fa_fallback_charge audit row.
ConsentVersion *string `json:"consent_version,omitempty"`
ConsentAccepted bool `json:"consent_accepted"`
}
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") {
// C2: serialize this till cash/giftcard charge under the SAME
// crussell:payment:<bookingID> advisory lock the online booking payment
// path (CreateBookingPayment) and the saved-card/terminal paths take.
// The bookings-row FOR UPDATE below only serializes against OTHER
// transactions that take the same row lock — the online path reads the
// remaining balance under the advisory lock with NO FOR UPDATE, so the
// two primitives do NOT serialize against each other: a concurrent
// online charge + till cash/giftcard charge could both pass their
// remaining-balance checks and both record money (the overflow carved
// into a non-refundable tip by buildSplitRecords). Holding the same
// advisory lock here makes every money-mutating path contend on one
// primitive; the FOR UPDATE stays as a harmless double-guard against
// concurrent cancellations. The bounded try-lock (R6) gives an
// in-flight online charge ~3s to finish, then fails this fast with 409.
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again")
if !lockOK {
return
}
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
// 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
// auditTargetUserID is the booking's customer for the MEDIUM-3a audit
// row (empty = guest booking, audited with a NULL target). Captured per
// branch and used AFTER the money commits below.
var auditTargetUserID string
if *req.PaymentMethod == "cash" {
if bookingPortionPounds > roundingEpsilon {
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 > roundingEpsilon {
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
}
}
// MEDIUM-3a: the admin-initiated CASH charge is audited in
// admin_audit_log AFTER the money commits below (best-effort,
// non-fatal). Capture the booking's customer now for the audit; a
// guest booking has no user_id and audits with a NULL target.
var cashCustomerID sql.NullString
if cuErr := tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&cashCustomerID); cuErr != nil {
log.Printf("Failed to query booking user for cash charge audit: %v", cuErr)
}
if cashCustomerID.Valid {
auditTargetUserID = cashCustomerID.String
}
} 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 > roundingEpsilon {
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 > roundingEpsilon {
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)
}
}
}
}
// The admin-initiated gift-card payment is audited AFTER the money
// commits below (best-effort). Capture the customer for the audit
// row; a guest booking has no user_id and audits with a NULL target.
if customerID.Valid {
auditTargetUserID = customerID.String
}
}
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
}
// MEDIUM-3a (best-effort, non-fatal — an audit-write failure can never
// roll back a completed charge). The audit runs AFTER the money commits:
// the OLD position wrote the row BEFORE tx.Commit, so a failed commit
// left a false audit row for a charge that never landed. Guest bookings
// audit with a NULL target_user_id (matching the till flow).
if *req.PaymentMethod == "cash" {
InsertAdminAuditCharge(r.Context(), adminID, auditTargetUserID, "admin_cash_charge", map[string]any{
"booking_id": bookingID,
"payment_id": paymentID,
"amount": amountPounds,
"payment_type": req.PaymentType,
})
} else {
InsertAdminAuditCharge(r.Context(), adminID, auditTargetUserID, "admin_giftcard_payment", map[string]any{
"booking_id": bookingID,
"payment_id": paymentID,
"amount": amountPounds,
"payment_type": req.PaymentType,
})
}
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
logVerificationTokenProvenance("admin terminal saved-card", bookingID, req.UserSavedCardID, terminalVerificationToken)
}
// 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
}
// C6: a fallback-authorized charge must carry the customer's
// accepted consent (403 consent_required otherwise) — the server-side
// guard that stops a 2FA-path charge reaching Square without it.
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
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).
writeChargeSnapshot(r.Context(), db.Conn, "payments", paymentID, paymentReq, "saved-card payment")
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 for a FRESH charge whose gate consumed a code
// (!reusePendingRecord): a pending-reuse retry verified WITHOUT
// consuming, so its code is still live and re-issuing would
// silently invalidate the one the customer holds.
if bookingUserID.Valid {
reissueTwoFACodeAfterFailedCharge(r.Context(), db.Conn, bookingUserID.String, true, twoFAFallbackUsed && !reusePendingRecord, r)
}
// 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)
}
}()
payable, err := postChargeRecheck(r.Context(), w, recheckTx, bookingID, paymentID, paymentResult.Status, paymentResult.SquarePayID, "saved-card payment", "This booking is no longer accepting payments")
if err != nil || !payable {
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 / finding 1: the charge reached its terminal SUCCESS state.
// For a FRESH charge the gate already consumed the code (consume=true
// at verify time — single-use), so nothing is left to do here. A
// PENDING-REUSE retry verified WITHOUT consuming, so THIS is where its
// code is burned — inside the transaction that records the completed
// charge. 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 && reusePendingRecord {
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)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
}
}
// 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
}
// savedCardRef is the effective saved-card reference for this request:
// the legacy card_id field OR the SCA path's saved_card_id (they are the
// same user_saved_cards.id; card_id wins when both are sent). When a
// NEW-card token arrives alongside it (SCA tokenize-result wire contract),
// the token is the one-time charge source and this row supplies the
// customer. ValidateCardInfo above already rejected card_id + new_card_token
// together, so a coexistence can only be new_card_token + saved_card_id.
savedCardRef := req.CardID
if savedCardRef == nil || *savedCardRef == "" {
savedCardRef = req.UserSavedCardID
}
scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != "" && savedCardRef != nil && *savedCardRef != ""
// 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 savedCardRef != nil && *savedCardRef != "" {
cardPart = *savedCardRef
}
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
// pendingStoredAmountPence is the amount the pending row's first attempt
// was charged at (the row stores chargeAmount — see the HIGH-2 note in the
// pending-reuse case). The retry's own chargeAmount (recomputed below) is
// compared against it after the A4 computation.
pendingStoredAmountPence := int64(0)
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.
// The amount guard is DELAYED until chargeAmount is computed below
// (HIGH-2): the pending row stores the CHARGE amount — for a
// deposit-with-discount, chargeAmount (req.Amount minus the campaign
// credit) differs from req.Amount, so comparing req.Amount here would
// 400 every legitimate deposit-with-discount retry forever. The retry's
// chargeAmount (recomputed under the same advisory lock) is compared
// against the row's stored amount after the A4 computation, in pence
// via math.Round (int64(pounds*100) truncation would reject legitimate
// same-amount retries for non-exact values — see CreateTipPayment).
paymentID = existingID.String
reusePendingRecord = true
pendingStoredAmountPence = int64(math.Round(existingAmount.Float64 * 100))
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 (auth-F1): the SAVE surface never forwards the client's
// verification_token to Square (the card is persisted via CreateCardOnFile,
// which takes no token), so a non-empty token is client-asserted and must
// NOT skip the gate — a token-less save falls back to the customer's 2FA
// code, and twoFAFallbackUsed records that the 2FA BACKUP authorized the
// operation (the caller audits it). Only the call-site's
// scaTokenizedSavedCard tokenize-result flow skips the save gate (the
// combined path never persists a card and Square validates the token as the
// source_id).
twoFAFallbackUsed := false
bookingVerificationToken := ""
if req.VerificationToken != nil {
bookingVerificationToken = *req.VerificationToken
logVerificationTokenProvenance("booking", bookingID, savedCardRef, bookingVerificationToken)
}
// scaTokenizedSavedCard (an SCA tokenize-result token charging a saved
// card) skips BOTH 2FA gates exactly like a present verification_token: the
// token only exists after the issuer completed buyer verification for this
// card + amount (SCA-primary), so no homegrown fallback authorization is
// needed. The SAVE gate is skipped because the combined path never persists
// a card (resolveChargeSource uses the token as a one-time source, no
// card-on-file is created).
if req.SaveCard && !scaTokenizedSavedCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true, false)
if !gateOK {
return
}
// C6: a fallback-authorized save/charge must carry the customer's
// accepted consent (403 consent_required otherwise).
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
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 runs against chargeAmount — the amount that will
// actually be charged at Square and split by buildSplitRecords — NOT
// req.Amount. A pending campaign credit inflated the old comparison
// (req.Amount > remaining + discount): a full/balance payment carries its
// discount client-side (chargeAmount == req.Amount), so req.Amount beyond
// the REAL remaining would pass the inflated guard and silently mint a
// pre-start tip (HIGH-1). A deposit, by contrast, is charged net of the
// server-side campaign credit (chargeAmount = req.Amount discount), so a
// deposit charge can never exceed the real remaining while req.Amount
// stays within it — comparing chargeAmount keeps the guard honest for both.
// 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
}
}
// 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 {
// A6: the deposit is charged net of the eligible campaign credit, then
// clamped DOWN to the discounted obligation max(0, remainingPence -
// eligibleDiscountPence). The OLD clamp only fired when chargeAmount<=0;
// a raw deposit between remaining and remaining+discount was charged at
// the discounted RAW amount while the headroom computation
// (discountHeadroomPence, which counts this in-flight charge) truncated
// the discount — the booking auto-completed with the customer overpaying
// by the truncated difference (Loop-B finding). Clamping ALWAYS to the
// discounted obligation guarantees the headroom always fits the full
// discount: the customer can never be charged beyond the discounted
// price, and no discount is ever truncated.
discounted := req.Amount - eligibleDiscountPence
obligation := remainingPence - eligibleDiscountPence
if obligation < 0 {
obligation = 0
}
if discounted > obligation {
chargeAmount = obligation
} else {
chargeAmount = discounted
}
}
// 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. The comparison is
// the RAW req.Amount vs the obligation: for a full/balance/partial charge
// (chargeAmount == req.Amount) the obligation is the REAL remaining (see the
// M4/M7 note above), so a charge beyond it requires confirmation. For a
// deposit-with-discount the discounted obligation is remainingPence -
// eligibleDiscountPence — the A6 clamp has already capped chargeAmount to it,
// so the guard compares req.Amount against it (Loop-B finding: a raw deposit
// between remaining and remaining+discount used to slip past the old
// chargeAmount-vs-remaining guard and silently truncate the discount). A
// chargeAmount of 0 (fully discount-covered deposit — the skip path below)
// has no money at all and never overflows. On confirmation the charge is the
// full req.Amount so buildSplitRecords carves the excess beyond the booking's
// real remaining as a tip record — the excess is never absorbed as service
// revenue.
if req.PaymentType != "tip" && chargeAmount > 0 {
overflowThreshold := remainingPence
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
overflowThreshold = remainingPence - eligibleDiscountPence
if overflowThreshold < 0 {
overflowThreshold = 0
}
}
if req.Amount > overflowThreshold {
if !req.ConfirmOverflowTip {
log.Printf("Overflow requires confirmation: requested %d exceeds obligation %d for booking %s (discount credit %d pence)", req.Amount, overflowThreshold, bookingID, eligibleDiscountPence)
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
}
chargeAmount = req.Amount
log.Printf("Overflow accepted as tip: requested %d exceeds obligation %d for booking %s (discount credit %d pence, confirmed=%v)", req.Amount, overflowThreshold, bookingID, eligibleDiscountPence, req.ConfirmOverflowTip)
}
}
// HIGH-2: a pending-reuse retry must match the CHARGE amount stored on the
// pending row (the discounted deposit charge, e.g. £40 — what the first
// attempt charged at Square), not req.Amount (£50 — the raw deposit the
// frontend resends). A mismatch proves the retry would charge a different
// amount than the row's first attempt under the same idempotency key
// (Square would reject the dedup anyway), so reject cleanly instead of
// 400-ing a legitimate deposit-with-discount retry forever.
if reusePendingRecord && pendingStoredAmountPence != chargeAmount {
log.Printf("Payment retry amount mismatch: pending record %s has %d pence, retry would charge %d pence (request %d pence)", paymentID, pendingStoredAmountPence, chargeAmount, req.Amount)
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
return
}
// 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. CRITICAL: the eligible campaign discount rows
// must be applied IMMEDIATELY (in this transaction) — the OLD deferral to
// the next real charge let the promised discount go unrecorded: the next
// balance/terminal charge F1-skipped it (discountHeadroomPence already
// spent by the real money) and the booking completed at the FULL price
// with no discount row, overcharging the customer (finding A6).
if chargeAmount <= 0 {
discountBefore := bookingDiscountPence(r.Context(), tx, bookingID)
if applyErr := applyEligibleCampaignsAtPayment(r.Context(), tx, bookingID, userID, preChargeDiscounts); applyErr != nil {
var exErr *campaignExhaustedAtApplyError
if errors.As(applyErr, &exErr) {
// B13 (Loop-B finding): the campaign was exhausted between the
// preview and the apply — the deposit is NOT discount-covered.
// The old code logged B13 and returned a success-shaped 200
// (status=completed, amount=0) with no discount row, so the
// frontend treated the deposit as PAID and a same-key retry
// could charge the full deposit. The exhaustion reserved nothing
// (the reservation is atomic and matched zero rows), so rolling
// back is clean. Mirror the real-charge path's 400
// (campaign_fully_redeemed) so the frontend prompts for the
// full amount.
log.Printf("B13: campaign %s exhausted between preview and apply for booking %s — the deposit is NOT discount-covered; no charge issued; returning 400 campaign_fully_redeemed", exErr.campaignID, bookingID)
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
}
log.Printf("Failed to apply eligible campaigns on the discount-covered deposit for booking %s: %v", bookingID, applyErr)
}
discountAfter := bookingDiscountPence(r.Context(), tx, bookingID)
discountApplied := discountAfter > discountBefore
// A fully discount-covered deposit can settle the booking: run the same
// fully-paid completion the real charge path runs, so a booking whose
// obligation is entirely covered by discount + real money completes
// instead of staying active but unpayable.
if bookingIsFullyPaid(r.Context(), tx, bookingID) {
completeActiveBookingFromPayment(r.Context(), tx, bookingID)
}
// Loop-B finding (idempotency): the skip path writes no row bound to the
// request's idempotency key, so a lost-response same-key retry re-runs
// the handler — and if the campaign has since exhausted, the retry would
// charge the FULL deposit. Attach the request key to the applied
// discount row (the ledger-correct record of the covered deposit) so the
// retry's completed-idempotency short-circuit dedups cleanly. Every
// 200-success skip path applied at least one discount row; when none
// exists nothing was credited and the idempotency gap is benign (the
// retry re-evaluates the same no-charge state).
if _, upErr := tx.Exec(r.Context(), `
UPDATE payments SET idempotency_key = $1
WHERE id = (SELECT id FROM payments
WHERE booking_id = $2 AND status = 'completed'
AND payment_method = 'discount' AND idempotency_key IS NULL
ORDER BY created_at DESC LIMIT 1)
`, req.IdempotencyKey, bookingID); upErr != nil {
log.Printf("Failed to attach idempotency key %q to the discount-covered deposit row for booking %s: %v", req.IdempotencyKey, bookingID, upErr)
}
// Commit the discount rows (and any completion) — the deferred
// rollback must not undo them.
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
}
log.Printf("Deposit for booking %s fully covered by %d pence of eligible campaign credit — skipping the Square charge (discount applied: %v)", bookingID, eligibleDiscountPence, discountApplied)
mw.RespondJSON(w, http.StatusOK, map[string]any{
"id": "",
"booking_id": bookingID,
"payment_type": req.PaymentType,
"status": "completed",
"amount": 0,
"deposit_covered_by_discount": discountApplied,
})
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 savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
if !gateOK {
return
}
// C6: a fallback-authorized charge must carry the customer's accepted
// consent (403 consent_required otherwise).
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
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).
// savedCardRef (card_id OR saved_card_id) is passed as the card reference;
// when an SCA tokenize-result token rides along in NewCardToken,
// resolveChargeSource uses the token as the source and the card row for the
// customer.
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, savedCardRef, 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).
writeChargeSnapshot(r.Context(), db.Conn, "payments", paymentID, paymentReq, "payment")
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 with save_card=false never gated and involves no code:
// re-issuing there would overwrite the customer's standing pending code
// with a fresh undelivered one, silently burning the code the operator
// relayed (finding 5). But a NEW-CARD charge with save_card=true DID
// gate — the SAVE gate above consumed the code before the charge — so
// the code must be re-issued there too or every retry hits "Verification
// code expired" forever (finding: save-gate burned code never re-issued).
// A pending-reuse retry verified WITHOUT consuming, so its code is still
// live and no re-issue runs (a re-issue would invalidate the code the
// customer already holds).
if (savedCardRef != nil && *savedCardRef != "") || req.SaveCard {
reissueTwoFACodeAfterFailedCharge(r.Context(), db.Conn, userID, true, twoFAFallbackUsed && !reusePendingRecord, r)
}
// 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.
payable, err := postChargeRecheck(r.Context(), w, tx2, bookingID, paymentID, paymentResult.Status, paymentResult.SquarePayID, "payment", "This booking is no longer accepting payments")
if err != nil || !payable {
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 / finding 1: a saved-card charge reached its terminal SUCCESS
// state. For a FRESH charge the gate already consumed the code
// (consume=true at verify time — single-use), so no write happens here.
// 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 charges — the gate only ran for those, and new-card
// charges have no code to consume.
if savedCardRef != nil && *savedCardRef != "" && reusePendingRecord {
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)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
}
// 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.
// bookingDiscountPence returns the total of completed discount payment rows on
// a booking, in pence. The A6 discount-covered deposit skip path (chargeAmount
// <= 0) measures this BEFORE and AFTER applyEligibleCampaignsAtPayment to learn
// whether a discount was actually applied — the deposit_covered_by_discount
// response flag must only be true when a discount row really was recorded (a
// campaign exhausted between preview and apply records nothing).
func bookingDiscountPence(ctx context.Context, q db.Querier, bookingID string) int64 {
var pence int64
if err := q.QueryRow(ctx, `
SELECT COALESCE(ROUND(SUM(amount) * 100), 0)
FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method = 'discount'
`, bookingID).Scan(&pence); err != nil {
log.Printf("Failed to read applied discount total for booking %s: %v", bookingID, err)
return 0
}
return pence
}
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 > roundingEpsilon {
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 > roundingEpsilon {
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 > roundingEpsilon {
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 > roundingEpsilon {
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 > roundingEpsilon {
dep := primary
dep.PaymentType = "deposit"
dep.Amount = depositAmount
records = append(records, dep)
splitIdx++
}
if balancePortion > roundingEpsilon {
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)
}
if tipAmount > roundingEpsilon {
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"`
// VerificationToken is a Square 3DS/SCA verification token. On the add-card
// SAVE surface it is CLIENT-ASSERTED and never forwarded to Square
// (CreateCardOnFile takes no verification_token), so the 2FA gate IGNORES it
// (auth-F1 — a forged value cannot authorise a save; see twofa.go). It is
// carried on the wire for parity with the charge surfaces and passed through
// to the gate, whose save variant applies the token-less SCA-only refusal
// when a genuine SCA proof is absent. The genuine proof for a SAVE is the
// STORE-intent tokenize-result submitted as card_token (see
// isSCATokenizeResultCardToken).
VerificationToken *string `json:"verification_token,omitempty"`
// ConsentVersion / ConsentAccepted mirror the charge structs (C6): the
// add-card endpoint never charges, so they are recorded on the fallback
// audit row (when the client sends them) but not enforced here.
ConsentVersion *string `json:"consent_version,omitempty"`
ConsentAccepted bool `json:"consent_accepted"`
}
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()
// SCA-only compliance (M11): the add-card surface's SCA proof is the card
// token itself. The frontend performs the STORE-intent SCA at tokenization
// (SquareCardInput.tokenizeForStore) — the tokenize-result IS the card
// token, and Square validates it as a card-on-file source when
// CreateCardOnFile persists it. A save that carries a genuine SCA
// tokenize-result as card_token is therefore SCA-compliant and skips the
// 2FA gate at the call site — exactly like the charge surfaces'
// scaTokenizedSavedCard skip: the STORE-intent SCA performed at tokenization
// IS the verification (PSR 2017 reg 100 compliant), so no homegrown fallback
// authorization is needed and no fallback audit is written.
//
// Every other save stays gated through the save-surface variant
// (tokenForwardedToSquare=false): a verification_token is client-asserted
// and NEVER forwarded to Square on a SAVE surface, so it must NOT skip the
// gate (auth-F1 — a forged value cannot authorise a save, and no 2FA code
// can either, SCA-only). A token-less legacy save (a raw card.tokenize()
// nonce, or a token the backend cannot recognize as a genuine SCA
// tokenize-result) is refused 402 verification_required in an enforced
// deployment.
saveVerificationToken := ""
if req.VerificationToken != nil {
saveVerificationToken = *req.VerificationToken
}
twoFAFallbackUsed := false
if !isSCATokenizeResultCardToken(req.CardToken) {
var gateOK bool
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, saveVerificationToken, true, false)
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. Unreachable
// under SCA-only (the gate never sets fallbackUsed), retained for parity
// with the charge surfaces.
if twoFAFallbackUsed && card != nil {
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
}
if err := json.NewEncoder(w).Encode(card); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// isSCATokenizeResultCardToken reports whether a card token submitted to the
// add-card save surface is a GENUINE Square tokenizeWithVerification result —
// the STORE-intent tokenize-result the account page's tokenizeForStore
// produces (the SCA challenge runs at tokenization, so the returned token is
// the SCA-verified source the backend stores). Real Square returns opaque cnon:
// tokens, so the dev mock's contract marks a genuine tokenize-result with
// "sca-" immediately after the cnon: prefix (internal/square's
// isSCATokenizeResultSource) — the enforcement-parity stand-in the charge
// paths also rely on (money-F2). A raw card.tokenize() nonce or any other
// shape is NOT an SCA proof and never skips the gate.
func isSCATokenizeResultCardToken(cardToken string) bool {
return strings.HasPrefix(cardToken, "cnon:sca-")
}
// 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
}
return square.ErrorCategory(err) == "INVALID_REQUEST_ERROR"
}
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: terminal COMPLETED resolves,
// FAILED/REJECTED is definitive; non-terminal states (PENDING — sweep
// reconciles — APPROVED, CANCELED, unknown) leave the refund pending.
reissueStatus, reissueTerminal := SquareRefundStatusToLocal(reissueResult.Status)
if reissueResult.Status == "APPROVED" {
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
// FAILED/CANCELED must still be able to demote the row.
reissueTerminal = false
}
if !reissueTerminal {
if reissueResult.Status == "APPROVED" {
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, reissueResult.ID, existingRefundID.String); upErr != nil {
log.Printf("Failed to record square refund id %s on pending refund %s: %v", reissueResult.ID, existingRefundID.String, upErr)
}
}
log.Printf("Square reissue %s is non-terminal (%s) — leaving refund %s pending for the sweep", reissueResult.ID, reissueResult.Status, existingRefundID.String)
} else if reissueStatus == "failed" {
log.Printf("Square reissue %s FAILED — marking refund %s failed", reissueResult.ID, existingRefundID.String)
}
if reissueTerminal {
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
}
} else {
reissueStatus = "pending"
}
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
// terminal COMPLETED resolves to completed; non-terminal states
// (PENDING — sweep reconciles — APPROVED, CANCELED, unknown) stay pending;
// FAILED/REJECTED is a real failure.
status, terminal := SquareRefundStatusToLocal(refundResult.Status)
if refundResult.Status == "APPROVED" {
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
// FAILED/CANCELED must still be able to demote the row.
terminal = false
}
if !terminal {
if refundResult.Status == "APPROVED" {
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil {
log.Printf("Failed to record square refund id %s on pending refund %s: %v", refundResult.ID, refundID, upErr)
}
}
log.Printf("Square refund %s is non-terminal (%s) — leaving refund %s pending for the sweep to resolve", refundResult.ID, refundResult.Status, refundID)
} else if status == "failed" {
log.Printf("Square refund %s FAILED — marking refund %s failed", refundResult.ID, refundID)
}
if terminal {
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
}
// MEDIUM-3a coverage: a manual refund that completes synchronously at
// Square (COMPLETED on the first attempt) records the SAME
// admin_audit_log row the sweep's re-issue writes — reuse the shared
// insertManualRefundAudit helper (refunds.go) so the row shape is
// byte-identical: action 'admin_refund', admin actor, payment id, pence
// amount and reason, best-effort own-tx non-fatal. Written AFTER the
// row is marked completed so a later sweep pass (which only processes
// still-pending rows) can never re-resolve this refund and duplicate
// the audit row.
insertManualRefundAudit(r.Context(), adminID, paymentID, req.Amount, req.Reason)
} else {
status = "pending"
}
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 non-terminal resume (PENDING — money in
// flight — APPROVED, CANCELED, unknown) 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, terminal := SquareRefundStatusToLocal(resumeResult.Status)
if resumeResult.Status == "APPROVED" {
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
// FAILED/CANCELED must still be able to demote the row.
terminal = false
}
if !terminal {
if resumeResult.Status == "APPROVED" {
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, resumeResult.ID, refundID); upErr != nil {
log.Printf("Failed to record square refund id %s on pending refund %s: %v", resumeResult.ID, refundID, upErr)
}
}
log.Printf("Square refund %s is non-terminal (%s) — leaving refund %s pending for the sweep", resumeResult.ID, resumeResult.Status, refundID)
} else if status == "failed" {
log.Printf("Square refund %s FAILED — marking refund %s failed", resumeResult.ID, refundID)
}
if terminal {
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
}
} else {
status = "pending"
}
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:
sqStatus, terminal := SquareRefundStatusToLocal(result.Status)
if result.Status == "APPROVED" {
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
// FAILED/CANCELED must still be able to demote the row.
terminal = false
}
if !terminal {
if result.Status == "APPROVED" {
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, result.ID, cf.refundID); upErr != nil {
log.Printf("Failed to record square refund id %s on pending refund %s: %v", result.ID, cf.refundID, upErr)
}
}
log.Printf("Square refund %s is non-terminal (%s) — leaving refund %s pending for the sweep", result.ID, result.Status, cf.refundID)
} else if sqStatus == "failed" {
log.Printf("Square refund %s FAILED — marking refund %s failed", result.ID, cf.refundID)
}
if terminal {
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, sqStatus, 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)
}
} else {
sqStatus = "pending"
}
status = sqStatus
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
}
// MEDIUM-3a coverage: record the admin booking refund in admin_audit_log
// (best-effort, own transaction — a failed audit write can never undo the
// refund). One row per action, not per payment, so the operator sees the
// admin decision that moved money.
InsertAdminAuditCharge(r.Context(), adminID, bookingUserID, "admin_booking_refund", map[string]any{
"booking_id": bookingID,
"amount": float64(req.Amount) / 100.0,
"reason": req.Reason,
"refund_count": len(refunds),
"refunded_payments": len(cardRefunds),
})
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). SCA-primary (auth-F1): the SAVE
// surface never forwards the client's verification_token to Square (the
// card is persisted via CreateCardOnFile, which takes no token), so a
// non-empty token is client-asserted and must NOT skip 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 = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, tipVerificationToken, true, false)
if !gateOK {
return
}
// C6: a fallback-authorized save/charge must carry the customer's
// accepted consent (403 consent_required otherwise).
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
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
}
// C6: a fallback-authorized charge must carry the customer's accepted
// consent (403 consent_required otherwise).
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
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).
writeChargeSnapshot(r.Context(), db.Conn, "payments", paymentID, paymentReq, "tip payment")
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). A NEW-CARD charge with save_card=true DID gate (the SAVE gate
// consumed the code), so the code is re-issued there too (finding:
// save-gate burned code never re-issued). A pending-reuse retry verified
// WITHOUT consuming, so its code is still live and no re-issue runs.
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
reissueTwoFACodeAfterFailedCharge(r.Context(), db.Conn, userID, true, twoFAFallbackUsed && !reusePendingRecord, r)
}
// 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)
}
}()
payable, err := postChargeRecheck(r.Context(), w, recheckTx, bookingID, paymentID, paymentResult.Status, paymentResult.SquarePayID, "tip", "This booking is no longer accepting tips")
if err != nil || !payable {
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 / finding 1: a saved-card tip charge reached its terminal
// SUCCESS state. For a FRESH charge the gate already consumed the code
// (single-use at verify time); for a PENDING-REUSE retry (gate passed
// consume=false) this is where its code is burned, inside the transaction
// that records the completed charge.
if req.CardID != nil && *req.CardID != "" && reusePendingRecord {
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)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
}
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)
}