Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
5791 lines
273 KiB
Go
5791 lines
273 KiB
Go
package payments
|
||
|
||
import (
|
||
"context"
|
||
"crussell/clock"
|
||
"crussell/db"
|
||
"crussell/internal/square"
|
||
"crussell/internal/twofa"
|
||
"crussell/internal/validators"
|
||
"crussell/mw"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"math"
|
||
"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:<bookingID> advisory lock the online booking payment
|
||
// path (CreateBookingPayment) and the saved-card/terminal paths take.
|
||
// The bookings-row FOR UPDATE below only serializes against OTHER
|
||
// transactions that take the same row lock — the online path reads the
|
||
// remaining balance under the advisory lock with NO FOR UPDATE, so the
|
||
// two primitives do NOT serialize against each other: a concurrent
|
||
// online charge + till cash/giftcard charge could both pass their
|
||
// remaining-balance checks and both record money (the overflow carved
|
||
// into a non-refundable tip by buildSplitRecords). Holding the same
|
||
// advisory lock here makes every money-mutating path contend on one
|
||
// primitive; the FOR UPDATE stays as a harmless double-guard against
|
||
// concurrent cancellations. The bounded try-lock (R6) gives an
|
||
// in-flight online charge ~3s to finish, then fails this fast with 409.
|
||
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again")
|
||
if !lockOK {
|
||
return
|
||
}
|
||
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
|
||
|
||
// Start transaction before the status and idempotency checks so they
|
||
// are atomic with the payment insert.
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
// Check booking status inside the transaction. FOR UPDATE (C5): the
|
||
// payment insert below commits in this same transaction, so a
|
||
// concurrent cancellation (which takes the same row lock) must not be
|
||
// able to commit a cancelled status between this read and the commit —
|
||
// otherwise the payment would land on a cancelled booking with no
|
||
// refund ever generated.
|
||
var status string
|
||
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&status); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to get booking status: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if status != "in_progress" && status != "completed" {
|
||
http.Error(w, "Booking must be in_progress or completed to create payment", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// B3: clamp the recorded amount to the booking's remaining obligation
|
||
// unless the customer explicitly requested a tip (tip_enabled) — mirror
|
||
// the card-terminal tip bound below: the booking portion can never
|
||
// exceed what is owed, and the tip portion can never exceed
|
||
// maxTerminalTipPence. The booking row FOR UPDATE lock above serializes
|
||
// concurrent cash/giftcard payments on this booking, so this read races
|
||
// no same-method payment. A fully-paid no-tip booking is rejected below
|
||
// (nothing left to record).
|
||
var remaining int64
|
||
if req.TipEnabled {
|
||
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||
if remErr != nil {
|
||
log.Printf("Failed to compute remaining balance for tip-enabled terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, remErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
remaining = remainingPence
|
||
maxChargePence := remainingPence + maxTerminalTipPence
|
||
if amount > maxChargePence {
|
||
log.Printf("Terminal %s payment for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", *req.PaymentMethod, bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
|
||
amount = maxChargePence
|
||
}
|
||
} else {
|
||
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
|
||
if cErr != nil {
|
||
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if clamped && effectiveAmount <= 0 {
|
||
// B3: the clamp zeroed the amount because the booking is fully paid
|
||
// (remaining <= 0). Reject rather than record a phantom £0 payment —
|
||
// an overpayment is handled manually at the counter, not minted
|
||
// into the ledger.
|
||
log.Printf("Terminal %s payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", *req.PaymentMethod, bookingID, remaining, amount)
|
||
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if clamped {
|
||
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
|
||
amount = effectiveAmount
|
||
}
|
||
}
|
||
|
||
// M4 (mirror of the card-terminal carve at sweep.go): when the customer
|
||
// explicitly requested a tip, any part of the charged amount beyond the
|
||
// remaining booking value is gratuity and must be recorded as its own
|
||
// payment_type='tip' row — never absorbed into the booking payment
|
||
// (which would over-credit the booking) nor rejected. The booking
|
||
// portion keeps the requested payment type, exactly as the non-tip
|
||
// cash/giftcard flow records it.
|
||
tipPortion := int64(0)
|
||
bookingPortion := amount
|
||
if req.TipEnabled && remaining < amount {
|
||
tipPortion = amount - remaining
|
||
bookingPortion = remaining
|
||
}
|
||
|
||
amountPounds := float64(amount) / 100.0
|
||
bookingPortionPounds := float64(bookingPortion) / 100.0
|
||
tipPounds := float64(tipPortion) / 100.0
|
||
var paymentID string
|
||
// auditTargetUserID is the booking's customer for the MEDIUM-3a audit
|
||
// row (empty = guest booking, audited with a NULL target). Captured per
|
||
// branch and used AFTER the money commits below.
|
||
var auditTargetUserID string
|
||
|
||
if *req.PaymentMethod == "cash" {
|
||
if bookingPortionPounds > roundingEpsilon {
|
||
err = tx.QueryRow(r.Context(), `
|
||
INSERT INTO payments (
|
||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at
|
||
) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW())
|
||
RETURNING id
|
||
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID).Scan(&paymentID)
|
||
if err != nil {
|
||
log.Printf("Failed to create cash payment record: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||
}
|
||
// The tip carve is recorded as its own 'tip' row so the booking
|
||
// portion is the only money that counts toward the obligation.
|
||
// When the charge is tip-only (fully-paid booking), the tip row is
|
||
// the ONLY record and its id is returned as the checkout id,
|
||
// mirroring the card-terminal carve (primary := records[0]).
|
||
if tipPounds > roundingEpsilon {
|
||
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
|
||
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
|
||
BookingID: bookingID,
|
||
PaymentType: "tip",
|
||
PaymentMethod: "cash",
|
||
Status: "completed",
|
||
Amount: tipPounds,
|
||
IdempotencyKey: &tipKey,
|
||
CreatedBy: &adminID,
|
||
CreatedAt: clock.Now(),
|
||
UpdatedAt: clock.Now(),
|
||
}, nil)
|
||
if tipErr != nil {
|
||
log.Printf("Failed to create cash tip payment record: %v", tipErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if paymentID == "" {
|
||
paymentID = tipID
|
||
}
|
||
}
|
||
|
||
// MEDIUM-3a: the admin-initiated CASH charge is audited in
|
||
// admin_audit_log AFTER the money commits below (best-effort,
|
||
// non-fatal). Capture the booking's customer now for the audit; a
|
||
// guest booking has no user_id and audits with a NULL target.
|
||
var cashCustomerID sql.NullString
|
||
if cuErr := tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&cashCustomerID); cuErr != nil {
|
||
log.Printf("Failed to query booking user for cash charge audit: %v", cuErr)
|
||
}
|
||
if cashCustomerID.Valid {
|
||
auditTargetUserID = cashCustomerID.String
|
||
}
|
||
} else { // giftcard
|
||
var customerID sql.NullString
|
||
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
|
||
if err != nil {
|
||
log.Printf("Failed to query booking user: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
usedBalance := false
|
||
if customerID.Valid {
|
||
var balance float64
|
||
err = tx.QueryRow(r.Context(), "SELECT balance FROM user_giftcard_balances WHERE user_id = $1 FOR UPDATE", customerID.String).Scan(&balance)
|
||
if err == nil {
|
||
if balance < amountPounds {
|
||
http.Error(w, "Insufficient gift card balance on user account", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Deduct from account balance
|
||
_, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String)
|
||
if err != nil {
|
||
log.Printf("Failed to deduct user balance: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
usedBalance = true
|
||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||
log.Printf("Failed to query user balance: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
var cardVoucherType string // voucher_type_at_purchase from the gift card
|
||
// giftCardPaymentID records the source of funds on the payment row:
|
||
// the gift_card_id for a direct card redemption, or nil when the
|
||
// payment came from the user's account balance (usedBalance). The
|
||
// cancellation refund loop reads this column to know where to
|
||
// credit money back (C3).
|
||
var giftCardPaymentID *string
|
||
if !usedBalance {
|
||
// Try direct card redemption (for guests or users without a redeemed balance)
|
||
if req.GiftCardID == nil || *req.GiftCardID == "" {
|
||
http.Error(w, "Gift card ID is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
cleanCardID := validators.NormalizeGiftCardCode(*req.GiftCardID)
|
||
|
||
var gcRemaining float64
|
||
var redeemedBy sql.NullString
|
||
var vtp sql.NullString
|
||
err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by, voucher_type_at_purchase FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy, &vtp)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to query gift card: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if redeemedBy.Valid {
|
||
http.Error(w, "This gift card has already been redeemed to an account. Please pay using the account balance.", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if gcRemaining < amountPounds {
|
||
http.Error(w, "Insufficient balance on gift card", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Record the voucher_type_at_purchase for later VAT decision.
|
||
// Legacy cards (created before this column existed) have NULL → default to SPV.
|
||
if vtp.Valid {
|
||
cardVoucherType = vtp.String
|
||
} else {
|
||
cardVoucherType = "SPV"
|
||
}
|
||
|
||
// Deduct directly from card remaining amount. A payment is a
|
||
// "use" per the rolling-expiry terms — reset the timer.
|
||
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
|
||
if expiryErr != nil {
|
||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
||
gcExpiryMonths = defaultGiftCardExpiryMonths
|
||
}
|
||
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2", amountPounds, cleanCardID, gcExpiryMonths)
|
||
if err != nil {
|
||
log.Printf("Failed to deduct gift card amount: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
giftCardPaymentID = &cleanCardID
|
||
}
|
||
|
||
// The gift-card source funds the full charged amount (booking
|
||
// portion + tip). The primary row records the booking portion; the
|
||
// tip carve is recorded as its own 'tip' row sourced from the same
|
||
// gift card (C3 source-of-funds tracking), so the booking portion
|
||
// is the only money that counts toward the obligation.
|
||
bookingPayID := ""
|
||
if bookingPortionPounds > roundingEpsilon {
|
||
err = tx.QueryRow(r.Context(), `
|
||
INSERT INTO payments (
|
||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id
|
||
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW(), $6)
|
||
RETURNING id
|
||
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&bookingPayID)
|
||
if err != nil {
|
||
log.Printf("Failed to create giftcard payment record: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
paymentID = bookingPayID
|
||
}
|
||
if tipPounds > roundingEpsilon {
|
||
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
|
||
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
|
||
BookingID: bookingID,
|
||
PaymentType: "tip",
|
||
PaymentMethod: "giftcard",
|
||
Status: "completed",
|
||
Amount: tipPounds,
|
||
IdempotencyKey: &tipKey,
|
||
CreatedBy: &adminID,
|
||
CreatedAt: clock.Now(),
|
||
UpdatedAt: clock.Now(),
|
||
}, giftCardPaymentID)
|
||
if tipErr != nil {
|
||
log.Printf("Failed to create giftcard tip payment record: %v", tipErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if paymentID == "" {
|
||
paymentID = tipID
|
||
}
|
||
}
|
||
|
||
// Apply VAT at redemption only if the gift card was purchased as MPV
|
||
// (VAT deferred to redemption). For SPV, VAT was already paid at sale.
|
||
// For account balance payments (usedBalance=true), VAT was already paid
|
||
// when the original card was purchased. Applied to the booking-portion
|
||
// payment only — a tip record is never VAT-applicable.
|
||
if bookingPayID != "" {
|
||
if usedBalance {
|
||
// VAT already paid at purchase time — nothing to do here.
|
||
} else if cardVoucherType == "MPV" {
|
||
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||
if vatErr == nil && vatCfg.IsVATRegistered {
|
||
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", bookingPayID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||
log.Printf("Failed to apply VAT to giftcard payment %s: %v", bookingPayID, vatExecErr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The admin-initiated gift-card payment is audited AFTER the money
|
||
// commits below (best-effort). Capture the customer for the audit
|
||
// row; a guest booking has no user_id and audits with a NULL target.
|
||
if customerID.Valid {
|
||
auditTargetUserID = customerID.String
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
log.Printf("Failed to commit payment: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// MEDIUM-3a (best-effort, non-fatal — an audit-write failure can never
|
||
// roll back a completed charge). The audit runs AFTER the money commits:
|
||
// the OLD position wrote the row BEFORE tx.Commit, so a failed commit
|
||
// left a false audit row for a charge that never landed. Guest bookings
|
||
// audit with a NULL target_user_id (matching the till flow).
|
||
if *req.PaymentMethod == "cash" {
|
||
InsertAdminAuditCharge(r.Context(), adminID, auditTargetUserID, "admin_cash_charge", map[string]any{
|
||
"booking_id": bookingID,
|
||
"payment_id": paymentID,
|
||
"amount": amountPounds,
|
||
"payment_type": req.PaymentType,
|
||
})
|
||
} else {
|
||
InsertAdminAuditCharge(r.Context(), adminID, auditTargetUserID, "admin_giftcard_payment", map[string]any{
|
||
"booking_id": bookingID,
|
||
"payment_id": paymentID,
|
||
"amount": amountPounds,
|
||
"payment_type": req.PaymentType,
|
||
})
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(CheckoutResponse{
|
||
CheckoutID: paymentID,
|
||
Status: "COMPLETED",
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Admin "Charge Saved Card": charge the customer's saved card directly via
|
||
// Square (no terminal). Pending-first with full idempotency: a deterministic
|
||
// key derived from booking+type+amount+card means a network retry reuses the
|
||
// same key — Square dedups the charge and the pending record is resumed, so
|
||
// a lost-response retry can NEVER double-charge. Mirrors CreateTipPayment.
|
||
if req.PaymentMethod != nil && *req.PaymentMethod == "saved_card" {
|
||
if req.UserSavedCardID == nil || *req.UserSavedCardID == "" {
|
||
http.Error(w, "saved_card_id is required for saved_card payment", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
status, err := service.GetBookingStatus(r.Context(), bookingID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Failed to get booking status: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if status != "in_progress" && status != "completed" {
|
||
http.Error(w, "Booking must be in_progress or completed to create payment", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// The saved card is owned by the booking's user, not the admin.
|
||
var bookingUserID sql.NullString
|
||
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil {
|
||
log.Printf("Failed to get booking user: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// SCA verification token (if any) — extracted once, used both by the
|
||
// 2FA gate below (a present token skips the gate: SCA-primary) and
|
||
// forwarded to Square on the CreatePaymentReq.
|
||
terminalVerificationToken := ""
|
||
if req.VerificationToken != nil {
|
||
terminalVerificationToken = *req.VerificationToken
|
||
logVerificationTokenProvenance("admin terminal saved-card", bookingID, req.UserSavedCardID, terminalVerificationToken)
|
||
}
|
||
|
||
// Resolve the saved-card Square source for the booking's user (the
|
||
// card's owner, not the admin) — shared new-card-vs-saved-card
|
||
// resolution, see resolveChargeSource for the R6 rationale. 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-<idempotency key>") because the column is a NOT NULL
|
||
// PRIMARY KEY; a row carrying one is provably pre-Square (no checkout was
|
||
// ever created for it).
|
||
provisionalID := "tmp-" + idempotencyKey
|
||
|
||
// B3: clamp the terminal checkout amount to the booking's remaining
|
||
// obligation UNLESS the customer explicitly requested a tip (tip_enabled).
|
||
// An accidental overpayment must never be presented to the card reader as a
|
||
// charge that the record path would later carve into an unintended tip. The
|
||
// advisory lock above serializes this read against concurrent same-booking
|
||
// payments. A fully-paid booking is rejected below (nothing left to charge).
|
||
checkoutAmount := amount
|
||
if req.TipEnabled {
|
||
// The tip is embedded in the amount (totalWithTip) and its value is
|
||
// unknown server-side, so cap the TOTAL at the remaining obligation
|
||
// plus a generous max tip bound: the booking portion can never exceed
|
||
// what is owed, and the tip portion can never exceed £50.
|
||
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||
if remErr != nil {
|
||
log.Printf("Failed to compute remaining balance for tip-enabled terminal checkout on booking %s: %v", bookingID, remErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
maxChargePence := remainingPence + maxTerminalTipPence
|
||
if checkoutAmount > maxChargePence {
|
||
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
|
||
checkoutAmount = maxChargePence
|
||
}
|
||
} else {
|
||
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
|
||
if cErr != nil {
|
||
log.Printf("Failed to compute remaining balance for terminal checkout on booking %s: %v", bookingID, cErr)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if clamped && effectiveAmount <= 0 {
|
||
// B3: the clamp zeroed the amount because the booking is fully paid
|
||
// (remaining <= 0). Reject rather than present a £0 (or overpaid)
|
||
// checkout to the card reader.
|
||
log.Printf("Terminal checkout for booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", bookingID, remaining, amount)
|
||
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if clamped {
|
||
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the card reader will present the remaining obligation only", bookingID, amount, effectiveAmount)
|
||
checkoutAmount = effectiveAmount
|
||
}
|
||
}
|
||
// tip_enabled is persisted on the checkout row so recordTerminalPaymentTx
|
||
// knows whether an overflow beyond the remaining value was an EXPLICIT tip
|
||
// (split into a tip record) or an accidental overpayment (kept on the
|
||
// booking record, refundable).
|
||
if _, err := db.Conn.Exec(r.Context(), `
|
||
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, tip_enabled)
|
||
VALUES ($1, $2, $3, 'PENDING', $4, $5)
|
||
`, provisionalID, bookingID, req.PaymentType, float64(checkoutAmount)/100.0, req.TipEnabled); err != nil {
|
||
log.Printf("Failed to record provisional terminal checkout for booking %s: %v", bookingID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Attach the booking customer's Square customer id (if any) so the terminal
|
||
// checkout is associated with their Square profile. Read-only lookup
|
||
// mirroring ensureSquareCustomer's read, but NEVER provisioning — a
|
||
// terminal checkout also serves walk-ins, and minting a customer profile
|
||
// for a terminal tap would create an unowned customer. No id → empty.
|
||
var checkoutCustomerID string
|
||
var bookingUserID sql.NullString
|
||
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err == nil && bookingUserID.Valid {
|
||
_ = db.Conn.QueryRow(r.Context(), `
|
||
SELECT square_customer_id FROM user_saved_cards
|
||
WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> ''
|
||
ORDER BY created_at DESC LIMIT 1
|
||
`, bookingUserID.String).Scan(&checkoutCustomerID)
|
||
}
|
||
|
||
checkoutReq := square.CreateCheckoutReq{
|
||
Amount: checkoutAmount,
|
||
Currency: "GBP",
|
||
IdempotencyKey: idempotencyKey,
|
||
ReferenceID: bookingID,
|
||
// The tip (if any) is already embedded in `amount` by the frontend
|
||
// (totalWithTip), so the terminal must NOT prompt for a second tip —
|
||
// setting AllowTipping here would double-count the tip in production.
|
||
AllowTipping: false,
|
||
CustomerID: checkoutCustomerID,
|
||
}
|
||
|
||
checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq)
|
||
if err != nil {
|
||
log.Printf("Failed to create checkout: %v", err)
|
||
// The provisional row is pre-Square and can never produce a charge —
|
||
// mark it failed so a retry can proceed (best-effort; log CRITICAL if
|
||
// the row update itself fails, since the row would then wedge the
|
||
// booking's in-flight guard).
|
||
if _, upErr := db.Conn.Exec(r.Context(), `
|
||
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW()
|
||
WHERE checkout_id = $1 AND status = 'PENDING'
|
||
`, provisionalID); upErr != nil {
|
||
log.Printf("CRITICAL: failed to mark provisional terminal checkout %s failed after CreateCheckout error (%v): %v — MANUAL RECONCILIATION REQUIRED", provisionalID, err, upErr)
|
||
}
|
||
http.Error(w, "Failed to create payment", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Attach the real Square checkout id to the tracked row (the provisional
|
||
// id was never seen by the client, so no poller can race this).
|
||
tag, upErr := db.Conn.Exec(r.Context(), `
|
||
UPDATE terminal_checkouts SET checkout_id = $1, updated_at = NOW()
|
||
WHERE checkout_id = $2
|
||
`, checkout.ID, provisionalID)
|
||
if upErr != nil {
|
||
log.Printf("CRITICAL: terminal checkout %s was created at Square but the tracking UPDATE (from provisional %s) failed: %v — manual reconciliation required", checkout.ID, provisionalID, upErr)
|
||
// The checkout is live at Square but untracked — best-effort cancel so
|
||
// a customer cannot complete a charge the backend can't record.
|
||
if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil {
|
||
log.Printf("CRITICAL: failed to cancel orphaned terminal checkout %s after the tracking UPDATE failed: %v — MANUAL RECONCILIATION REQUIRED: the checkout may still be live at Square", checkout.ID, cErr)
|
||
}
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
// The provisional row vanished while the Square call was in flight
|
||
// (the sweep resolved it as stale) — the checkout is now live at
|
||
// Square but untracked.
|
||
log.Printf("CRITICAL: terminal checkout %s was created at Square but provisional row %s was already resolved — the checkout is untracked; MANUAL RECONCILIATION REQUIRED", checkout.ID, provisionalID)
|
||
if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil {
|
||
log.Printf("CRITICAL: failed to cancel untracked terminal checkout %s: %v — MANUAL RECONCILIATION REQUIRED", checkout.ID, cErr)
|
||
}
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(CheckoutResponse{
|
||
CheckoutID: checkout.ID,
|
||
Status: checkout.Status,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
// activeTerminalCheckoutID returns the checkout_id of an in-flight terminal
|
||
// checkout for the booking, or "" if none. Called under the
|
||
// crussell:payment:<booking> advisory lock. A PENDING/IN_PROGRESS row is
|
||
// resolved against Square: an already-completed checkout must not block a new
|
||
// charge, while one still live at Square is returned so a lost-response retry
|
||
// reuses it instead of creating a second live checkout.
|
||
//
|
||
// A checkout in a definitively terminal state (CANCELED / CANCEL_REQUESTED, or
|
||
// NOT_FOUND for an expired checkout) is ALSO resolved: it can never complete,
|
||
// so it must not wedge the booking. Such a checkout surfaces as a GetCheckout
|
||
// error (the HTTP client returns an error for any non-COMPLETED, non-PENDING
|
||
// status) and would otherwise be treated as "still in flight" forever, blocking
|
||
// every future terminal charge on the booking. Only ErrCheckoutPending and
|
||
// ambiguous transport errors keep the checkout in flight — a second live
|
||
// checkout must never be created while the first one's money state is unknown.
|
||
func activeTerminalCheckoutID(ctx context.Context, bookingID string) string {
|
||
var checkoutID string
|
||
if err := db.Conn.QueryRow(ctx, `
|
||
SELECT checkout_id FROM terminal_checkouts
|
||
WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||
ORDER BY created_at ASC LIMIT 1
|
||
`, bookingID).Scan(&checkoutID); err != nil {
|
||
if !errors.Is(err, pgx.ErrNoRows) {
|
||
log.Printf("Failed to query active terminal checkout for booking %s: %v", bookingID, err)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// A provisional (pre-Square) row carries a synthetic "tmp-" checkout_id (or
|
||
// an empty one for legacy rows). It is no longer PROVABLY not live (H4): a
|
||
// hard crash between the terminal_checkouts insert and the provisional→real
|
||
// UPDATE leaves a LIVE checkout at Square (created under the idempotency
|
||
// key embedded in the tmp id) while the row still carries the synthetic id.
|
||
// Resolving it to failed unconditionally would let a lost-response retry
|
||
// create a SECOND live checkout (C2) while C1 can still complete at the
|
||
// terminal into an untracked charge. Query Square first to disambiguate.
|
||
if checkoutID == "" || strings.HasPrefix(checkoutID, "tmp-") {
|
||
if checkoutID != "" {
|
||
result, err := SquareClient.GetCheckout(ctx, checkoutID)
|
||
switch {
|
||
case err == nil && result.Status == "COMPLETED":
|
||
// C1 actually completed at the terminal. The payment MUST be
|
||
// recorded now — the old code only marked the row COMPLETED and
|
||
// relied on GetCheckoutStatus (the poll path) to record it, but a
|
||
// checkout that is never polled (abandoned booking / lost poll)
|
||
// would leave the charge permanently untracked:
|
||
// SweepStaleTerminalCheckouts only re-examines PENDING/IN_PROGRESS
|
||
// rows, so a row already marked COMPLETED here is never revisited
|
||
// and the money stays unrecorded and unrefundable via the app.
|
||
// Mirror the sweep's recordUntrackedTerminalPayment: the same
|
||
// crussell:terminal:<squarePayID> advisory lock (serializes
|
||
// against a concurrent poll), the same dedup by booking_id +
|
||
// square_payment_id, the same PaymentRecord shape, the same
|
||
// deposit/balance/tip split, and the same fully-paid completion.
|
||
recorded := recordUntrackedTerminalPayment(ctx, checkoutID, bookingID, result)
|
||
if recorded {
|
||
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — payment recorded", checkoutID, bookingID)
|
||
return ""
|
||
}
|
||
// Recording failed (transient DB/lock contention) — keep the
|
||
// in-flight guard UP so a second live checkout is never created
|
||
// while the charge is unrecorded. The row stays PENDING, so the
|
||
// stale-terminal sweep re-runs recordUntrackedTerminalPayment on
|
||
// it; once recorded, this guard releases on the next attempt.
|
||
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square but payment recording failed — keeping it in flight; no second checkout until the charge is recorded", checkoutID, bookingID)
|
||
return checkoutID
|
||
case errors.Is(err, square.ErrCheckoutPending):
|
||
// C1 is still live at Square — reuse it instead of creating C2.
|
||
log.Printf("Provisional terminal checkout %s for booking %s is live at Square — reusing it", checkoutID, bookingID)
|
||
return checkoutID
|
||
case isTerminalCheckoutError(err):
|
||
// NOT_FOUND (no checkout was ever created — the crash happened
|
||
// before the Square call) or CANCELED — safe to resolve failed
|
||
// and create a fresh checkout.
|
||
log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, bookingID)
|
||
if _, upErr := db.Conn.Exec(ctx, `
|
||
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
|
||
`, checkoutID); upErr != nil {
|
||
log.Printf("Failed to mark provisional terminal checkout %s failed: %v", checkoutID, upErr)
|
||
}
|
||
return ""
|
||
default:
|
||
// Ambiguous error — the checkout's money state at Square is
|
||
// unknown. Keep it in flight rather than spawning a second live
|
||
// checkout.
|
||
return checkoutID
|
||
}
|
||
}
|
||
log.Printf("Legacy empty checkout_id row for booking %s resolved as failed — no live checkout at Square", bookingID)
|
||
return ""
|
||
}
|
||
|
||
// Resolve against Square: once the terminal charge finished, the checkout
|
||
// is COMPLETED and its payment may already be recorded — it must not
|
||
// block a subsequent charge on the same booking.
|
||
result, err := SquareClient.GetCheckout(ctx, checkoutID)
|
||
if err == nil && result.Status == "COMPLETED" {
|
||
if _, upErr := db.Conn.Exec(ctx, `
|
||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1
|
||
`, checkoutID); upErr != nil {
|
||
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr)
|
||
}
|
||
return ""
|
||
}
|
||
// A definitively terminal checkout (cancelled / cancel-requested /
|
||
// expired-NOT_FOUND) can never complete — mark the row failed and allow a
|
||
// new checkout instead of wedging the booking forever.
|
||
if isTerminalCheckoutError(err) {
|
||
log.Printf("Terminal checkout %s is definitively terminal at Square (%v) — allowing a new checkout for booking %s", checkoutID, err, bookingID)
|
||
if _, upErr := db.Conn.Exec(ctx, `
|
||
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
|
||
`, checkoutID); upErr != nil {
|
||
log.Printf("Failed to mark terminal checkout %s failed after terminal state: %v", checkoutID, upErr)
|
||
}
|
||
return ""
|
||
}
|
||
// ErrCheckoutPending or any ambiguous error: treat the checkout as still
|
||
// in flight. Never create a second live checkout while the first one's
|
||
// money state at Square is unknown.
|
||
return checkoutID
|
||
}
|
||
|
||
func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||
// Defense-in-depth admin check (S-1) — terminal completion records a
|
||
// payment, so it must stay admin-only.
|
||
if !isAdminRequest(r) {
|
||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||
return
|
||
}
|
||
checkoutID := chi.URLParam(r, "checkout_id")
|
||
if checkoutID == "" {
|
||
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !validators.IsValidSquareCheckoutID(checkoutID) {
|
||
http.Error(w, "not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
// Reject provisional "tmp-" checkout ids: CreateTerminalPayment stores a
|
||
// synthetic "tmp-<idempotency key>" id in terminal_checkouts until Square
|
||
// returns the real checkout id (provisional-row design), so such an id was
|
||
// never a real checkout — resolving it against Square would come back
|
||
// NOT_FOUND and surface as a 500. Answer 404 instead (mirrors the guard in
|
||
// CreateTerminalPayment and the sweep).
|
||
if strings.HasPrefix(checkoutID, "tmp-") {
|
||
http.Error(w, "Checkout not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
bookingID := r.URL.Query().Get("booking_id")
|
||
if bookingID == "" {
|
||
http.Error(w, "booking_id query parameter is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !validators.IsValidID(bookingID) {
|
||
http.Error(w, "Invalid booking ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
|
||
if err != nil {
|
||
if errors.Is(err, square.ErrCheckoutPending) {
|
||
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
}
|
||
log.Printf("Failed to get checkout status: %v", err)
|
||
http.Error(w, "Failed to get checkout status", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Ownership check: the terminal checkout must reference THIS booking.
|
||
// CreateTerminalPayment sets reference_id = bookingID; without this check,
|
||
// polling the wrong checkout ID would attach its payment to a different
|
||
// booking (admin-only route, but a mis-scoped charge is a data-integrity
|
||
// bug worth rejecting). An EMPTY reference_id is also rejected: a checkout
|
||
// created outside this app with no reference must not be attachable to a
|
||
// booking (S-1) — fail closed on anything that is not exactly this booking.
|
||
if paymentResult.ReferenceID == "" || paymentResult.ReferenceID != bookingID {
|
||
log.Printf("Checkout %s does not reference booking %s (reference_id=%q) — refusing to record", checkoutID, bookingID, paymentResult.ReferenceID)
|
||
http.Error(w, "Checkout does not belong to this booking", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if paymentResult.Status == "COMPLETED" {
|
||
// Serialize terminal-completion records per Square payment ID. Two
|
||
// concurrent polls of the same checkout could otherwise BOTH pass the
|
||
// dedup SELECT and BOTH INSERT, with the second dying on the
|
||
// idempotency_key UNIQUE constraint after the customer already paid —
|
||
// the same double-record race every other payment path guards against.
|
||
// Bounded try-lock (R6) so a contended lock never blocks the pool.
|
||
terminalLockKey := paymentResult.SquarePayID
|
||
pinConn, err := db.Conn.Acquire(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to acquire connection for terminal-completion lock: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer pinConn.Release()
|
||
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:terminal:"+terminalLockKey)
|
||
if err != nil {
|
||
log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", terminalLockKey, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !lockOK {
|
||
log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", terminalLockKey)
|
||
http.Error(w, "Payment in progress, try again", http.StatusConflict)
|
||
return
|
||
}
|
||
defer releasePaymentLock(pinConn, "crussell:terminal:"+terminalLockKey)
|
||
|
||
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
|
||
// payment insert. The shared money-recording core
|
||
// (recordTerminalPaymentTx, sweep.go) runs inside this transaction,
|
||
// commits it, and completes a now-fully-paid booking; this handler
|
||
// maps the result to the HTTP response.
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
paymentID, recErr := recordTerminalPaymentTx(r.Context(), tx, checkoutID, bookingID, paymentResult)
|
||
if recErr != nil {
|
||
if errors.Is(recErr, errTerminalBookingNotPayable) {
|
||
// The core already committed the checkout's 'failed' mark:
|
||
// money was taken at Square on a booking that is no longer
|
||
// payable and MUST be refunded manually.
|
||
http.Error(w, "This booking is no longer accepting payments", http.StatusConflict)
|
||
return
|
||
}
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||
Status: "COMPLETED",
|
||
PaymentID: paymentID,
|
||
Amount: paymentResult.Amount,
|
||
CardBrand: paymentResult.CardBrand,
|
||
CardLast4: paymentResult.CardLast4,
|
||
ReceiptURL: paymentResult.ReceiptURL,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// No non-COMPLETED fallthrough here: GetCheckout (via getCheckoutHTTP)
|
||
// only returns a nil error for a COMPLETED checkout — a non-COMPLETED
|
||
// status or an expired/cancelled checkout surfaces as an error, which was
|
||
// already handled above (ErrCheckoutPending → PENDING, everything else →
|
||
// 500). The previous trailing `http.Error(w, "Payment failed", 402)` was
|
||
// unreachable dead code and has been removed.
|
||
}
|
||
|
||
// IsValidBookingStatusForPayment returns true if the booking status allows
|
||
// accepting payments. This guard prevents racing with CleanupExpiredDeposits —
|
||
// once a booking's slot has been released (deposit_lapsed, etc.),
|
||
// we must reject the payment before hitting Square's API.
|
||
func IsValidBookingStatusForPayment(status string) bool {
|
||
switch status {
|
||
case "confirmed", "pending", "pending_release", "in_progress":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// bookingStatusAllowsCompletedPayment reports whether a charge that already
|
||
// went through Square can still be recorded as a completed payment. It differs
|
||
// from IsValidBookingStatusForPayment: a booking that legitimately completed
|
||
// ('completed') must still accept the recorded payment, while a cancelled,
|
||
// lapsed, or no-show booking must NOT — the money would bypass the
|
||
// cancellation refund system, which computes refunds from completed payments.
|
||
func bookingStatusAllowsCompletedPayment(status string) bool {
|
||
switch status {
|
||
case "confirmed", "pending", "pending_release", "in_progress", "completed":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||
bookingID := chi.URLParam(r, "id")
|
||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||
if !ok || userID == "" {
|
||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req CreateBookingPaymentRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Failed to decode booking payment request: %v", err)
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Product rule (security): only verified accounts may save cards. An
|
||
// unverified/guest/affiliate user may still pay, but save_card=true is
|
||
// rejected here — before any charge source resolution or payment record.
|
||
if rejectSaveCardForUnverified(w, r, req.SaveCard) {
|
||
return
|
||
}
|
||
|
||
service := NewPaymentService()
|
||
|
||
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
|
||
var bookingBuyerEmail string
|
||
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
|
||
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||
}
|
||
|
||
if err := ValidateAmount(req.Amount); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := ValidatePaymentType(req.PaymentType); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// A3: tips have a dedicated endpoint (POST /api/bookings/{id}/tip,
|
||
// CreateTipPayment) which enforces the M4 "tips only after the service
|
||
// starts" gate. A 'tip' payment_type on the booking payment endpoint would
|
||
// bypass that gate — the overflow/tip guard below explicitly skips tip-type
|
||
// requests and buildSplitRecords would carve the charge as a deposit or
|
||
// balance (or silently overflow into a tip record) — so it is rejected
|
||
// outright here, before any charge source resolution or payment record.
|
||
if req.PaymentType == "tip" {
|
||
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
|
||
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := ValidateCardInfo(req.CardID, req.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_<nanosecond>" 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-<prefix>_<amount>_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:<payment_id>
|
||
// locks — so the residual computation below cannot race an in-flight refund.
|
||
tx, err := db.Conn.Begin(r.Context())
|
||
if err != nil {
|
||
log.Printf("Failed to begin transaction for admin booking refund: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer func() {
|
||
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||
slog.Error("failed to rollback admin booking refund transaction", "err", err)
|
||
}
|
||
}()
|
||
|
||
if err := lockCancellationPayments(r.Context(), tx, payments); err != nil {
|
||
log.Printf("Failed to acquire refund locks for booking %s: %v", bookingID, err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Prior refunds per payment (completed + pending) so a payment is never
|
||
// refunded past its residual.
|
||
priorRefunds := make(map[string]float64)
|
||
prRows, prErr := tx.Query(r.Context(), `
|
||
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
|
||
WHERE booking_id = $1 AND status IN ('completed', 'pending')
|
||
GROUP BY payment_id`, bookingID)
|
||
if prErr != nil {
|
||
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
|
||
} else {
|
||
for prRows.Next() {
|
||
var pid string
|
||
var amt float64
|
||
if err := prRows.Scan(&pid, &amt); err == nil {
|
||
priorRefunds[pid] = amt
|
||
}
|
||
}
|
||
prRows.Close()
|
||
}
|
||
|
||
// cardRefunds tracks card refunds that need a post-commit Square call.
|
||
type cardRefund struct {
|
||
refundID string
|
||
amountPence int64
|
||
squareID string
|
||
reason string
|
||
key string
|
||
}
|
||
var cardRefunds []cardRefund
|
||
var refunds []RefundResponse
|
||
remaining := float64(req.Amount) / 100.0
|
||
|
||
for _, p := range payments {
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
already := priorRefunds[p.ID]
|
||
residual := math.Round((p.Amount-already)*100) / 100
|
||
if residual <= 0 {
|
||
continue
|
||
}
|
||
portion := math.Round(math.Min(residual, remaining)*100) / 100
|
||
remaining -= portion
|
||
|
||
var refundStatus string
|
||
var refundKey *string
|
||
|
||
switch p.PaymentMethod {
|
||
case "online_square", "in_person_card":
|
||
if p.SquarePaymentID == nil || *p.SquarePaymentID == "" {
|
||
log.Printf("Admin booking refund: card payment %s has no Square reference — marking failed; refund must be arranged manually", p.ID)
|
||
refundStatus = "failed"
|
||
break
|
||
}
|
||
// Unique per-attempt key (same shape as RefundPayment's no-client-key
|
||
// fallback): distinct refunds never collide on the UNIQUE constraint.
|
||
key := p.ID + "-refund-" + strconv.FormatInt(int64(math.Round(portion*100)), 10) + "-" + randomHexSuffix(6)
|
||
refundKey = &key
|
||
refundStatus = "pending"
|
||
case "giftcard":
|
||
if p.GiftCardID != nil && *p.GiftCardID != "" {
|
||
var expired bool
|
||
if err := tx.QueryRow(r.Context(), `
|
||
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
|
||
FROM gift_cards WHERE id = $1
|
||
`, *p.GiftCardID).Scan(&expired); err != nil {
|
||
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *p.GiftCardID, err)
|
||
} else if expired {
|
||
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *p.GiftCardID, bookingID)
|
||
continue
|
||
}
|
||
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(r.Context(), tx)
|
||
if expiryErr != nil {
|
||
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
|
||
gcExpiryMonths = defaultGiftCardExpiryMonths
|
||
}
|
||
if _, gcErr := tx.Exec(r.Context(), `
|
||
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
|
||
WHERE id = $2
|
||
`, portion, *p.GiftCardID, gcExpiryMonths); gcErr != nil {
|
||
log.Printf("Failed to refund £%.2f to gift card %s: %v", portion, *p.GiftCardID, gcErr)
|
||
continue
|
||
}
|
||
if _, gcErr := tx.Exec(r.Context(), `
|
||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
|
||
`, *p.GiftCardID, portion, bookingID, bookingUserID, "Refund from admin booking refund"); gcErr != nil {
|
||
log.Printf("Failed to create gift card transaction for refund: %v", gcErr)
|
||
}
|
||
} else {
|
||
if bookingUserID == "" {
|
||
log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", p.ID)
|
||
continue
|
||
}
|
||
if _, balErr := tx.Exec(r.Context(), `
|
||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||
VALUES ($1, $2, NOW())
|
||
ON CONFLICT (user_id) DO UPDATE SET
|
||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||
updated_at = NOW()
|
||
`, bookingUserID, portion); balErr != nil {
|
||
log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
|
||
continue
|
||
}
|
||
}
|
||
refundStatus = "completed"
|
||
case "cash":
|
||
if bookingUserID == "" || isGuest {
|
||
log.Printf("Cash refund: booking %s payment %s amount £%.2f — admin must process cash refund at till", bookingID, p.ID, portion)
|
||
} else {
|
||
if _, balErr := tx.Exec(r.Context(), `
|
||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||
VALUES ($1, $2, NOW())
|
||
ON CONFLICT (user_id) DO UPDATE SET
|
||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||
updated_at = NOW()
|
||
`, bookingUserID, portion); balErr != nil {
|
||
log.Printf("Failed to credit user %s balance for cash refund of booking %s: %v", bookingUserID, bookingID, balErr)
|
||
continue
|
||
}
|
||
}
|
||
refundStatus = "completed"
|
||
default:
|
||
log.Printf("Skipping admin booking refund for payment %s with method %q (no money exchanged)", p.ID, p.PaymentMethod)
|
||
continue
|
||
}
|
||
|
||
var refundID string
|
||
if refundKey != nil {
|
||
err = tx.QueryRow(r.Context(), `
|
||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual')
|
||
RETURNING id
|
||
`, p.ID, bookingID, portion, refundStatus, req.Reason, refundKey, adminID, clock.Now()).Scan(&refundID)
|
||
} else {
|
||
err = tx.QueryRow(r.Context(), `
|
||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_by, created_at, origin)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'manual')
|
||
RETURNING id
|
||
`, p.ID, bookingID, portion, refundStatus, req.Reason, adminID, clock.Now()).Scan(&refundID)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Failed to create refund record for payment %s: %v", p.ID, err)
|
||
continue
|
||
}
|
||
|
||
refunds = append(refunds, RefundResponse{
|
||
ID: refundID,
|
||
PaymentID: p.ID,
|
||
Amount: int64(math.Round(portion * 100)),
|
||
Status: refundStatus,
|
||
Reason: req.Reason,
|
||
CreatedAt: clock.Now().Format(time.RFC3339),
|
||
})
|
||
|
||
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
|
||
if refundKey != nil && p.SquarePaymentID != nil {
|
||
cardRefunds = append(cardRefunds, cardRefund{
|
||
refundID: refundID,
|
||
amountPence: int64(math.Round(portion * 100)),
|
||
squareID: *p.SquarePaymentID,
|
||
reason: req.Reason,
|
||
key: *refundKey,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(r.Context()); err != nil {
|
||
log.Printf("Failed to commit admin booking refund transaction: %v", err)
|
||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Post-commit Square pass for card refunds (mirrors RefundPayment's
|
||
// status resolution). A definitive decline marks the row failed; an
|
||
// ambiguous error leaves it pending for the manual-refund sweep.
|
||
for _, cf := range cardRefunds {
|
||
status := "completed"
|
||
result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{
|
||
PaymentID: cf.squareID,
|
||
Amount: cf.amountPence,
|
||
IdempotencyKey: cf.key,
|
||
Reason: cf.reason,
|
||
})
|
||
switch {
|
||
case rErr == nil:
|
||
sqStatus, terminal := SquareRefundStatusToLocal(result.Status)
|
||
if result.Status == "APPROVED" {
|
||
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
|
||
// FAILED/CANCELED must still be able to demote the row.
|
||
terminal = false
|
||
}
|
||
if !terminal {
|
||
if result.Status == "APPROVED" {
|
||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, result.ID, cf.refundID); upErr != nil {
|
||
log.Printf("Failed to record square refund id %s on pending refund %s: %v", result.ID, cf.refundID, upErr)
|
||
}
|
||
}
|
||
log.Printf("Square refund %s is non-terminal (%s) — leaving refund %s pending for the sweep", result.ID, result.Status, cf.refundID)
|
||
} else if sqStatus == "failed" {
|
||
log.Printf("Square refund %s FAILED — marking refund %s failed", result.ID, cf.refundID)
|
||
}
|
||
if terminal {
|
||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, sqStatus, result.ID, cf.refundID); upErr != nil {
|
||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", result.ID, cf.refundID, upErr)
|
||
}
|
||
} else {
|
||
sqStatus = "pending"
|
||
}
|
||
status = sqStatus
|
||
case errors.Is(rErr, square.ErrRefundAlreadyProcessed):
|
||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'completed' WHERE id = $1`, cf.refundID); upErr != nil {
|
||
log.Printf("Failed to resolve refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", cf.refundID, upErr)
|
||
}
|
||
log.Printf("Refund %s already processed at Square — marked completed", cf.refundID)
|
||
case errors.Is(rErr, square.ErrRefundDeclined):
|
||
if _, upErr := db.Conn.Exec(r.Context(), `UPDATE refunds SET status = 'failed' WHERE id = $1`, cf.refundID); upErr != nil {
|
||
log.Printf("Failed to mark refund %s failed after definitive rejection: %v", cf.refundID, upErr)
|
||
}
|
||
log.Printf("Refund %s definitively declined by Square: %v", cf.refundID, rErr)
|
||
default:
|
||
log.Printf("Failed to refund payment (refund %s left pending): %v", cf.refundID, rErr)
|
||
}
|
||
for i := range refunds {
|
||
if refunds[i].ID == cf.refundID {
|
||
refunds[i].Status = status
|
||
}
|
||
}
|
||
}
|
||
|
||
var totalRefunded int64
|
||
for _, rf := range refunds {
|
||
totalRefunded += rf.Amount
|
||
}
|
||
|
||
// MEDIUM-3a coverage: record the admin booking refund in admin_audit_log
|
||
// (best-effort, own transaction — a failed audit write can never undo the
|
||
// refund). One row per action, not per payment, so the operator sees the
|
||
// admin decision that moved money.
|
||
InsertAdminAuditCharge(r.Context(), adminID, bookingUserID, "admin_booking_refund", map[string]any{
|
||
"booking_id": bookingID,
|
||
"amount": float64(req.Amount) / 100.0,
|
||
"reason": req.Reason,
|
||
"refund_count": len(refunds),
|
||
"refunded_payments": len(cardRefunds),
|
||
})
|
||
|
||
if err := json.NewEncoder(w).Encode(AdminBookingRefundResponse{
|
||
RefundedAmount: totalRefunded,
|
||
Refunds: refunds,
|
||
}); err != nil {
|
||
log.Printf("Failed to encode JSON response: %v", err)
|
||
}
|
||
}
|
||
|
||
func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||
bookingID := chi.URLParam(r, "id")
|
||
if bookingID == "" || !validators.IsValidID(bookingID) {
|
||
http.Error(w, "Booking not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||
if !ok || userID == "" {
|
||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req CreateTipPaymentRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Failed to decode tip payment request: %v", err)
|
||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := validators.Validate.Struct(&req); err != nil {
|
||
log.Printf("Failed to process request: %v", err)
|
||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
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:<id>
|
||
// 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-<bookingID>-<paymentType>-<amount>-<cardID>", sha256-truncated when the
|
||
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic,
|
||
// so a same-key retry still dedups).
|
||
//
|
||
// The key must distinguish "same live operation retried" (dedup) from "new
|
||
// operation that happens to have equal amount" (new charge). The candidate is
|
||
// the base key (seq 0) and then the base key with a "-<seq>" suffix (seq ≥ 1)
|
||
// until a slot without a COMPLETED payment is found; what makes a completed
|
||
// slot "spent" depends on the type:
|
||
//
|
||
// - 'partial' (repeatable type): a completed row ALWAYS advances the
|
||
// sequence — two genuine equal-amount partial payments are distinct
|
||
// operations and must diverge onto distinct keys (the dedup lookup would
|
||
// otherwise return the first as success and silently swallow the second).
|
||
// - non-repeatable types (deposit/full/balance): a completed row advances
|
||
// only when its money is no longer live (it has a completed/pending
|
||
// refund). A refunded payment must not be returned as "success" for a new
|
||
// equal-amount charge — the booking would show paid with no money
|
||
// collected (refund-then-repay). An UN-refunded completed row is the SAME
|
||
// live operation retried, so its key is reused and the dedup lookup
|
||
// returns it (paying the same 50% deposit twice on an un-refunded booking
|
||
// MUST still dedup — the double-charge protection).
|
||
//
|
||
// A PENDING row never occupies a slot (the scan only matches 'completed'), so
|
||
// a lost-response retry of an in-flight charge re-derives the same key and
|
||
// reuses the pending record. seq 0 is the historical un-sequenced key, so
|
||
// legacy deterministic-key rows are still matched.
|
||
//
|
||
// Must be called inside the transaction holding the per-booking advisory lock
|
||
// so the spent-slot scan races no concurrent charge (mirrors the tip flow's
|
||
// no-client-key fallback, which counts completed tips under the tip lock).
|
||
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
|
||
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
|
||
for seq := 0; ; seq++ {
|
||
// nextIdempotencyCandidate (idempotency_helpers.go) reproduces the
|
||
// historical candidate exactly: the base key at seq 0, "base-seq" at
|
||
// seq ≥ 1, sha256-truncated to the 45-char limit under the "pay-"
|
||
// prefix when the verbatim form overflows — the key stays deterministic
|
||
// so a same-key retry still dedups (A3).
|
||
candidate := nextIdempotencyCandidate(baseKey, seq)
|
||
var completedID string
|
||
err := q.QueryRow(ctx, `
|
||
SELECT id FROM payments
|
||
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
|
||
`, bookingID, candidate).Scan(&completedID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return candidate, nil
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
refunded, rErr := paymentHasLiveRefund(ctx, q, completedID)
|
||
if rErr != nil {
|
||
return "", rErr
|
||
}
|
||
if paymentType == "partial" || refunded {
|
||
continue
|
||
}
|
||
return candidate, nil
|
||
}
|
||
}
|
||
|
||
// paymentHasLiveRefund reports whether the payment has a refund in a state
|
||
// meaning its money is no longer fully live: a completed refund (money
|
||
// returned) or a pending refund (money in flight). Failed refunds never moved
|
||
// money and are excluded. Used to re-validate a dedup hit — a refunded payment
|
||
// must never be returned as "success" for a new equal-amount charge.
|
||
func paymentHasLiveRefund(ctx context.Context, q db.Querier, paymentID string) (bool, error) {
|
||
var exists bool
|
||
err := q.QueryRow(ctx, `
|
||
SELECT EXISTS(
|
||
SELECT 1 FROM refunds WHERE payment_id = $1 AND status IN ('completed', 'pending')
|
||
)
|
||
`, paymentID).Scan(&exists)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return exists, nil
|
||
}
|
||
|
||
// randomHexSuffix returns n random bytes hex-encoded (2n hex chars) from
|
||
// crypto/rand, used to disambiguate idempotency fallback keys that would
|
||
// otherwise collide on deterministic inputs (e.g. the no-client-key refund
|
||
// key). Falls back to a masked monotonic timestamp if the OS entropy source
|
||
// errors — effectively impossible on Linux (crypto/rand.Read blocks until
|
||
// entropy is available) — keeping the same width so the key stays within
|
||
// Square's 45-char idempotency-key limit.
|
||
func randomHexSuffix(n int) string {
|
||
b := make([]byte, n)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return fmt.Sprintf("%0*x", 2*n, time.Now().UnixNano()&(int64(1)<<(8*int64(n))-1))
|
||
}
|
||
return fmt.Sprintf("%x", b)
|
||
}
|