package payments import ( "crussell/internal/square" "crussell/mw" "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"` } 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 == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) 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 } 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 == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) 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 DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { cardID := chi.URLParam(r, "id") if cardID == "" { http.Error(w, "Card ID is required", http.StatusBadRequest) 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 == "" { http.Error(w, "Payment ID is required", http.StatusBadRequest) 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 == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) 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 == "" { http.Error(w, "Booking ID is required", http.StatusBadRequest) 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, }) }