feat(payments): Implement comprehensive Stripe payment integration
- Add PaymentHandler with methods for creating payment intents, confirming payments, and handling webhooks - Implement PaymentService interface with Stripe payment processing logic - Create DTOs for payment-related requests in services/interfaces.go - Add error handling and validation for payment-related operations - Configure Stripe API key and support for automatic payment methods - Implement webhook signature verification and event processing - Enhance error responses with detailed error messages and appropriate HTTP status codes Enables full payment flow using Stripe, supporting intent creation, payment confirmation, and webhook handling for robust transaction management.
This commit is contained in:
parent
0e83ee056c
commit
a45b22afd0
2
go.mod
2
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
|
||||
|
||||
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
151
internal/services/payment_service.go
Normal file
151
internal/services/payment_service.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user