Files
Crussell/backend/handlers/payments/handlers.go
T
popertots 9ff591fa4e Fix payment review round 2: refund idempotency, pending-resume safety, terminal-completion lock
Refund idempotency (P2):
- RefundRequest gains an optional client idempotency_key: two DISTINCT equal
  partial refunds of one payment no longer collide on the amount-derived key
  (the second was silently swallowed as a dedup)
- Extract resumeManualPendingRefund: resumes a pending refund with the row's
  OWN stored key, so Square's key dedup returns the original refund if the
  prior attempt completed — never issues a second
- (payment, amount) pending fallback: when the exact-key lookup misses (admin
  reopened the modal, new UUID), resume the matching pending row instead of
  creating a second pending row the sweep would double-process
- 409 in-flight guard: if a pending refund exists for the payment but no
  same-amount row matches, reject a different-amount refund (money state at
  Square is unknown — no new refund is safe until it resolves)
- Frontend (EditBookingModal): UUID per refund attempt, reused on retry,
  mirroring the tip flow

Terminal completion (P3):
- GetCheckoutStatus serializes on pg_advisory_lock('crussell:terminal:' ||
  SquarePayID) on a pinned connection — concurrent polls of the same checkout
  can no longer both pass the dedup SELECT and race the UNIQUE constraint

Card-on-file / doc-only:
- Document why CreateCardOnFile is NOT rolled back on payment failure
  (deterministic sha256 retry returns the same card; deletion breaks it)
- Document HasCompletedPayment's deliberate 'tip' exclusion

Regression tests:
- TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates
- TestRefund_PendingResume_NewKeyAfterModalReopen (proves stored-key resume)
- TestRefund_PendingResume_DifferentAmountRejected (409 + no second row)
- TestRefund_GuardCountsPendingRefunds updated: 400 -> 409 (in-flight guard
  fires first — strictly safer, blocks before any Square attempt)
- TestGetCheckoutStatus_ConcurrentPolls_SingleRecord (real two-goroutine race)
2026-08-22 00:34:49 +01:00

2671 lines
101 KiB
Go

