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) } } // 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"` // new_card_token: the SCA tokenize-result token (card.tokenize( // verificationDetails, cardId)) for a saved-card charge. When present it // coexists with saved_card_id — the token is the one-time charge SOURCE and // the saved-card row supplies the customer (resolveChargeSource). Without a // token the stored ccof: card id is the source (legacy saved-card charge). NewCardToken *string `json:"new_card_token,omitempty"` // verification_token: Square 3DS/SCA verification token returned by the // frontend's buyer-verification flow (tokenizeWithVerification). When a // saved-card charge carries one, SCA has been performed by the issuer and // the homegrown 2FA gate is SKIPPED (SCA is primary). Forwarded verbatim // to Square on the CreatePaymentReq. VerificationToken *string `json:"verification_token,omitempty"` // idempotency_key: optional client-generated per-attempt UUID for saved-card // charges. The frontend generates one per distinct charge and reuses it // across retries of the SAME charge, so two DISTINCT identical charges on // one booking (e.g. a second £50 'full' charge for a second service) get // different keys and never collapse on the deterministic fallback key. // When absent, the handler falls back to the deterministic booking+type+ // amount+card key for no-client-key retry safety. Cap ≤45 (Square's // idempotency-key limit for /v2/payments — this key feeds CreatePayment). IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` } type CreateBookingPaymentRequest struct { Amount int64 `json:"amount" validate:"required,gt=0"` PaymentType string `json:"payment_type" validate:"required"` CardID *string `json:"card_id,omitempty"` // 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"` // 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" validate:"required,gt=0"` Reason string `json:"reason"` // Optional client-generated idempotency key. Two DISTINCT refunds of the // same amount against the same payment must not collide on the default // amount-derived key (the dedup lookup would swallow the second refund). // The frontend sends a UUID generated per refund attempt and reuses it on // retry, mirroring the tip-flow pattern. Cap ≤45 (Square's /v2/refunds limit). IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` } type CreateTipPaymentRequest struct { Amount int64 `json:"amount" validate:"required,gt=0"` CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } type CheckoutResponse struct { CheckoutID string `json:"checkout_id"` Status string `json:"status"` } type PaymentStatusResponse struct { Status string `json:"status"` PaymentID string `json:"payment_id,omitempty"` Amount int64 `json:"amount,omitempty"` CardBrand string `json:"card_brand,omitempty"` CardLast4 string `json:"card_last4,omitempty"` ReceiptURL string `json:"receipt_url,omitempty"` } type PaymentResponse struct { ID string `json:"id"` BookingID string `json:"booking_id"` PaymentType string `json:"payment_type"` Status string `json:"status"` Amount int64 `json:"amount"` CardBrand string `json:"card_brand,omitempty"` CardLast4 string `json:"card_last4,omitempty"` ReceiptURL string `json:"receipt_url,omitempty"` CreatedAt string `json:"created_at"` } type RefundResponse struct { ID string `json:"id"` PaymentID string `json:"payment_id"` Amount int64 `json:"amount"` Status string `json:"status"` Reason string `json:"reason"` CreatedAt string `json:"created_at"` } type PaymentSummaryResponse struct { TotalAmount int64 `json:"total_amount"` PaidAmount int64 `json:"paid_amount"` RefundedAmount int64 `json:"refunded_amount"` RemainingAmount int64 `json:"remaining_amount"` TotalVATAmount int64 `json:"total_vat_amount"` TotalNetAmount int64 `json:"total_net_amount"` Payments []PaymentResponse `json:"payments"` Refunds []RefundResponse `json:"refunds"` } // DiscountPreviewResponse describes eligible discounts for a booking. type DiscountPreviewResponse struct { Eligible bool `json:"eligible"` Discounts []DiscountPreview `json:"discounts"` OriginalTotal float64 `json:"original_total"` DiscountedTotal float64 `json:"discounted_total"` } // DiscountPreview describes a single eligible discount. type DiscountPreview struct { Source string `json:"source"` Name string `json:"name"` Percent float64 `json:"percent"` Amount float64 `json:"amount"` } // isAdminRequest is a defense-in-depth role check for admin-only payment // handlers. The routes are mounted under mw.RequireAdmin, but this in-handler // guard keeps admin-only actions (refunds, terminal charges, till sales) // protected even if a route is ever re-registered on a non-admin router (S-1). func isAdminRequest(r *http.Request) bool { role, ok := r.Context().Value(mw.UserRoleKey).(string) return ok && role == "admin" } // isVerifiedRole reports whether the authenticated user may save cards // (account_role in ('verified_email','admin')). Guests, unverified accounts, // and affiliates may still pay but must never persist a card. func isVerifiedRole(r *http.Request) bool { role, ok := r.Context().Value(mw.UserRoleKey).(string) return ok && (role == "verified_email" || role == "admin") } // rejectSaveCardForUnverified enforces the save-card product rule at the // handler level for charge endpoints. When a charge request carries // save_card=true for a non-verified user it responds 403 and returns true so // the caller aborts BEFORE resolveChargeSource (where the card would be // persisted), the pending payment record insert, or the Square call — failing // closed with no side effects. A non-verified user may still pay; only card // persistence is blocked. The dedicated save endpoints are additionally // protected by mw.RequireVerified middleware (main.go). func rejectSaveCardForUnverified(w http.ResponseWriter, r *http.Request, saveCard bool) bool { if saveCard && !isVerifiedRole(r) { http.Error(w, "Only verified accounts can save a card", http.StatusForbidden) return true } return false } // GetDiscountPreviewHandler returns eligible discounts for a booking without applying them. // GET /api/bookings/{id}/discount-preview func GetDiscountPreviewHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if !validators.IsValidID(bookingID) { http.Error(w, "Invalid booking ID", http.StatusBadRequest) return } userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } userRole, _ := r.Context().Value(mw.UserRoleKey).(string) service := NewPaymentService() // Fail closed (R5): a non-admin request must own the booking. The previous // handler computed the discount preview for ANY booking id the caller // supplied — leaking another user's booking total and eligible discounts // (IDOR). Mirror GetBookingPaymentSummary's ownership check exactly: a // request with no user context gets 401, and a non-owner gets 403. if userRole != "admin" { if userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } } preview := calculateDiscountPreview(r.Context(), bookingID, userID) if err := json.NewEncoder(w).Encode(preview); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // calculateDiscountPreview runs the same eligibility queries as // applyEligibleCampaignsAtPayment (via ComputeEligibleDiscounts) but returns // the results without inserting any records. func calculateDiscountPreview(ctx context.Context, bookingID string, userID string) DiscountPreviewResponse { resp := DiscountPreviewResponse{ Discounts: []DiscountPreview{}, } var bookingTotal float64 if err := db.Conn.QueryRow(ctx, ` SELECT total_amount FROM bookings WHERE id = $1 `, bookingID).Scan(&bookingTotal); err != nil { log.Printf("Failed to query booking total for discount preview %s: %v", bookingID, err) } if bookingTotal <= 0 { return resp } resp.OriginalTotal = bookingTotal discountTotal := 0.0 // Shared with the apply-at-payment path so the preview shows exactly what // payment will apply — including the global in-person milestone discount // that was previously only computed at payment time. for _, d := range ComputeEligibleDiscounts(ctx, db.Conn, bookingID, userID, bookingTotal) { resp.Discounts = append(resp.Discounts, DiscountPreview{ Source: d.Source, Name: d.Name, Percent: d.Percent, Amount: d.Amount, }) discountTotal += d.Amount } resp.Eligible = len(resp.Discounts) > 0 resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal) return resp } // 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 // maxOnlineTipPence caps a single ONLINE tip (CreateTipPayment, the dedicated // POST /api/bookings/{id}/tip endpoint). The terminal checkout (B3) caps its // embedded gratuity at maxTerminalTipPence (£50) because the admin keys the tip // in alongside the booking portion; the online path is customer-initiated and // is deliberately more generous. £250 is the bound because: // - tips are gratuity on a percentage of the service — the frontend presets // are 10/15/20% of the booking subtotal (frontend/src/lib/constants/policy.ts // TIP_PRESET_PCTS), so £250 is ~5x the 20% preset on even the salon's most // expensive service; // - it matches the £250 per-transaction ceiling the business already uses for // gift-card creates/topups (maxAdminGiftCardTransactionPence, giftcard_limits.go), // an owner-signed figure this codebase already treats as the generous // single-transaction bound; // - it sits far above the £50 till cap while staying well below // ValidateAmount's generic £10,000 ceiling — without it a customer could tip // £9,999 online when the till is capped at £50 (money/UX inconsistency). // - 25,000 pence is the effective online ceiling: ValidateAmount passes first, // and this stricter bound makes the generic £10,000 cap unreachable here. const maxOnlineTipPence = int64(25000) // £250 // 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: 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. When the // request carries an SCA tokenize-result token (new_card_token), it is // used as the one-time charge source and the saved-card row supplies // the customer — mirroring the booking path (handlers.go:2050-2051, // 2626). sourceID, _, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, bookingUserID.String, req.NewCardToken, 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). // scaTokenizedSavedCard (an SCA tokenize-result token charging a saved // card) skips the 2FA gate 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. scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != "" if bookingUserID.Valid && !scaTokenizedSavedCard { if gateOK, _ := requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, terminalVerificationToken, !reusePendingRecord); !gateOK { return } } // B13: the pre-charge discount SET for the post-charge apply-time // re-check. The online booking path (CreateBookingPayment) keeps the set // computed BEFORE the charge so applyEligibleCampaignsAtPayment can // detect a campaign exhausted by a concurrent redemption between the // frontend's preview and the apply-time re-check; the saved-card path // snapshots it here, under the same advisory lock, before the Square // charge. var preChargeDiscounts []EligibleDiscount // Pending-first: insert a pending payment record, commit, then charge. tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if paymentID == "" { record := PaymentRecord{ BookingID: bookingID, PaymentType: req.PaymentType, PaymentMethod: "online_square", Status: "pending", Amount: float64(amount) / 100.0, IdempotencyKey: &scKey, UserSavedCardID: req.UserSavedCardID, SquareSourceID: &sourceID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &adminID, } // M4: the service's record function sets every column the inline // INSERT previously left to defaults (fees, VAT fields, etc.), so // the pending row is created the same way every other flow creates // its payment records. var insertErr error paymentID, insertErr = service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if insertErr != nil { log.Printf("Failed to insert pending saved-card payment: %v", insertErr) _ = tx.Rollback(r.Context()) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else { // Reused pending row: same immutability rule as the booking/tip // reuse paths. square_source_id is refreshed ONLY for snapshot-less // legacy rows; when the row already carries the original // square_request_snapshot it is left untouched so the sweep's // by-key replay keeps matching the FIRST attempt's body. Saved-card // ccof sources are stable, so this is mostly latent, but keeping // the snapshot immutable is money-safe (see the booking reuse // comment above). if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil { log.Printf("Failed to update square_source_id on reused saved-card payment %s: %v", paymentID, srcErr) } } // B13: snapshot the pre-charge discount set (read-only, under the // advisory lock) so the post-charge re-check can surface a campaign // exhausted by a concurrent redemption (see the declaration above). var bookingTotal float64 if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil { log.Printf("Failed to load booking total for discount computation: %v", err) } preChargeDiscounts = ComputeEligibleDiscounts(r.Context(), tx, bookingID, bookingUserID.String, bookingTotal) if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit pending saved-card payment: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } var buyerEmail string if bookingUserID.Valid { _ = db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, bookingUserID.String).Scan(&buyerEmail) } paymentReq := square.CreatePaymentReq{ Amount: amount, Currency: "GBP", SourceID: sourceID, CustomerID: savedCardCustomerID, IdempotencyKey: scKey, ReferenceID: bookingID, Note: req.PaymentType, BuyerEmail: buyerEmail, VerificationToken: terminalVerificationToken, // MIT (merchant-initiated): the admin charging the customer's SAVED // card (admin "Charge Saved Card") is a merchant-initiated stored- // credential charge — NOT the cardholder. customer_initiated=false // classifies it MIT for Square: no SCA is demanded and no liability // shift applies, which is the correct treatment for an operator- // initiated charge (any issuer challenge is handled via // verification_token when the frontend performs one). CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false}, } // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot // is written ONLY when the row has none: it records the FIRST attempt's // body, which stays immutable so a reused pending row never redirects // the sweep's replay away from the original charge (same rule as the // booking/tip paths — see the reuse branch above). 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)) // 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) } } // R10: guard the completion flip on status='pending'. The Square // payment.completed webhook (webhooks/square.go) can win the booking // FOR UPDATE lock between Square returning and this flip, completing // the payment row and running the booking-completion side-effects // itself. Without the guard this UPDATE would blindly re-flip the // already-completed row (RowsAffected=1), re-run VAT and the // completion side-effects below, and double-record money. With the // guard, a row the webhook/sweep already resolved is a no-op // (RowsAffected=0): the payment IS completed — skip the side-effects // and report success. flipRes, upErr := recheckTx.Exec(r.Context(), `UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2 AND status = 'pending'`, paymentResult.SquarePayID, paymentID, ) if upErr != nil { log.Printf("CRITICAL: Square payment %s succeeded but saved-card payment %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, paymentID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if flipRes.RowsAffected() == 0 { // Defense-in-depth re-read (R10): the guarded flip no-opped, so a // concurrent resolver already moved the row off 'pending'. Re-read // the status inside THIS transaction to distinguish a webhook- // completed row (skip everything — the webhook already ran the // completion side-effects) from a vanished/failed row (CRITICAL). var curStatus string rErr := recheckTx.QueryRow(r.Context(), `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&curStatus) if rErr != nil || curStatus != "completed" { log.Printf("CRITICAL: Square payment %s (ID=%s) succeeded but payment row %s could not be verified as completed (re-read status=%q err=%v) — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, curStatus, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("Saved-card payment %s for booking %s was already resolved to %q by a concurrent resolver (Square webhook/sweep) before the sync completion flip — skipping completion side-effects", paymentID, bookingID, curStatus) // MEDIUM-2: burn the re-issued 2FA code anyway (idempotent) — the // charge reached terminal success, so a single-use code re-issued // for this retry must not authorize another charge. 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 } } if cErr := recheckTx.Commit(r.Context()); cErr != nil { log.Printf("CRITICAL: Square payment %s succeeded but committing the post-charge transaction for already-completed payment %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, paymentID, cErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } // The payment IS completed (by the webhook/sweep) — report success // exactly like the normal completion path; no side-effects re-run. 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 } // 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, }) } // 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-") 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: 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: 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-" 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.UserSavedCardID, 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 validated the three legal card // source shapes (saved-card ref alone, token alone, or ref + token as the // SCA tokenize-result source). 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) case errors.Is(err, pgx.ErrNoRows): // R12: no payment on THIS booking carries the key, but the key may // still exist globally — payments.idempotency_key is UNIQUE — on a // DIFFERENT booking. Without a guard the pending insert below would die // on the constraint → 500 → the frontend's same-key retry 500s forever. // Mirror the A6 cross-user collision pattern (giftcards.go:1592-1607): // a foreign match is not this booking's operation, so derive a fresh // deterministic key for THIS booking and proceed as a fresh charge. // A deterministic no-client-key fallback already embeds this booking's // id, so this branch can only fire for a client-supplied key. var foreignID string fErr := tx.QueryRow(r.Context(), ` SELECT id FROM payments WHERE idempotency_key = $1 AND booking_id != $2 `, req.IdempotencyKey, bookingID).Scan(&foreignID) if fErr == nil { log.Printf("Payment idempotency key %q matched payment row %s on a different booking — deriving a fresh deterministic key for booking %s", req.IdempotencyKey, foreignID, bookingID) cardPart := "new" if savedCardRef != nil && *savedCardRef != "" { cardPart = *savedCardRef } derivedKey, dErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart) if dErr != nil { log.Printf("Failed to derive fresh booking idempotency key after cross-booking collision: %v", dErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } req.IdempotencyKey = derivedKey } else if !errors.Is(fErr, pgx.ErrNoRows) { log.Printf("Failed to check cross-booking idempotency collision for key %q: %v", req.IdempotencyKey, fErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // 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. 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 is refused 402 (SCA-only). 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). 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). // // R13: the charge-time SAVE gate's tokenForwardedToSquare=false variant // refuses 402 verification_required in an enforced deployment — but a // genuine SCA tokenize-result (cnon:sca-... / verify_mock_..., the shapes // the frontend's tokenizeWithVerification mints) only exists after the // buyer completed the STORE-intent SCA, so it is SCA-proven and skips the // gate. This unblocks the NEW-card SCA tokenize-result save (a cnon:sca-... // token with save_card=true and no saved-card reference), which // resolveChargeSource persists via CreateCardOnFile. A plain card-entry // nonce (cnon:... without the sca- marker — card.tokenize() with no // verification) is NOT an SCA proof and keeps failing closed through the // gate — 402 in an enforced deployment (auth-F1). saveGateToken := "" if req.NewCardToken != nil { saveGateToken = *req.NewCardToken } if req.SaveCard && !scaTokenizedSavedCard && !isSCATokenizeResultShape(saveGateToken) { if gateOK, _ := requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, bookingVerificationToken, true, false); !gateOK { return } } // After the idempotency check (which handles same-key retries), verify // that no completed payment of the same non-partial type already exists. // buildSplitRecords converts 'full' and 'deposit' input types into a // 'deposit' PB record, so we also check for an existing deposit when the // incoming type is 'full' or 'deposit'. Together with the advisory lock, // this prevents the two-tab race where different idempotency keys allow // concurrent payments of the same type. if req.PaymentType != "partial" { var existingCount int if err := tx.QueryRow(r.Context(), ` SELECT COUNT(*) FROM payments p WHERE p.booking_id = $1 AND p.status = 'completed' AND p.payment_method NOT IN ('discount', 'on_the_house') AND ( p.payment_type = $2 OR ($2 IN ('full', 'deposit') AND p.payment_type = 'deposit') ) AND NOT EXISTS ( SELECT 1 FROM refunds r WHERE r.payment_id = p.id AND r.status IN ('completed', 'pending') ) `, 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 } // maxOnlineTipPence cap (the £250 bound the dedicated tip endpoint // enforces): the portion buildSplitRecords will actually carve as a // payment_type='tip' row is the excess beyond the booking's REAL // remaining obligation (TotalAmount - TotalPaid, which excludes // discount rows) — the post-start carve and the pre-start // deposit/balance carve both compute tipPortion = // paymentAmount - realRemaining. The deposit-with-discount // overflowThreshold compared above is only the CONFIRMATION // trigger (a raw deposit between the discounted obligation and the // real remaining needs the flag but carves no tip); capping the // real tip portion keeps every minted tip row within // maxOnlineTipPence. Without this, a confirmed £10,000 payment on // a booking with £50 remaining would mint a £9,950 tip row — far // over the cap and unreachable by the tip-refund path. if tipPortion := req.Amount - remainingPence; tipPortion > maxOnlineTipPence { log.Printf("Overflow tip rejected: requested %d exceeds obligation %d for booking %s — the %d pence tip portion exceeds the £250 online tip cap (discount credit %d pence)", req.Amount, overflowThreshold, bookingID, tipPortion, eligibleDiscountPence) http.Error(w, "Tip exceeds the maximum allowed amount (£250)", http.StatusBadRequest) 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. A charge carrying a // Square verification_token (SCA performed) skips the gate; a token-less // charge is refused 402 verification_required (SCA-only — the homegrown 2FA // fallback was removed). if savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard { if gateOK, _ := requireTwoFactorForCardAccess(w, r, service, userID, bookingVerificationToken, !reusePendingRecord); !gateOK { return } } // Resolve the new-card-vs-saved-card Square source (shared with // CreateTipPayment, BuyGiftCard, and the saved-card branch of // CreateTerminalPayment — see resolveChargeSource for the R6 rationale). // 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)) // 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 } // R11: a nil error does not mean the payment is COMPLETED. Square's // terminal payment states are COMPLETED, CANCELED, FAILED; APPROVED // (authorization-only) and PENDING are NON-terminal — both can still // transition to COMPLETED. Mirror the stale-pending sweep's classification // (sweep.go classifyStalePendingByKey): APPROVED/PENDING stay pending for a // later sweep run to re-poll (staleReconcileLeavePending), CANCELED/FAILED // are marked failed (the charge never landed). A status-blind handler that // records 'completed' on nil error alone would book money that was never // collected. if paymentResult.Status != "COMPLETED" { routeNonCompletedPayment(r.Context(), w, bookingID, paymentID, paymentResult, "booking") 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 { var splitErr error records, splitErr = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount) if splitErr != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but buildSplitRecords rejected the split for booking %s: %v — the pending record %s stays for the stale-pending sweep; manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, bookingID, splitErr, paymentID) http.Error(w, "internal server error", http.StatusInternalServerError) return } } 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] // R10: guard the split-primary flip on status='pending'. The Square // payment.completed webhook can win the booking FOR UPDATE lock between // the Square call and this UPDATE and complete the payment row + run the // completion side-effects itself. Without the guard this UPDATE would // blindly re-flip the already-completed row (RowsAffected=1), OVERWRITE // the primary row's amount/payment_type/fees/VAT with the split values, // and re-insert duplicate split tip/balance rows — the ledger would no // longer reconcile to the actual Square charge. With the guard a row the // webhook/sweep already resolved is a no-op (RowsAffected=0): the payment // IS completed — skip the split/booking side-effects and report success. flipRes, 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 AND status = 'pending' `, paymentResult.SquarePayID, primary.Amount, primary.PaymentType, primary.Fees, paymentID) if 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 } if flipRes.RowsAffected() == 0 { // Defense-in-depth re-read (R10): distinguish a webhook-completed row // (skip everything — the webhook already ran the completion side- // effects; the row keeps the webhook's values, not the split overwrite) // from a vanished/failed row (CRITICAL — money was taken at Square). var curStatus string rErr := tx2.QueryRow(r.Context(), `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&curStatus) if rErr != nil || curStatus != "completed" { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but payment row %s could not be verified as completed (re-read status=%q err=%v) — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, curStatus, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("Payment %s for booking %s was already resolved to %q by a concurrent resolver (Square webhook/sweep) before the sync completion flip — skipping split records, VAT and booking-completion side-effects", paymentID, bookingID, curStatus) // R10 belt-and-braces: verify the booking IS actually completed. The // webhook path runs ApplyBookingCompletionSideEffects (which applies // campaigns at completion time) only when the booking is fully paid and // transitions to 'completed'. If the booking is NOT completed despite // the payment being completed, the booking is stuck in a non-terminal // state with a completed payment — manual reconciliation required. var bookingStatus string if bErr := tx2.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus); bErr != nil || bookingStatus != "completed" { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed and payment row %s is completed, but booking %s is in status %q (not 'completed') — the booking is stuck in a non-terminal state with a completed payment — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, bookingID, bookingStatus) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-2: burn the re-issued 2FA code anyway (idempotent) — the // charge reached terminal success, so a single-use code re-issued for // this retry must not authorize another charge. 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 } } if cErr := tx2.Commit(r.Context()); cErr != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but committing the transaction for already-completed payment %s failed: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, cErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } // The payment IS completed (by the webhook/sweep) — report success // exactly like the normal completion path; no side-effects re-run. 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) } 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. // Tip records are EXCLUDED: vat.go's single-source policy (see // ApplyVATToBookingPayment, which skips discount / on_the_house / tip rows) // and every other tip path (CreateTipPayment, the terminal sweep rescue) // apply VAT to the booking portion only — an overflow payment that carves a // payment_type='tip' record must never have VAT applied to the tip. The // records slice holds the primary (records[0], the committed pending row // paymentID) followed by the additional split records (paymentIDs, in // order), so the loop keys on the record's own payment_type. vatCfg, vatErr := GetVATConfig(r.Context(), tx2) if vatErr == nil && vatCfg.IsVATRegistered { for i, rec := range records { if rec.PaymentType == "tip" { continue } pid := paymentID if i > 0 { pid = paymentIDs[i-1] } 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 } // 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) } } // routeNonCompletedPayment handles a CreatePayment result whose status is not // COMPLETED, mirroring the stale-pending sweep's classification (sweep.go // classifyStalePendingByKey): APPROVED and PENDING are NON-terminal — Square // may still settle the charge — so the pending row is left pending for a later // sweep run to re-poll (staleReconcileLeavePending); CANCELED, FAILED and any // unknown status are terminal non-success — the charge never landed, so the row // is marked failed so a same-key retry can never issue a second Square charge. // The HTTP response is a 5xx in both cases: the payment is NOT complete, the // frontend must not show success, and the sweep reconciles the row in the // background regardless. func routeNonCompletedPayment(ctx context.Context, w http.ResponseWriter, bookingID, paymentID string, paymentResult *square.PaymentResult, label string) { switch paymentResult.Status { case "APPROVED", "PENDING": log.Printf("%s payment %s for booking %s is %q at Square (non-terminal) — leaving the row pending; the stale-pending sweep will reconcile it", label, paymentID, bookingID, paymentResult.Status) http.Error(w, "Payment is still processing at the payment provider and has not yet been confirmed", http.StatusInternalServerError) default: log.Printf("%s payment %s for booking %s is %q at Square (terminal non-success) — marking the row failed", label, paymentID, bookingID, paymentResult.Status) if _, upErr := db.Conn.Exec(ctx, `UPDATE payments SET status = 'failed' WHERE id = $1 AND status = 'pending'`, paymentID); upErr != nil { log.Printf("CRITICAL: Square %s payment %s (ID=%s) is %q but marking payment %s failed errored: %v — manual reconciliation required", label, paymentID, paymentResult.SquarePayID, paymentResult.Status, paymentID, upErr) } http.Error(w, "Payment failed", http.StatusPaymentRequired) } } // 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, error) { // 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 { // Belt-and-braces: the online tip bound (maxOnlineTipPence) applies // to EVERY tip row — including overflow tips carved from a booking // charge. The B12 gate enforces it before the charge is taken; this // check catches any future caller that skips the gate. Returning an // error (never clamping) preserves the partition invariant: the // records must always sum to the charged paymentAmount. if tipPence := int64(math.Round(tipPortion * 100)); tipPence > maxOnlineTipPence { return nil, fmt.Errorf("buildSplitRecords: post-start carve for booking %s would mint a tip of %d pence, exceeding the £250 online tip cap (charge %.2f)", primary.BookingID, tipPence, paymentAmount) } 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), nil } return []PaymentRecord{primary}, nil } // 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 // Belt-and-braces (same bound as the post-start carve and the dedicated // tip endpoint): the pre-start deposit/balance/tip carve can mint a tip // row when the payment exceeds the booking's REAL remaining (e.g. a // deposit-with-discount overflow). The B12 gate enforces the cap before // the charge; this check catches any future caller that skips the gate. // Never clamp — the records must partition the charged amount exactly. if tipPortion > roundingEpsilon { if tipPence := int64(math.Round(tipPortion * 100)); tipPence > maxOnlineTipPence { return nil, fmt.Errorf("buildSplitRecords: pre-start carve for booking %s would mint a tip of %d pence, exceeding the £250 online tip cap (charge %.2f)", primary.BookingID, tipPence, paymentAmount) } } 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, nil } // 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_" payment IDs); an over-length base is routed through // the deterministic truncateIdempotencyKey helper (idempotency_helpers.go) so // the key always fits and the suffix stays verbatim (the split role remains // readable and the -split-1 / -split-tip distinction is exact). Deterministic // hashing — NOT a raw prefix cut — preserves uniqueness: two distinct base keys // (e.g. Square payment IDs differing only in their tail) must never collapse // onto the same truncated prefix, which would 500 the second insert on the // UNIQUE(idempotency_key) index. func splitIdempotencyKey(base, suffix string) string { maxBase := 64 - len(suffix) if len(base) > maxBase { base = truncateIdempotencyKey("split", base) } 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"` // 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 // token itself — any Square token-like card token (cnon:/ccof:) was minted // by a tokenization flow that ran the STORE-intent SCA, so it is treated as // SCA-proven and the gate is skipped (see CreatePaymentMethod). 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. Any genuine Square token-like card token // (a cnon: nonce or ccof: card id — the only shapes the mock's // CreateCardOnFile and real Square accept, plus the dev mock's // verify_mock_ transition shape) was minted by a tokenization flow, so it is // treated as SCA-proven and the homegrown gate is skipped; Square's own // acceptance of the token is the authoritative check. The fictional // cnon:sca- marker requirement is gone — real Square never produces it, so // it made every save 402 in an enforced deployment. // // A non-token-like value (a raw PAN or any other shape) fails closed // 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). It is // refused 402 verification_required in an enforced deployment. saveVerificationToken := "" if req.VerificationToken != nil { saveVerificationToken = *req.VerificationToken } if !isTokenLikeSaveSource(req.CardToken) { if gateOK, _ := requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, saveVerificationToken, true, false); !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 } if err := json.NewEncoder(w).Encode(card); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // isTokenLikeSaveSource reports whether a card token submitted to the add-card // save surface is a genuine Square token-like source — the shapes the mock's // CreateCardOnFile accepts (cnon: nonces and ccof: card ids, plus the dev // mock's verify_mock_ transition shape), which real Square only mints after a // tokenization flow ran the STORE-intent SCA. Anything else (a raw PAN, an // arbitrary string) is not token-like and fails closed through the 2FA gate. func isTokenLikeSaveSource(cardToken string) bool { return strings.HasPrefix(cardToken, "cnon:") || strings.HasPrefix(cardToken, "ccof:") || strings.HasPrefix(cardToken, "verify_mock_") } // isSCATokenizeResultShape reports whether a card token is a genuine SCA // tokenize-result — the shapes Square mints only AFTER the buyer completed // issuer verification (the frontend's tokenizeWithVerification returns // cnon:sca-__ok|_deny; verify_mock_ is the dev/mock transition // shape). Mirrors the dev mock's isSCATokenizeResultSource. Unlike // isTokenLikeSaveSource (the DEDICATED add-card surface, where every token // passed through a STORE-intent tokenize flow), a plain card-entry nonce // (cnon:... without the sca- marker) is NOT an SCA proof — it is a raw // card.tokenize() result — so the charge-time SAVE gate must keep refusing it // 402 in an enforced deployment (auth-F1). func isSCATokenizeResultShape(cardToken string) bool { return strings.HasPrefix(cardToken, "cnon:sca-") || strings.HasPrefix(cardToken, "verify_mock_") } // 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: // 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 } if err := ValidateAmount(req.Amount); 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. 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 is refused // 402 (SCA-only). tipVerificationToken := "" if req.VerificationToken != nil { tipVerificationToken = *req.VerificationToken } // savedCardRef is the effective saved-card reference for this request: // card_id is the saved-card ref; 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. savedCardRef := req.CardID scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != "" && savedCardRef != nil && *savedCardRef != "" saveGateToken := "" if req.NewCardToken != nil { saveGateToken = *req.NewCardToken } if req.SaveCard && !scaTokenizedSavedCard && !isSCATokenizeResultShape(saveGateToken) { if gateOK, _ := requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, tipVerificationToken, true, false); !gateOK { return } } // maxOnlineTipPence bound: ValidateAmount's generic £10,000 cap is the // money-minting ceiling for booking charges, but a tip is gratuity on a // percentage of the service — a single online tip over £250 is not a // legitimate business transaction (the till's embedded-gratuity bound is // only £50). Reject BEFORE any charge-source resolution, idempotency // handling, or Square call, so no pending record or charge is ever minted // for an over-bound tip. if req.Amount > maxOnlineTipPence { log.Printf("Tip rejected: booking %s requested a tip of %d pence which exceeds the £250 online tip cap", bookingID, req.Amount) http.Error(w, "Tip exceeds the maximum allowed amount (£250)", http.StatusBadRequest) return } if err := ValidateCardInfo(req.CardID, nil, 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. Shares the crussell:payment: // key with the booking-payment handlers so a tip cannot race an in-flight // payment on the same booking (they don't nest — no handler re-acquires // this lock while holding it — so sharing the key cannot deadlock). // 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) // 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. A charge carrying a Square verification_token (SCA // performed) skips the gate; a token-less charge is refused 402 // verification_required (SCA-only — the homegrown 2FA fallback was removed). if savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard { if gateOK, _ := requireTwoFactorForCardAccess(w, r, service, userID, tipVerificationToken, !reusePendingRecord); !gateOK { return } } if !reusePendingRecord { record := PaymentRecord{ BookingID: bookingID, PaymentType: "tip", PaymentMethod: "online_square", Status: "pending", Amount: float64(req.Amount) / 100.0, IdempotencyKey: &idempotencyKey, Fees: 0, UserSavedCardID: savedCardID, SquareSourceID: &sourceID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &userID, } paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, record, nil) if err != nil { log.Printf("Failed to create payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } ApplyVATToBookingPayment(r.Context(), tx, paymentID) } else { // Reused pending row. The stored square_request_snapshot is the FIRST // attempt's charge body and MUST remain immutable across nonce-changing // retries: if that original charge actually landed at Square (the row // is pending only because the post-charge outcome is unknown), the // by-key sweep replay must match the original body so Square's // idempotency dedup returns the landed payment and the sweep rescues // the row. Overwriting the snapshot's SourceID with this retry's fresh // nonce — or refreshing the square_source_id column the sweep overrides // the replay source with — would make the sweep replay the NEW source, // Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge // would never be rescued (stranded until the 24h blind-fail). A retry // that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the // sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the // row's true state from the immutable first-attempt body instead. The // column is refreshed ONLY for snapshot-less legacy rows, whose // fallback replay body is rebuilt from it (and which get a fresh // snapshot from the post-commit write below). if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil { log.Printf("Failed to update square_source_id on reused tip payment %s: %v", paymentID, srcErr) } } // Always commit the transaction. In the reuse path no rows were written, // but the commit is required in the test harness: there the context carries // an outer test tx, so Begin creates a nested savepoint whose deferred // rollback would otherwise undo the status UPDATE executed later on the // same connection. In production Begin is a plain tx and this commit is a // harmless no-op that keeps both paths identical. if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Step 2: DB transaction committed — safe to call Square now. // If Square fails, the record stays 'pending' for manual retry. // Resolve the user's email for Square receipt delivery. var buyerEmail string if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, CustomerID: savedCardCustomerID, IdempotencyKey: idempotencyKey, ReferenceID: bookingID, Note: "tip", BuyerEmail: buyerEmail, VerificationToken: tipVerificationToken, // C3: the tip charge is cardholder-initiated whether it uses a saved // card (ccof — customer_details required) or a freshly entered card // (cnon — buyer present), so the flag is true either way. CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, } // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is // written ONLY when the row has none: it records the FIRST attempt's body, // which stays immutable so a nonce-changing retry can never redirect the // sweep's replay away from the original charge (see the reuse branch above). 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. // 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 } // R11: a nil error does not mean the payment is COMPLETED — see // CreateBookingPayment. APPROVED/PENDING stay pending for the sweep to // re-poll (staleReconcileLeavePending); CANCELED/FAILED are marked failed // (the tip charge never landed). if paymentResult.Status != "COMPLETED" { routeNonCompletedPayment(r.Context(), w, bookingID, paymentID, paymentResult, "tip") 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. // R10: guard the flip on status='pending'. The Square payment.completed // webhook can win the booking FOR UPDATE lock between the Square call and // this UPDATE and complete the tip row itself. Without the guard this // would blindly re-flip the already-completed row and re-run the 2FA // consume below; with it, a row the webhook/sweep already resolved is a // no-op (RowsAffected=0): the tip IS completed — report success. flipRes, upErr := recheckTx.Exec(r.Context(), `UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2 AND status = 'pending'`, paymentResult.SquarePayID, paymentID, ) if upErr != nil { log.Printf("Failed to update payment %s after Square success: %v (square_payment_id=%s)", paymentID, upErr, paymentResult.SquarePayID) // Square charge succeeded but status update failed. // Record stays 'pending' for manual reconciliation. http.Error(w, "internal server error", http.StatusInternalServerError) return } if flipRes.RowsAffected() == 0 { // Defense-in-depth re-read (R10): distinguish a webhook-completed tip // row (skip the side-effects — the webhook already recorded it) from // a vanished/failed row (CRITICAL — money was taken at Square). var curStatus string rErr := recheckTx.QueryRow(r.Context(), `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&curStatus) if rErr != nil || curStatus != "completed" { log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but tip row %s could not be verified as completed (re-read status=%q err=%v) — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, curStatus, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("Tip payment %s for booking %s was already resolved to %q by a concurrent resolver (Square webhook/sweep) before the sync completion flip — skipping completion side-effects", paymentID, bookingID, curStatus) // MEDIUM-2: burn the re-issued 2FA code anyway (idempotent) — the // charge reached terminal success, so a single-use code re-issued for // this retry must not authorize another 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 transaction for already-completed tip %s failed: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, paymentID, cErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } // The tip IS completed (by the webhook/sweep) — report success exactly // like the normal completion path; no side-effects re-run. 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) } 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 } if err := json.NewEncoder(w).Encode(PaymentResponse{ ID: paymentID, BookingID: bookingID, PaymentType: "tip", Status: "completed", Amount: req.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, CreatedAt: clock.Now().Format(time.RFC3339), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, _ := r.Context().Value(mw.UserIDKey).(string) userRole, _ := r.Context().Value(mw.UserRoleKey).(string) service := NewPaymentService() // Fail closed: a non-admin request must carry a user ID. The previous // `userID != ""` guard silently skipped the ownership check for requests // with no user context, leaking another user's payment summary. if userRole != "admin" { if userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking user: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } } summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID) if err != nil { log.Printf("Failed to get payment summary: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } payments := make([]PaymentResponse, len(summary.Payments)) for i, p := range summary.Payments { payments[i] = PaymentResponse{ ID: p.ID, BookingID: p.BookingID, PaymentType: p.PaymentType, Status: p.Status, Amount: int64(math.Round(p.Amount * 100)), CardLast4: p.CardLast4, CreatedAt: p.CreatedAt.Format(time.RFC3339), } } refunds := make([]RefundResponse, len(summary.Refunds)) for i, rf := range summary.Refunds { refunds[i] = RefundResponse{ ID: rf.ID, PaymentID: rf.PaymentID, Amount: int64(math.Round(rf.Amount * 100)), Status: rf.Status, Reason: rf.Reason, CreatedAt: rf.CreatedAt.Format(time.RFC3339), } } if err := json.NewEncoder(w).Encode(PaymentSummaryResponse{ TotalAmount: int64(math.Round(summary.TotalAmount * 100)), PaidAmount: int64(math.Round(summary.PaidAmount * 100)), RefundedAmount: int64(math.Round(summary.RefundedAmount * 100)), RemainingAmount: int64(math.Round(summary.RemainingAmount * 100)), TotalVATAmount: int64(math.Round(summary.TotalVATAmount * 100)), TotalNetAmount: int64(math.Round(summary.TotalNetAmount * 100)), Payments: payments, Refunds: refunds, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // PaymentLockDuration is the TTL for a payment-in-flight lock in minutes. const PaymentLockDuration = 5 // AcquirePaymentLock creates or extends a 5-minute time_blocker for the // booking's slot so that pending_release eviction is blocked during card // entry and Square charge processing. func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Verify the user owns this booking. var bookingUserID string if err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID, ).Scan(&bookingUserID); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } if bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } // Before acquiring the lock, double-check the slot is still available. // For confirmed/in_progress bookings this is a formality; for // pending_release bookings it catches the eviction race before we // create a time_blocker — the NOT EXISTS guard in eviction queries // handles the sub-5-minute race, this catches the >5-minute gap. var currentStatus string var startTime time.Time if err := db.Conn.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1", bookingID, ).Scan(¤tStatus, &startTime); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } // If the booking has been evicted (deposit_lapsed) or reached a terminal // state, reject the lock — payment cannot proceed. if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" { log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus) http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict) return } // Upsert the time_blocker atomically: delete old PAYMENT_IN_FLIGHT and insert // a fresh one in a single transaction. Prevents lock loss if INSERT fails. tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction for payment lock: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 `, bookingID); err != nil { log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES (NOW(), $1, $2, $3) `, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil { log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err) http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit payment lock transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]any{ "status": "locked", "ttl_min": PaymentLockDuration, "bookingID": bookingID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking. func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } // Mirror AcquirePaymentLock's ownership check: releasing another user's // PAYMENT_IN_FLIGHT blocker would evict their slot mid-payment. The // booking's own user (or an admin) may release it. userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } userRole, _ := r.Context().Value(mw.UserRoleKey).(string) var bookingUserID string if err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID, ).Scan(&bookingUserID); err != nil { http.Error(w, "Booking not found", http.StatusNotFound) return } if userRole != "admin" && bookingUserID != userID { http.Error(w, "Unauthorized", http.StatusForbidden) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1 `, bookingID); err != nil { log.Printf("Failed to release payment lock for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // uniqueChargeKey generates a unique idempotency key under the given prefix // (e.g. "till-") where the client did not supply one. Client-supplied keys // handle retry dedup; this fallback only needs uniqueness so two legitimate // identical requests never collapse on the same key. Deliberately NOT derived // from request fields — two identical requests would hash to the same key. // Current callers (A7): the terminal-payment idempotency key in // CreateTerminalPayment (handlers.go:343, fresh admin actions never // network-retried) and the till cash/on_the_house no-client-key fallback // (till.go:406, two identical keyless cash gift-card sales are distinct // operations). The tip flow no longer uses it — its no-client-key fallback is // derived deterministically from the completed-tip count (see CreateTipPayment). func uniqueChargeKey(prefix string) string { return prefix + rand.Text() } // deriveBookingPaymentIdempotencyKey returns the deterministic fallback // idempotency key for a no-client-key booking payment: // "pay----", 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 "-" 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) }