975 lines
29 KiB
Go
975 lines
29 KiB
Go
package payments
|
|
|
|
import (
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type CreateTerminalPaymentRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
PaymentType string `json:"payment_type"`
|
|
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"`
|
|
PaymentType string `json:"payment_type"`
|
|
CardID *string `json:"card_id,omitempty"`
|
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
|
SaveCard bool `json:"save_card"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
|
|
type RefundRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type CreateTipPaymentRequest struct {
|
|
Amount int64 `json:"amount"`
|
|
CardToken string `json:"card_token"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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 := 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.DB.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 := normalizeCode(*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 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
|
|
}
|
|
|
|
bookingID := r.URL.Query().Get("booking_id")
|
|
if bookingID == "" {
|
|
http.Error(w, "booking_id query parameter is required", 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)
|
|
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)
|
|
}
|
|
|
|
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 := 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
|
|
fees := service.CalculateFees(req.Amount, "online")
|
|
|
|
record := PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: req.PaymentType,
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: float64(req.Amount) / 100.0,
|
|
SquarePaymentID: &paymentResult.SquarePayID,
|
|
IdempotencyKey: &req.IdempotencyKey,
|
|
Fees: float64(fees) / 100.0,
|
|
UserSavedCardID: savedCardID,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
CreatedBy: &userID,
|
|
}
|
|
|
|
paymentID, err := service.CreatePaymentRecord(r.Context(), record)
|
|
if err != nil {
|
|
log.Printf("Failed to create payment record: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if req.PaymentType == "deposit" {
|
|
err = service.UpdateBookingDepositPaid(r.Context(), bookingID, true)
|
|
if err != nil {
|
|
log.Printf("Failed to update deposit paid: %v", err)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(PaymentResponse{
|
|
ID: paymentID,
|
|
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),
|
|
})
|
|
}
|
|
|
|
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"`
|
|
Expiry string `json:"expiry"`
|
|
CVC string `json:"cvc"`
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
if payment.PaymentType == "deposit" && (req.Amount+alreadyRefunded) >= int64(payment.Amount*100) {
|
|
err = service.UpdateBookingDepositPaid(r.Context(), payment.BookingID, false)
|
|
if err != nil {
|
|
log.Printf("Failed to update deposit paid: %v", err)
|
|
}
|
|
}
|
|
|
|
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 := 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)
|
|
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,
|
|
})
|
|
}
|