package payments
import (
"context"
"crypto/rand"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
type CreateTerminalPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
PaymentType string `json:"payment_type" validate:"required"`
OverrideAmount *int64 `json:"override_amount,omitempty"`
TipEnabled bool `json:"tip_enabled"`
PaymentMethod *string `json:"payment_method,omitempty"`
GiftCardID *string `json:"gift_card_id,omitempty"`
}
type CreateBookingPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
PaymentType string `json:"payment_type" validate:"required"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key" validate:"required"`
}
type RefundRequest struct {
Amount int64 `json:"amount"`
Reason string `json:"reason"`
// Optional client-generated idempotency key. Two DISTINCT refunds of the
// same amount against the same payment must not collide on the default
// amount-derived key (the dedup lookup would swallow the second refund).
// The frontend sends a UUID generated per refund attempt and reuses it on
// retry, mirroring the tip-flow pattern.
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
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"`
}
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"`
}
// 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
}
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 queries as applyEligibleCampaignsAtPayment
// 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
var campaignID string
var campaignPercent float64
var campaignName string
if err := db.Conn.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" {
var exists int
if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists); err != nil {
log.Printf("Failed to scan campaign discount existence: %v", err)
}
if exists == 0 {
amount := roundTo2(bookingTotal * campaignPercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
Source: "campaign",
Name: campaignName,
Percent: campaignPercent,
Amount: amount,
})
discountTotal += amount
}
}
var userBookingCount int
if err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount); err != nil {
log.Printf("Failed to scan user completed booking count: %v", err)
}
var milestoneCampaignID string
var milestonePercent float64
var milestoneName string
if err := db.Conn.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent, &milestoneName); err != nil {
log.Printf("Failed to query milestone campaign for discount preview (user %s, count %d): %v", userID, userBookingCount, err)
}
if milestoneCampaignID != "" {
var exists int
if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists); err != nil {
log.Printf("Failed to scan milestone discount existence: %v", err)
}
if exists == 0 {
amount := roundTo2(bookingTotal * milestonePercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
Source: "campaign",
Name: milestoneName,
Percent: milestonePercent,
Amount: amount,
})
discountTotal += amount
}
}
var firstVisitDate time.Time
if err := db.Conn.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate); err != nil {
log.Printf("Failed to scan first visit date: %v", err)
}
if !firstVisitDate.IsZero() {
type annCamp struct {
id string
pct float64
value int
unit string
name string
}
annRows, err := db.Conn.Query(ctx, `
SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, userID)
if err == nil {
var campaigns []annCamp
for annRows.Next() {
var c annCamp
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit, &c.name) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
for _, c := range campaigns {
var exists int
if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists); err != nil {
log.Printf("Failed to scan anniversary discount existence: %v", err)
}
if exists > 0 {
continue
}
var matches bool
elapsed := time.Since(firstVisitDate)
switch c.unit {
case "months":
matches = int(elapsed.Hours()/(30*24)) >= c.value
case "years":
matches = int(elapsed.Hours()/(365.25*24)) >= c.value
}
if matches {
amount := roundTo2(bookingTotal * c.pct / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
Source: "campaign",
Name: c.name,
Percent: c.pct,
Amount: amount,
})
discountTotal += amount
}
}
}
}
// Check for referrer's unused referral discount
var rdID string
var rdPercent float64
if err := db.Conn.QueryRow(ctx, `
SELECT id, discount_percent FROM referral_discounts
WHERE user_id = $1 AND used = FALSE
LIMIT 1
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
exists := 0
if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists); err != nil {
log.Printf("Failed to scan referral discount existence: %v", err)
}
if exists == 0 {
amount := roundTo2(bookingTotal * rdPercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
Source: "referral",
Name: "Referral Discount (10%)",
Percent: rdPercent,
Amount: amount,
})
discountTotal += amount
}
}
resp.Eligible = len(resp.Discounts) > 0
resp.DiscountedTotal = roundTo2(bookingTotal - discountTotal)
return resp
}
func CreateTerminalPayment(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
}
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
}
// M8
// L5
if err := ValidateAmount(req.Amount); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidatePaymentType(req.PaymentType); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
service := NewPaymentService()
amount := req.Amount
if req.OverrideAmount != nil {
amount = *req.OverrideAmount
}
// 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.
idempotencyKey := uniqueTipKey()
// Route based on payment method
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
// Start transaction before the status and idempotency checks so they
// are atomic with the payment insert.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Check booking status inside the transaction.
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: %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
}
// Check idempotency inside the transaction.
var existingID string
var existingStatus string
if err := tx.QueryRow(r.Context(), `
SELECT id, status FROM payments WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingStatus); err == nil {
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingID,
Status: existingStatus,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check idempotency: %v", err)
}
amountPounds := float64(amount) / 100.0
var paymentID string
if *req.PaymentMethod == "cash" {
err = tx.QueryRow(r.Context(), `
INSERT INTO payments (
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at
) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW())
RETURNING id
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID)
if err != nil {
log.Printf("Failed to create cash payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
} else { // giftcard
var customerID sql.NullString
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
if err != nil {
log.Printf("Failed to query booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
usedBalance := false
if customerID.Valid {
var balance float64
err = tx.QueryRow(r.Context(), "SELECT balance FROM user_giftcard_balances WHERE user_id = $1 FOR UPDATE", customerID.String).Scan(&balance)
if err == nil {
if balance < amountPounds {
http.Error(w, "Insufficient gift card balance on user account", http.StatusBadRequest)
return
}
// Deduct from account balance
_, err = tx.Exec(r.Context(), "UPDATE user_giftcard_balances SET balance = balance - $1, updated_at = NOW() WHERE user_id = $2", amountPounds, customerID.String)
if err != nil {
log.Printf("Failed to deduct user balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
usedBalance = true
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to query user balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
var cardVoucherType string // voucher_type_at_purchase from the gift card
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
_, err = tx.Exec(r.Context(), "UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW() WHERE id = $2", amountPounds, cleanCardID)
if err != nil {
log.Printf("Failed to deduct gift card amount: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
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, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW())
RETURNING id
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID)
if err != nil {
log.Printf("Failed to create giftcard payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Apply VAT at redemption only if the gift card was purchased as MPV
// (VAT deferred to redemption). For SPV, VAT was already paid at sale.
// For account balance payments (usedBalance=true), VAT was already paid
// when the original card was purchased.
if usedBalance {
// VAT already paid at purchase time — nothing to do here.
} else if cardVoucherType == "MPV" {
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
if vatErr == nil && vatCfg.IsVATRegistered {
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr)
}
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit payment: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: paymentID,
Status: "COMPLETED",
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
// For Square checkout (terminal card reader), validate booking status
// and check idempotency. No DB transaction needed since Square handles
// the payment — no DB writes occur until GetCheckoutStatus.
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
}
existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, idempotencyKey)
if err != nil {
log.Printf("Failed to check idempotency: %v", err)
}
if existingPayment != nil {
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingPayment.ID,
Status: existingPayment.Status,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
}
checkoutReq := square.CreateCheckoutReq{
Amount: amount,
Currency: "GBP",
IdempotencyKey: idempotencyKey,
ReferenceID: bookingID,
TipEnabled: req.TipEnabled,
}
checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq)
if err != nil {
log.Printf("Failed to create checkout: %v", err)
http.Error(w, "Failed to create payment", 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)
}
}
func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) {
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
return
}
if !validators.IsValidID(checkoutID) {
http.Error(w, "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
}
if paymentResult.Status == "COMPLETED" {
service := NewPaymentService()
// Serialize terminal-completion records per Square payment ID. Two
// concurrent polls of the same checkout could otherwise BOTH pass the
// dedup SELECT and BOTH INSERT, with the second dying on the
// idempotency_key UNIQUE constraint after the customer already paid —
// the same double-record race every other payment path guards against.
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()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:terminal:' || $1))
`, terminalLockKey); 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
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1))
`, terminalLockKey); err != nil {
log.Printf("Failed to release terminal-completion serialization lock for %s: %v", terminalLockKey, err)
}
}()
// Deterministic idempotency key derived from booking + amount + Square
// payment ID. The Square payment ID disambiguates two distinct
// equal-amount charges on the same booking, so equal amounts never
// collide on the UNIQUE constraint.
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID
// Begin the transaction BEFORE the dedup lookup so it's atomic with the
// payment insert.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Dedup by Square payment ID: a double poll of the same terminal
// checkout must return the existing payment row instead of inserting a
// duplicate (which previously 500'd on the idempotency-key UNIQUE
// violation after the customer had already paid).
var existingID string
if err := tx.QueryRow(r.Context(), `
SELECT id FROM payments
WHERE booking_id = $1 AND square_payment_id = $2
`, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil {
if err := json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existingID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check for existing payment: %v", err)
}
record := PaymentRecord{
BookingID: bookingID,
PaymentType: "full",
PaymentMethod: "in_person_card",
Status: "completed",
Amount: float64(paymentResult.Amount) / 100.0,
SquarePaymentID: &paymentResult.SquarePayID,
IdempotencyKey: &idempotencyKey,
Fees: float64(paymentResult.Fees) / 100.0,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
}
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)
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
}
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
}
http.Error(w, "Payment failed", http.StatusPaymentRequired)
}
// 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
}
}
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
}
// 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)
}
// M8
// L5
if err := ValidateAmount(req.Amount); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidatePaymentType(req.PaymentType); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
service := NewPaymentService()
if req.PaymentType == "partial" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
}
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
// Serialize payment attempts for this booking to prevent concurrent payments
// across browser tabs or duplicate requests. Uses a PostgreSQL session-level
// advisory lock so that only one goroutine processes payment for a given
// booking at a time, even if two requests pass the optimistic status check below.
//
// We acquire a dedicated connection from the pool and hold it for the
// duration of the handler so that lock and unlock use the same connection.
// Using db.Conn.Exec() for both would be unsafe — each call may get a
// different pool connection, and pg_advisory_unlock on a different session
// is a silent no-op, leaking the lock.
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for payment lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire payment serialization lock for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release payment serialization lock for %s: %v", bookingID, err)
}
}()
// Now that we hold the serialization lock, begin a transaction and re-check
// the booking status inside it. If another request (e.g. from a different tab)
// already processed a payment and promoted the booking while we were waiting,
// we see that here.
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction: %v", txErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
var status string
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status for payment check: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !IsValidBookingStatusForPayment(status) {
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
}
// Check idempotency inside the transaction.
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
`, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil {
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
PaymentType: existingPaymentType.String,
Status: existingStatus.String,
Amount: int64(math.Round(existingAmount.Float64 * 100)),
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check idempotency: %v", err)
}
// After the idempotency check (which handles same-key retries), verify
// that no completed payment of the same non-partial type already exists.
// buildSplitRecords converts 'full' and 'deposit' input types into a
// 'deposit' PB record, so we also check for an existing deposit when the
// incoming type is 'full' or 'deposit'. Together with the advisory lock,
// this prevents the two-tab race where different idempotency keys allow
// concurrent payments of the same type.
if req.PaymentType != "partial" {
var existingCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1
AND status = 'completed'
AND payment_method NOT IN ('discount', 'on_the_house')
AND (
payment_type = $2
OR ($2 IN ('full', 'deposit') AND payment_type = 'deposit')
)
`, bookingID, req.PaymentType).Scan(&existingCount); err == nil && existingCount > 0 {
log.Printf("Payment rejected: booking %s already has a completed %q payment", bookingID, req.PaymentType)
http.Error(w, "A payment of this type has already been processed for this booking", http.StatusConflict)
return
}
}
var sourceID string
var savedCardID *string
if req.NewCardToken != nil && *req.NewCardToken != "" {
// CreateCardOnFile runs before the charge. If the subsequent payment
// fails, this card-on-file is intentionally NOT deleted: the pending
// record's retry re-creates it via the deterministic sha256 idempotency
// key, and Square returns the same card — deleting it would break that
// retry. The orphan is harmless (Square-side only, never charged).
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
sourceID = cardOnFile.CardID
if req.SaveCard {
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil {
log.Printf("Failed to save card: %v", err)
} else {
savedCardID = &cardID
}
}
if savedCardID == nil && req.SaveCard {
log.Printf("Card was not saved despite save_card=true for user %s", userID)
}
} else if req.CardID != nil {
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
sourceID = card.SquareCardID
savedCardID = req.CardID
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
IdempotencyKey: req.IdempotencyKey,
ReferenceID: bookingID,
Note: req.PaymentType,
BuyerEmail: bookingBuyerEmail,
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create payment: %v", err)
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
fees := service.CalculateFees(req.Amount, "online")
paymentAmount := float64(req.Amount) / 100.0
// 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)
primaryRecord := PaymentRecord{
BookingID: bookingID,
PaymentType: req.PaymentType,
PaymentMethod: "online_square",
Status: "completed",
Amount: paymentAmount,
SquarePaymentID: &paymentResult.SquarePayID,
IdempotencyKey: &req.IdempotencyKey,
Fees: float64(fees) / 100.0,
UserSavedCardID: savedCardID,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &userID,
}
var records []PaymentRecord
if bErr == nil && bookingInfo != nil {
records = buildSplitRecords(primaryRecord, req.PaymentType, bookingInfo, paymentAmount)
} else {
if bErr != nil {
log.Printf("Failed to get booking info for split: %v — using single record", bErr)
}
records = []PaymentRecord{primaryRecord}
}
// Create all payment records for this Square charge inside the transaction
// so that if any insert fails the entire group rolls back. This prevents
// a data inconsistency where Square charged the customer but only part of
// the split is reflected in the DB.
var primaryPaymentID string
var paymentIDs []string
for i, rec := range records {
pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil)
if cErr != nil {
log.Printf("Failed to create payment record %d/%d: %v", i+1, len(records), cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
paymentIDs = append(paymentIDs, pid)
if i == 0 {
primaryPaymentID = pid
}
}
// Apply VAT to all split records if the business is VAT-registered.
// Must be inside the transaction so VAT updates are atomic with inserts.
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
if vatErr == nil && vatCfg.IsVATRegistered {
for _, pid := range paymentIDs {
if _, execErr := tx.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-inserted payments.
var depositMet bool
if err := tx.QueryRow(r.Context(), `
WITH booking_total AS (
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
),
paid_total AS (
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents
FROM payments
WHERE booking_id = $1 AND status = 'completed'
)
SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2)
FROM booking_total bt, paid_total pt
`, bookingID).Scan(&depositMet); err != nil {
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
}
if depositMet {
if _, err := tx.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)
}
}
// Apply eligible campaign discounts inside the payment transaction, so
// atomicity with the payment inserts is guaranteed. The call is idempotent
// — if discounts were already applied, the duplicate check skips them.
applyEligibleCampaignsAtPayment(r.Context(), tx, bookingID, userID)
if cErr := tx.Commit(r.Context()); cErr != nil {
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required",
paymentResult.Status, paymentResult.SquarePayID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: primaryPaymentID,
BookingID: bookingID,
PaymentType: req.PaymentType,
Status: "completed",
Amount: req.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
CreatedAt: clock.Now().Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// applyEligibleCampaignsAtPayment checks and applies any eligible discount
// campaigns to the booking. Uses the provided transaction so that discount
// writes are atomic with the caller's payment transaction — if the payment
// commit fails, the discount writes roll back with it.
// Skips if the booking already has 2+ completed non-discount payments — this
// prevents applying new discounts after a customer has already paid, which
// would create a credit balance or require a refund.
func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID string, userID string) {
var existingPayment int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).Scan(&existingPayment); err != nil {
log.Printf("Failed to scan existing payment count: %v", err)
}
// Only block if this is the 2nd+ real payment — the first payment should still
// trigger discount application (existingPayment counts already-completed payments
// visible within the transaction, including the just-inserted one).
if existingPayment >= 2 {
return
}
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
}
if bookingTotal <= 0 {
return
}
var campaignID string
var campaignPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
var exists int
if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists); err != nil {
log.Printf("Failed to scan time-based campaign discount existence: %v", err)
}
if exists == 0 {
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert time-based campaign discount: %v", err)
} else {
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", campaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", campaignID, bookingID, err)
}
}
}
}
var userBookingCount int
if err := q.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount); err != nil {
log.Printf("Failed to scan user booking count: %v", err)
}
var milestoneCampaignID string
var milestonePercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent); err != nil {
log.Printf("Failed to query per-user milestone campaign: %v", err)
}
if milestoneCampaignID != "" {
var exists int
if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists); err != nil {
log.Printf("Failed to scan milestone campaign discount existence: %v", err)
}
if exists == 0 {
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert per-user milestone discount: %v", err)
} else {
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err)
}
}
}
}
var firstVisitDate time.Time
if err := q.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate); err != nil {
log.Printf("Failed to scan first visit date: %v", err)
}
if !firstVisitDate.IsZero() {
annRows, err := q.Query(ctx, `
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, userID)
if err == nil {
type annCampaign struct {
id string
pct float64
value int
unit string
}
var campaigns []annCampaign
for annRows.Next() {
var c annCampaign
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
for _, c := range campaigns {
var exists int
if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists); err != nil {
log.Printf("Failed to scan anniversary discount existence: %v", err)
}
if exists > 0 {
continue
}
var matches bool
elapsed := time.Since(firstVisitDate)
switch c.unit {
case "months":
months := int(elapsed.Hours() / (30 * 24))
matches = months >= c.value
case "years":
years := int(elapsed.Hours() / (365.25 * 24))
matches = years >= c.value
}
if matches {
discountAmount := roundTo2(bookingTotal * c.pct / 100)
if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert anniversary discount: %v", err)
} else {
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", c.id, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", c.id, bookingID, err)
}
}
break
}
}
} else {
log.Printf("Failed to query anniversary campaigns: %v", err)
}
}
var firstPaymentMethod string
if err := q.QueryRow(ctx, `
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
var globalCount int
if err := q.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount); err != nil {
log.Printf("Failed to scan global completed booking count: %v", err)
}
var globalCampaignID string
var globalPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value <= $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE source_id = discount_campaigns.id AND booking_id = $2)
ORDER BY milestone_value DESC LIMIT 1
`, globalCount, bookingID).Scan(&globalCampaignID, &globalPercent); err != nil {
log.Printf("Failed to query global milestone campaign: %v", err)
}
if globalCampaignID != "" {
var exists int
if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists); err != nil {
log.Printf("Failed to scan global campaign discount existence: %v", err)
}
if exists == 0 {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert global milestone discount: %v", err)
} else {
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", globalCampaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", globalCampaignID, bookingID, err)
}
}
}
}
}
// Apply referrer's referral discount if available
if bookingTotal > 0 {
var rdID string
var rdPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM referral_discounts
WHERE user_id = $1 AND used = FALSE
LIMIT 1
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
exists := 0
if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists); err != nil {
log.Printf("Failed to scan referral discount existence: %v", err)
}
if exists == 0 {
discountAmount := roundTo2(bookingTotal * rdPercent / 100)
if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6)
`, bookingID, userID, rdID, rdPercent, bookingTotal, discountAmount); err == nil {
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", rdID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
`, rdID); err != nil {
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
}
}
}
}
}
}
// 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.
func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord {
// After the booking starts there is no deposit protection window —
// record the payment as a single entry with its original type.
if clock.Now().After(info.StartTime) {
return []PaymentRecord{primary}
}
// 1. Deposit portion: up to 50% of total, minus what's already been paid.
maxDeposit := info.TotalAmount * ProtectedDepositMaxPct
remainingDepositRoom := math.Max(0, maxDeposit-info.TotalPaid)
depositAmount := math.Min(paymentAmount, remainingDepositRoom)
depositAmount = math.Round(depositAmount*100) / 100
// 2. Remaining after deposit.
remainingAfterDeposit := math.Round((paymentAmount-depositAmount)*100) / 100
// 3. Balance portion: covers whatever is still owed on the booking.
bookingRemaining := math.Max(0, info.TotalAmount-info.TotalPaid-depositAmount)
balancePortion := math.Min(remainingAfterDeposit, bookingRemaining)
balancePortion = math.Round(balancePortion*100) / 100
// 4. Tip: anything beyond the booking total.
tipPortion := math.Round((remainingAfterDeposit-balancePortion)*100) / 100
var records []PaymentRecord
splitIdx := 0
// 1. Deposit portion (always present when there's deposit room left).
if depositAmount > 0.004 {
dep := primary
dep.PaymentType = "deposit"
dep.Amount = depositAmount
records = append(records, dep)
splitIdx++
}
// 2. Balance / partial / full record — covers the remaining booking total.
if balancePortion > 0.004 {
bal := primary
bal.Amount = balancePortion
bal.Fees = 0
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
bal.IdempotencyKey = &k
}
totalPaidAfterBalance := info.TotalPaid + depositAmount + balancePortion
switch {
case totalPaidAfterBalance >= info.TotalAmount && totalPaidAfterBalance-balancePortion > 0:
bal.PaymentType = "balance"
case totalPaidAfterBalance >= info.TotalAmount:
bal.PaymentType = "full"
default:
bal.PaymentType = "partial"
}
records = append(records, bal)
splitIdx++
}
// If neither deposit nor balance was created (deposit exhausted, booking
// fully paid), the primary is still a valid record — use it directly.
if len(records) == 0 {
primary.Fees = 0
records = append(records, primary)
}
// Tip record — overflow beyond the booking total.
if tipPortion > 0.004 {
tip := primary
tip.PaymentType = "tip"
tip.Amount = tipPortion
tip.Fees = 0
splitIdx++
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + fmt.Sprintf("-split-%d", splitIdx)
tip.IdempotencyKey = &k
}
records = append(records, tip)
}
// If nothing was appended (shouldn't happen given validation upstream),
// return the primary as a fallback.
if len(records) == 0 {
return []PaymentRecord{primary}
}
return records
}
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) {
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
service := NewPaymentService()
cards, err := service.GetUserPaymentMethods(r.Context(), userID)
if err != nil {
log.Printf("Failed to get payment methods for user %s: %v", userID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(cards); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
cardID := chi.URLParam(r, "id")
if cardID == "" || !validators.IsValidID(cardID) {
http.Error(w, "Payment method not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
service := NewPaymentService()
err := service.DeletePaymentMethod(r.Context(), cardID, userID)
if err != nil {
log.Printf("Failed to delete payment method: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
type CreatePaymentMethodRequest struct {
CardToken string `json:"card_token" validate:"required"`
}
func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req CreatePaymentMethodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.CardToken == "" {
http.Error(w, "card_token is required — use a Square Web Payments nonce", http.StatusBadRequest)
return
}
service := NewPaymentService()
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
if err != nil {
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
log.Printf("Failed to create payment method: %v", err)
http.Error(w, "Failed to add card", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(card); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
func RefundPayment(w http.ResponseWriter, r *http.Request) {
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
}
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
}
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
}
if payment.SquarePaymentID == nil {
http.Error(w, "Payment has no Square reference", http.StatusBadRequest)
return
}
// Deterministic idempotency key so a same-key retry (network timeout)
// does not create a second Square refund. When the client supplies an
// idempotency key (one per distinct refund attempt, reused on retry), use
// it — the amount-derived fallback would collide on two DISTINCT partial
// refunds of the same amount, silently swallowing the second.
idempotencyKey := paymentID + "-refund-" + strconv.FormatInt(req.Amount, 10)
if req.IdempotencyKey != "" {
idempotencyKey = paymentID + "-refund-" + req.IdempotencyKey
}
// 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.
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()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
`, refundLockKey); err != nil {
log.Printf("Failed to acquire refund serialization lock for %s: %v", paymentID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, refundLockKey); err != nil {
log.Printf("Failed to release refund serialization lock for %s: %v", paymentID, err)
}
}()
// Dedup/resume (inside the lock): a same-key retry of a completed or
// in-flight (pending) refund must not create a second Square refund. Runs
// BEFORE the over-refund guard so a resuming refund never evaluates its own
// pending row against the guard.
var existingRefundID sql.NullString
var existingRefundStatus sql.NullString
var existingRefundAmount sql.NullFloat64
var existingRefundOrigin sql.NullString
var existingRefundReason sql.NullString
var existingRefundCreatedAt sql.NullTime
var existingRefundKey sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, status, amount, origin, reason, created_at, idempotency_key FROM refunds WHERE idempotency_key = $1
`, idempotencyKey).Scan(&existingRefundID, &existingRefundStatus, &existingRefundAmount, &existingRefundOrigin, &existingRefundReason, &existingRefundCreatedAt, &existingRefundKey)
switch {
case err == nil && existingRefundStatus.String == "completed":
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
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
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: 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
}
reissueReq := square.RefundPaymentReq{
PaymentID: refundSqPaymentID,
Amount: resumeAmount,
IdempotencyKey: existingRefundKey.String,
Reason: existingRefundReason.String,
}
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
switch {
case reissueErr == nil:
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
reissueResult.ID, existingRefundID.String,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", reissueResult.ID, existingRefundID.String, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: existingRefundID.String,
PaymentID: paymentID,
Amount: 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
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: 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
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
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,
payment.BookingID,
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
}
squareRefundID := refundResult.ID
// Square succeeded — update the refund record to completed.
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
squareRefundID, refundID,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", squareRefundID, refundID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
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.
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))
resumeReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: resumeAmount,
IdempotencyKey: refundKey,
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
}
if _, upErr := db.Conn.Exec(r.Context(),
`UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`,
resumeResult.ID, refundID,
); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for refund %s failed — manual reconciliation required: %v", resumeResult.ID, refundID, upErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: resumeAmount,
Status: "completed",
Reason: refundReason,
CreatedAt: clock.Now().Format(time.RFC3339),
}); 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
}
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
service := NewPaymentService()
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
}
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). Fall back to a unique key when absent — must NOT be derived from
// request fields alone (bookingID + amount would dedupe distinct tips).
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uniqueTipKey()
}
// Resolve the card source ID — same pattern as CreateBookingPayment.
var sourceID string
var savedCardID *string
if req.NewCardToken != nil && *req.NewCardToken != "" {
// CreateCardOnFile runs before the charge. If the subsequent payment
// fails, this card-on-file is intentionally NOT deleted: the pending
// record's retry re-creates it via the deterministic sha256 idempotency
// key, and Square returns the same card — deleting it would break that
// retry. The orphan is harmless (Square-side only, never charged).
cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), userID, *req.NewCardToken)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
sourceID = cardOnFile.CardID
if req.SaveCard {
cardID, err := service.SaveCardForUser(r.Context(), userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil {
log.Printf("Failed to save card: %v", err)
} else {
savedCardID = &cardID
}
}
if savedCardID == nil && req.SaveCard {
log.Printf("Card was not saved despite save_card=true for user %s", userID)
}
} else if req.CardID != nil {
card, err := service.GetCardByID(r.Context(), *req.CardID, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
sourceID = card.SquareCardID
savedCardID = req.CardID
}
// 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.
// See CreateBookingPayment lines 815-846 for the same pattern.
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for tip lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:tip:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to acquire tip serialization lock for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:tip:' || $1))
`, bookingID); err != nil {
log.Printf("Failed to release tip serialization lock for %s: %v", bookingID, err)
}
}()
// 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)
}
}()
// Check idempotency inside the transaction.
// Only short-circuit when the existing record is 'completed'. A 'pending'
// record means the previous Square call failed — returning it as 200 would
// show a success toast without ever charging. Re-attempt the charge below
// with the same idempotency key (Square dedups safely) and reuse the
// existing record.
var existingID sql.NullString
var existingBookingID sql.NullString
var existingPaymentType sql.NullString
var existingStatus sql.NullString
var existingAmount sql.NullFloat64
var existingCreatedAt sql.NullTime
err = tx.QueryRow(r.Context(), `
SELECT id, booking_id, payment_type, status, amount, created_at
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt)
paymentID := ""
reusePendingRecord := false
switch {
case err == nil && existingStatus.String == "completed":
// Idempotent dedup — return the already-completed payment.
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
PaymentType: existingPaymentType.String,
Status: existingStatus.String,
Amount: int64(math.Round(existingAmount.Float64 * 100)),
CreatedAt: existingCreatedAt.Time.Format(time.RFC3339),
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
case err == nil && existingStatus.String == "pending":
// Previous Square call failed — reuse the pending record and re-attempt.
// Guard the amount: a retry with a different amount must not mutate the
// original record (books, VAT, refund caps) or silently charge the new
// amount against the old record. Compare in pence via math.Round — the
// stored pounds value is float64, so int64(pounds*100) truncation would
// reject legitimate same-amount retries for non-exact values (e.g. £1.14
// stored as 1.1399999999999999 → int64 gives 113 ≠ 114).
if int64(math.Round(existingAmount.Float64*100)) != req.Amount {
log.Printf("Tip retry amount mismatch: pending record %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), req.Amount)
http.Error(w, "Amount does not match the pending tip payment", http.StatusBadRequest)
return
}
paymentID = existingID.String
reusePendingRecord = true
case err != nil && !errors.Is(err, pgx.ErrNoRows):
log.Printf("Failed to check tip idempotency: %v", err)
}
if !reusePendingRecord {
record := PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "online_square",
Status: "pending",
Amount: float64(req.Amount) / 100.0,
IdempotencyKey: &idempotencyKey,
Fees: 0,
UserSavedCardID: savedCardID,
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)
}
// 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,
IdempotencyKey: idempotencyKey,
ReferenceID: bookingID,
Note: "tip",
BuyerEmail: buyerEmail,
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create tip payment: %v", err)
// Payment record intentionally left as 'pending' for manual retry.
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
// Step 3: Square succeeded — update the payment record.
_, upErr := db.Conn.Exec(r.Context(),
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
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 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()
if userRole != "admin" && userID != "" {
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking user: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
}
summary, err := service.GetBookingPaymentSummary(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get payment summary: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
payments := make([]PaymentResponse, len(summary.Payments))
for i, p := range summary.Payments {
payments[i] = PaymentResponse{
ID: p.ID,
BookingID: p.BookingID,
PaymentType: p.PaymentType,
Status: p.Status,
Amount: int64(math.Round(p.Amount * 100)),
CardLast4: p.CardLast4,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
}
}
refunds := make([]RefundResponse, len(summary.Refunds))
for i, rf := range summary.Refunds {
refunds[i] = RefundResponse{
ID: rf.ID,
PaymentID: rf.PaymentID,
Amount: int64(math.Round(rf.Amount * 100)),
Status: rf.Status,
Reason: rf.Reason,
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
}
}
if err := json.NewEncoder(w).Encode(PaymentSummaryResponse{
TotalAmount: int64(math.Round(summary.TotalAmount * 100)),
PaidAmount: int64(math.Round(summary.PaidAmount * 100)),
RefundedAmount: int64(math.Round(summary.RefundedAmount * 100)),
RemainingAmount: int64(math.Round(summary.RemainingAmount * 100)),
TotalVATAmount: int64(math.Round(summary.TotalVATAmount * 100)),
TotalNetAmount: int64(math.Round(summary.TotalNetAmount * 100)),
Payments: payments,
Refunds: refunds,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// PaymentLockDuration is the TTL for a payment-in-flight lock in minutes.
const PaymentLockDuration = 5
// AcquirePaymentLock creates or extends a 5-minute time_blocker for the
// booking's slot so that pending_release eviction is blocked during card
// entry and Square charge processing.
func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Verify the user owns this booking.
var bookingUserID string
if err := db.Conn.QueryRow(r.Context(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
).Scan(&bookingUserID); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
if bookingUserID != userID {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
// Before acquiring the lock, double-check the slot is still available.
// For confirmed/in_progress bookings this is a formality; for
// pending_release bookings it catches the eviction race before we
// create a time_blocker — the NOT EXISTS guard in eviction queries
// handles the sub-5-minute race, this catches the >5-minute gap.
var currentStatus string
var startTime time.Time
if err := db.Conn.QueryRow(r.Context(),
"SELECT status, start_time FROM bookings WHERE id = $1", bookingID,
).Scan(&currentStatus, &startTime); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// If the booking has been evicted (deposit_lapsed) or reached a terminal
// state, reject the lock — payment cannot proceed.
if !IsValidBookingStatusForPayment(currentStatus) || currentStatus == "pending" {
log.Printf("Payment lock rejected: booking %s is in status %q (no longer accepting payments)", bookingID, currentStatus)
http.Error(w, "This booking is no longer accepting payments. The slot may have been released.", http.StatusConflict)
return
}
// Upsert the time_blocker atomically: delete old PAYMENT_IN_FLIGHT and insert
// a fresh one in a single transaction. Prevents lock loss if INSERT fails.
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction for payment lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
`, bookingID); err != nil {
log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES (NOW(), $1, $2, $3)
`, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil {
log.Printf("Failed to acquire payment lock for booking %s: %v", bookingID, err)
http.Error(w, "Failed to secure payment slot", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit payment lock transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "locked",
"ttl_min": PaymentLockDuration,
"bookingID": bookingID,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// ReleasePaymentLock removes the PAYMENT_IN_FLIGHT time_blocker for a booking.
func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
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)
}
// uniqueTipKey generates a unique idempotency key for tip payments where the
// client did not supply one. Client-supplied UUIDs handle retry dedup; this
// fallback only needs uniqueness so identical tips don't collapse.
func uniqueTipKey() string {
return "tip-" + rand.Text()
}