diff --git a/go.mod b/go.mod index 4a5f05f..10e452c 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-playground/validator/v10 v10.14.0 github.com/goccy/go-json v0.10.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/internal/handlers/payment.go b/internal/handlers/payment.go index c6dafbb..421ba9c 100644 --- a/internal/handlers/payment.go +++ b/internal/handlers/payment.go @@ -1,28 +1,171 @@ package handlers import ( + "io" + "net/http" + "strings" + + "attune-heart-therapy/internal/services" + "github.com/gin-gonic/gin" ) type PaymentHandler struct { - // Will be implemented in later tasks + paymentService services.PaymentService } -func NewPaymentHandler() *PaymentHandler { - return &PaymentHandler{} +func NewPaymentHandler(paymentService services.PaymentService) *PaymentHandler { + return &PaymentHandler{ + paymentService: paymentService, + } } +// CreatePaymentIntent handles POST /api/payments/intent for payment intent creation func (h *PaymentHandler) CreatePaymentIntent(c *gin.Context) { - // Will be implemented in task 7 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + var req services.CreatePaymentIntentRequest + + // Bind JSON request to struct + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request format", + "details": err.Error(), + }) + return + } + + // Set default currency if not provided + if req.Currency == "" { + req.Currency = "usd" + } + + // Create payment intent + paymentIntent, err := h.paymentService.CreatePaymentIntent(req.Amount, req.Currency) + if err != nil { + if strings.Contains(err.Error(), "amount must be greater than 0") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid amount", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create payment intent", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Payment intent created successfully", + "client_secret": paymentIntent.ClientSecret, + "payment_intent": paymentIntent.ID, + "amount": paymentIntent.Amount, + "currency": paymentIntent.Currency, + "status": paymentIntent.Status, + }) } +// ConfirmPayment handles POST /api/payments/confirm for payment confirmation func (h *PaymentHandler) ConfirmPayment(c *gin.Context) { - // Will be implemented in task 7 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + var req services.ConfirmPaymentRequest + + // Bind JSON request to struct + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request format", + "details": err.Error(), + }) + return + } + + // Confirm payment + paymentIntent, err := h.paymentService.ConfirmPayment(req.PaymentIntentID) + if err != nil { + if strings.Contains(err.Error(), "payment intent ID is required") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Payment intent ID is required", + "details": err.Error(), + }) + return + } + + // Check if it's a Stripe error (payment failed, card declined, etc.) + if strings.Contains(err.Error(), "Your card was declined") || + strings.Contains(err.Error(), "insufficient_funds") || + strings.Contains(err.Error(), "card_declined") { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment failed", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to confirm payment", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Payment confirmed successfully", + "payment_intent": paymentIntent.ID, + "status": paymentIntent.Status, + "amount": paymentIntent.Amount, + "currency": paymentIntent.Currency, + }) } +// HandleWebhook handles POST /api/payments/webhook for Stripe webhooks func (h *PaymentHandler) HandleWebhook(c *gin.Context) { - // Will be implemented in task 7 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get the raw body + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Failed to read request body", + "details": err.Error(), + }) + return + } + + // Get Stripe signature from header + signature := c.GetHeader("Stripe-Signature") + if signature == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Missing Stripe signature header", + }) + return + } + + // Process webhook + err = h.paymentService.HandleWebhook(body, signature) + if err != nil { + if strings.Contains(err.Error(), "webhook signature") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid webhook signature", + "details": err.Error(), + }) + return + } + + if strings.Contains(err.Error(), "webhook payload is empty") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Empty webhook payload", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to process webhook", + "details": err.Error(), + }) + return + } + + // Return 200 to acknowledge receipt of the webhook + c.JSON(http.StatusOK, gin.H{ + "message": "Webhook processed successfully", + }) } diff --git a/internal/services/interfaces.go b/internal/services/interfaces.go index 03a3677..d4dbf24 100644 --- a/internal/services/interfaces.go +++ b/internal/services/interfaces.go @@ -78,3 +78,13 @@ type BookingRequest struct { Notes string `json:"notes"` Amount float64 `json:"amount" binding:"required"` } + +// Payment DTOs +type CreatePaymentIntentRequest struct { + Amount float64 `json:"amount" binding:"required,gt=0"` + Currency string `json:"currency"` +} + +type ConfirmPaymentRequest struct { + PaymentIntentID string `json:"payment_intent_id" binding:"required"` +} diff --git a/internal/services/payment_service.go b/internal/services/payment_service.go new file mode 100644 index 0000000..33140ea --- /dev/null +++ b/internal/services/payment_service.go @@ -0,0 +1,151 @@ +package services + +import ( + "encoding/json" + "fmt" + "log" + + "attune-heart-therapy/internal/config" + + "github.com/stripe/stripe-go/v76" + "github.com/stripe/stripe-go/v76/paymentintent" + "github.com/stripe/stripe-go/v76/webhook" +) + +// paymentService implements the PaymentService interface +type paymentService struct { + config *config.Config +} + +// NewPaymentService creates a new instance of PaymentService +func NewPaymentService(cfg *config.Config) PaymentService { + // Set Stripe API key + stripe.Key = cfg.Stripe.SecretKey + + return &paymentService{ + config: cfg, + } +} + +// CreatePaymentIntent creates a new Stripe payment intent for payment initialization +func (s *paymentService) CreatePaymentIntent(amount float64, currency string) (*stripe.PaymentIntent, error) { + if amount <= 0 { + return nil, fmt.Errorf("amount must be greater than 0") + } + + if currency == "" { + currency = "usd" // Default to USD + } + + // Convert amount to cents (Stripe expects amounts in smallest currency unit) + amountCents := int64(amount * 100) + + params := &stripe.PaymentIntentParams{ + Amount: stripe.Int64(amountCents), + Currency: stripe.String(currency), + AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ + Enabled: stripe.Bool(true), + }, + } + + pi, err := paymentintent.New(params) + if err != nil { + log.Printf("Failed to create payment intent: %v", err) + return nil, fmt.Errorf("failed to create payment intent: %w", err) + } + + return pi, nil +} + +// ConfirmPayment confirms a payment intent for payment completion +func (s *paymentService) ConfirmPayment(paymentIntentID string) (*stripe.PaymentIntent, error) { + if paymentIntentID == "" { + return nil, fmt.Errorf("payment intent ID is required") + } + + params := &stripe.PaymentIntentConfirmParams{} + pi, err := paymentintent.Confirm(paymentIntentID, params) + if err != nil { + log.Printf("Failed to confirm payment intent %s: %v", paymentIntentID, err) + return nil, fmt.Errorf("failed to confirm payment: %w", err) + } + + return pi, nil +} + +// HandleWebhook processes Stripe webhook events for webhook processing +func (s *paymentService) HandleWebhook(payload []byte, signature string) error { + if len(payload) == 0 { + return fmt.Errorf("webhook payload is empty") + } + + if signature == "" { + return fmt.Errorf("webhook signature is required") + } + + // Verify webhook signature + event, err := webhook.ConstructEvent(payload, signature, s.config.Stripe.WebhookSecret) + if err != nil { + log.Printf("Failed to verify webhook signature: %v", err) + return fmt.Errorf("failed to verify webhook signature: %w", err) + } + + // Handle different event types + switch event.Type { + case "payment_intent.succeeded": + var paymentIntent stripe.PaymentIntent + objectBytes, err := json.Marshal(event.Data.Object) + if err != nil { + log.Printf("Failed to marshal event data: %v", err) + return fmt.Errorf("failed to parse event data: %w", err) + } + + if err := json.Unmarshal(objectBytes, &paymentIntent); err != nil { + log.Printf("Failed to unmarshal payment intent: %v", err) + return fmt.Errorf("failed to parse payment intent: %w", err) + } + + log.Printf("Payment succeeded for payment intent: %s", paymentIntent.ID) + // TODO: Update booking status to confirmed + // This will be handled when booking service is integrated + + case "payment_intent.payment_failed": + var paymentIntent stripe.PaymentIntent + objectBytes, err := json.Marshal(event.Data.Object) + if err != nil { + log.Printf("Failed to marshal event data: %v", err) + return fmt.Errorf("failed to parse event data: %w", err) + } + + if err := json.Unmarshal(objectBytes, &paymentIntent); err != nil { + log.Printf("Failed to unmarshal payment intent: %v", err) + return fmt.Errorf("failed to parse payment intent: %w", err) + } + + log.Printf("Payment failed for payment intent: %s", paymentIntent.ID) + // TODO: Update booking status to failed + // This will be handled when booking service is integrated + + case "payment_intent.canceled": + var paymentIntent stripe.PaymentIntent + objectBytes, err := json.Marshal(event.Data.Object) + if err != nil { + log.Printf("Failed to marshal event data: %v", err) + return fmt.Errorf("failed to parse event data: %w", err) + } + + if err := json.Unmarshal(objectBytes, &paymentIntent); err != nil { + log.Printf("Failed to unmarshal payment intent: %v", err) + return fmt.Errorf("failed to parse payment intent: %w", err) + } + + log.Printf("Payment canceled for payment intent: %s", paymentIntent.ID) + // TODO: Update booking status to canceled + // This will be handled when booking service is integrated + + default: + log.Printf("Unhandled webhook event type: %s", event.Type) + } + + return nil +}