Files
Crussell/backend/handlers/payments/handlers.go
T
popertotsandSisyphus 14df08e129 refactor(payments): replace inline total_amount queries with booking field
Simplify payment handlers by using bookings.total_amount computed column instead of inline UNION sub-queries calculating price totals from booking_services and booking_custom_services.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 21:47:16 +01:00

1819 lines
61 KiB
Go

package payments
import (
"context"
"crussell/db"
"crussell/internal/square"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"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"`
}
type CreateTipPaymentRequest struct {
Amount int64 `json:"amount" validate:"required,gt=0"`
CardToken string `json:"card_token" validate:"required"`
}
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"`
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)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(preview)
}
// 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
db.Conn.QueryRow(ctx, `
SELECT total_amount FROM bookings WHERE id = $1
`, bookingID).Scan(&bookingTotal)
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
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
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
db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
var milestoneCampaignID string
var milestonePercent float64
var milestoneName string
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)
if milestoneCampaignID != "" {
var exists int
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
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
db.Conn.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
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
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
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
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)
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if err := ValidateAmount(req.Amount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := ValidatePaymentType(req.PaymentType); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
service := NewPaymentService()
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
}
amount := req.Amount
if req.OverrideAmount != nil {
amount = *req.OverrideAmount
}
idempotencyKey := bookingID + "-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10)
existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, idempotencyKey)
if err != nil {
log.Printf("Failed to check idempotency: %v", err)
}
if existingPayment != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingPayment.ID,
Status: existingPayment.Status,
})
return
}
// Route based on payment method
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
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 tx.Rollback(r.Context())
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, created_by, created_at, updated_at
) VALUES ($1, $2, 'cash', 'completed', $3, $4, NOW(), NOW())
RETURNING id
`, bookingID, req.PaymentType, amountPounds, 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
}
} 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
}
}
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
err = tx.QueryRow(r.Context(), "SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1 FOR UPDATE", cleanCardID).Scan(&gcRemaining, &redeemedBy)
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
}
// 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, created_by, created_at, updated_at
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, NOW(), NOW())
RETURNING id
`, bookingID, req.PaymentType, amountPounds, 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
}
}
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: paymentID,
Status: "COMPLETED",
})
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: checkout.ID,
Status: checkout.Status,
})
_ = adminID
}
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 err.Error() == "checkout pending" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
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()
existing, err := service.CheckIdempotency(r.Context(), bookingID, "")
if err != nil {
log.Printf("Failed to check for existing payment: %v", err)
}
if existing != nil && existing.SquarePaymentID != nil && *existing.SquarePaymentID == paymentResult.SquarePayID {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: existing.ID,
Amount: int64(existing.Amount * 100),
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
})
return
}
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10)
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: time.Now(),
UpdatedAt: time.Now(),
}
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
if err != nil {
log.Printf("Failed to create payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PaymentStatusResponse{
Status: "COMPLETED",
PaymentID: paymentID,
Amount: paymentResult.Amount,
CardBrand: paymentResult.CardBrand,
CardLast4: paymentResult.CardLast4,
ReceiptURL: paymentResult.ReceiptURL,
})
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if err := ValidateAmount(req.Amount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := ValidatePaymentType(req.PaymentType); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil {
http.Error(w, err.Error(), 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 {
http.Error(w, err.Error(), 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, re-check the booking status.
// 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.
status, err := service.GetBookingStatus(r.Context(), bookingID)
if err != nil {
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
}
existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, req.IdempotencyKey)
if err != nil {
log.Printf("Failed to check idempotency: %v", err)
}
if existingPayment != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PaymentResponse{
ID: existingPayment.ID,
BookingID: existingPayment.BookingID,
PaymentType: existingPayment.PaymentType,
Status: existingPayment.Status,
Amount: int64(existingPayment.Amount * 100),
CreatedAt: existingPayment.CreatedAt.Format(time.RFC3339),
})
return
}
// After the idempotency check (which handles same-key retries), verify
// that no completed payment of the same non-partial type already exists.
// buildSplitRecords converts 'full' and 'deposit' input types into a
// 'deposit' PB record, so we also check for an existing deposit when the
// incoming type is 'full' or 'deposit'. Together with the advisory lock,
// this prevents the two-tab race where different idempotency keys allow
// concurrent payments of the same type.
if req.PaymentType != "partial" {
var existingCount int
if err := db.Conn.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 != "" {
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,
}
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
}
// Apply eligible campaign discounts before the payment is processed, so
// the discount payment records exist in the DB before the frontend computes
// the net amount to charge. The call is idempotent — if discounts were
// already applied (e.g. by a prior call), the duplicate check skips them.
applyEligibleCampaignsAtPayment(r.Context(), bookingID, userID)
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: time.Now(),
UpdatedAt: time.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 a 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.
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for payment records: %v", txErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var primaryPaymentID 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
}
if i == 0 {
primaryPaymentID = pid
}
}
// 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
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)
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)
}
}
if cErr := tx.Commit(r.Context()); cErr != nil {
log.Printf("Failed to commit payment records: %v", cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
applyEligibleCampaignsAtPayment(r.Context(), bookingID, userID)
w.Header().Set("Content-Type", "application/json")
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: time.Now().Format(time.RFC3339),
})
}
// applyEligibleCampaignsAtPayment runs after a payment is committed to check
// and apply any eligible discount campaigns to the booking.
// Skips if the booking already has a completed non-discount payment — 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, bookingID string, userID string) {
var existingPayment int
db.Conn.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)
// Only block if this is the 2nd+ real payment — the first payment should still
// trigger discount application (existingPayment counts already-completed payments).
// When called before payment commit (line 850), existingPayment=0 so discounts
// are applied. When called after commit (line 952), existingPayment=1 and the
// idempotency check handles it. At the 2nd+ payment attempt, this guard prevents
// applying any new discounts.
if existingPayment >= 2 {
return
}
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 calculate booking total for campaign check: %v", err)
return
}
if bookingTotal <= 0 {
return
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin discount application transaction: %v", err)
return
}
defer tx.Rollback(ctx)
var campaignID string
var campaignPercent float64
if err := tx.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
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
if exists == 0 {
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
if _, err := tx.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 {
tx.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)
tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID)
}
}
}
var userBookingCount int
tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
var milestoneCampaignID string
var milestonePercent float64
tx.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)
if milestoneCampaignID != "" {
var exists int
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
if exists == 0 {
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
if _, err := tx.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 {
tx.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)
tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID)
}
}
}
var firstVisitDate time.Time
tx.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
if !firstVisitDate.IsZero() {
annRows, err := tx.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
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
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 := tx.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 {
tx.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)
tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id)
}
break
}
}
} else {
log.Printf("Failed to query anniversary campaigns: %v", err)
}
}
var firstPaymentMethod string
if err := tx.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
tx.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var globalCampaignID string
var globalPercent float64
tx.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)
if globalCampaignID != "" {
var exists int
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists)
if exists == 0 {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
if _, err := tx.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 {
tx.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)
tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID)
}
}
}
}
// Apply referrer's referral discount if available
if bookingTotal > 0 {
var rdID string
var rdPercent float64
if err := tx.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
tx.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists)
if exists == 0 {
discountAmount := roundTo2(bookingTotal * rdPercent / 100)
if _, err := tx.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 {
tx.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)
tx.Exec(ctx, `
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
`, rdID)
}
}
}
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit discount application: %v", 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 time.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
}
// nonDepositPaymentType picks the right label for the non-deposit portion of a
// split payment, following the same rules as the frontend's handlePayFull:
// 'balance' when some payment already exists, 'full' when covering everything,
// 'partial' when leaving a remainder.
func nonDepositPaymentType(reqType string, totalPaidAfterThis float64, thisPortion float64, bookingTotal float64) string {
if totalPaidAfterThis >= bookingTotal {
if totalPaidAfterThis-thisPortion > 0 {
return "balance"
}
return "full"
}
if reqType == "full" || reqType == "deposit" {
return "partial"
}
return "partial"
}
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cards)
}
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cards)
}
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}
type CreatePaymentMethodRequest struct {
CardNumber string `json:"card_number" validate:"required"`
Expiry string `json:"expiry" validate:"required"`
CVC string `json:"cvc" 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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if req.CardNumber == "" || req.Expiry == "" || req.CVC == "" {
http.Error(w, "Card number, expiry, and CVC are required", http.StatusBadRequest)
return
}
service := NewPaymentService()
card, err := service.CreatePaymentMethodFromDetails(r.Context(), userID, req.CardNumber, req.Expiry, req.CVC)
if err != nil {
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Failed to create payment method: %v", err)
http.Error(w, "Failed to add card", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(card)
}
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := ValidateRefundReason(req.Reason); err != nil {
http.Error(w, err.Error(), 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
}
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(payment.Amount*100) {
http.Error(w, "Refund amount exceeds payment amount", http.StatusBadRequest)
return
}
refundReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: req.Amount,
IdempotencyKey: paymentID + "-" + strconv.FormatInt(req.Amount, 10),
Reason: req.Reason,
}
refundResult, err := SquareClient.RefundPayment(r.Context(), refundReq)
if err != nil {
log.Printf("Failed to refund payment: %v", err)
http.Error(w, "Refund failed", http.StatusInternalServerError)
return
}
squareRefundID := refundResult.ID
record := RefundRecord{
PaymentID: paymentID,
BookingID: payment.BookingID,
Amount: float64(req.Amount) / 100.0,
SquareRefundID: &squareRefundID,
Status: "completed",
Reason: req.Reason,
CreatedBy: &adminID,
CreatedAt: time.Now(),
}
refundID, err := service.CreateRefundRecord(r.Context(), record)
if err != nil {
log.Printf("Failed to create refund record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(RefundResponse{
ID: refundID,
PaymentID: paymentID,
Amount: req.Amount,
Status: "completed",
Reason: req.Reason,
CreatedAt: time.Now().Format(time.RFC3339),
})
}
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if err := ValidateAmount(req.Amount); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.CardToken == "" {
http.Error(w, "Card token is required", 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
}
idempotencyKey := bookingID + "-tip-" + strconv.FormatInt(req.Amount, 10)
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
SourceID: req.CardToken,
IdempotencyKey: idempotencyKey,
ReferenceID: bookingID,
Note: "tip",
}
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create tip payment: %v", err)
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
record := PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "online_square",
Status: "completed",
Amount: float64(req.Amount) / 100.0,
SquarePaymentID: &paymentResult.SquarePayID,
IdempotencyKey: &idempotencyKey,
Fees: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedBy: &userID,
}
paymentID, err := service.CreatePaymentRecord(r.Context(), record, nil)
if err != nil {
log.Printf("Failed to create payment record: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
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: time.Now().Format(time.RFC3339),
})
}
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(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(rf.Amount * 100),
Status: rf.Status,
Reason: rf.Reason,
CreatedAt: rf.CreatedAt.Format(time.RFC3339),
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PaymentSummaryResponse{
TotalAmount: int64(summary.TotalAmount * 100),
PaidAmount: int64(summary.PaidAmount * 100),
RefundedAmount: int64(summary.RefundedAmount * 100),
RemainingAmount: int64(summary.RemainingAmount * 100),
Payments: payments,
Refunds: refunds,
})
}
// 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: delete any existing PAYMENT_IN_FLIGHT for this
// booking, then insert a fresh one. This effectively extends the lock.
if _, err := db.Conn.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)
}
if _, err := db.Conn.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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "locked",
"ttl_min": PaymentLockDuration,
"bookingID": bookingID,
})
}
// 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
}
if _, err := db.Conn.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
}
w.WriteHeader(http.StatusNoContent)
}