gohorsejobs/backend/internal/handlers/payment_handler.go
Tiago Yamamoto 6cd8c02252 feat: add test coverage and handler improvements
- Add new test files for handlers (storage, payment, settings)
- Add new test files for services (chat, email, storage, settings, admin)
- Add integration tests for services
- Update handler implementations with bug fixes
- Add coverage reports and test documentation
2026-01-02 08:50:29 -03:00

338 lines
9.5 KiB
Go

package handlers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// PaymentCredentialsServiceInterface defines the contract for credentials
type PaymentCredentialsServiceInterface interface {
GetDecryptedKey(ctx context.Context, keyName string) (string, error)
}
// StripeClientInterface defines the contract for Stripe operations
type StripeClientInterface interface {
CreateCheckoutSession(secretKey string, req CreateCheckoutRequest) (string, string, error)
}
// PaymentHandler handles Stripe payment operations
type PaymentHandler struct {
credentialsService PaymentCredentialsServiceInterface
stripeClient StripeClientInterface
}
// NewPaymentHandler creates a new payment handler
func NewPaymentHandler(credentialsService PaymentCredentialsServiceInterface) *PaymentHandler {
return &PaymentHandler{
credentialsService: credentialsService,
stripeClient: &defaultStripeClient{},
}
}
// defaultStripeClient implements StripeClientInterface
type defaultStripeClient struct{}
func (c *defaultStripeClient) CreateCheckoutSession(secretKey string, req CreateCheckoutRequest) (string, string, error) {
return createStripeCheckoutSession(secretKey, req)
}
// CreateCheckoutRequest represents a checkout session request
type CreateCheckoutRequest struct {
JobID int `json:"jobId"`
PriceID string `json:"priceId"` // Stripe Price ID
SuccessURL string `json:"successUrl"` // URL after success
CancelURL string `json:"cancelUrl"` // URL after cancel
}
// CreateCheckoutResponse represents the checkout session response
type CreateCheckoutResponse struct {
SessionID string `json:"sessionId"`
CheckoutURL string `json:"checkoutUrl"`
}
// StripeConfig holds the configuration for Stripe
type StripeConfig struct {
SecretKey string `json:"secretKey"`
WebhookSecret string `json:"webhookSecret"`
PublishableKey string `json:"publishableKey"`
}
// CreateCheckout creates a Stripe checkout session for job posting payment
// @Summary Create checkout session
// @Description Create a Stripe checkout session for job posting payment
// @Tags Payments
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateCheckoutRequest true "Checkout request"
// @Success 200 {object} CreateCheckoutResponse
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/payments/create-checkout [post]
func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) {
var req CreateCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.JobID == 0 || req.PriceID == "" {
http.Error(w, "JobID and PriceID are required", http.StatusBadRequest)
return
}
// Get Stripe config from encrypted vault
var config StripeConfig
payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe")
if err == nil {
json.Unmarshal([]byte(payload), &config)
}
// Fallback to Env if not found or empty (for migration/dev)
if config.SecretKey == "" {
config.SecretKey = os.Getenv("STRIPE_SECRET_KEY")
}
if config.SecretKey == "" {
http.Error(w, "Payment service not configured", http.StatusInternalServerError)
return
}
// Create Stripe checkout session via API
sessionID, checkoutURL, err := h.stripeClient.CreateCheckoutSession(config.SecretKey, req)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError)
return
}
response := CreateCheckoutResponse{
SessionID: sessionID,
CheckoutURL: checkoutURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleWebhook processes Stripe webhook events
// @Summary Handle Stripe webhook
// @Description Process Stripe webhook events (payment success, failure, etc.)
// @Tags Payments
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Failure 400 {string} string "Bad Request"
// @Router /api/v1/payments/webhook [post]
func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
// Get Stripe config from encrypted vault
var config StripeConfig
payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe")
if err == nil {
json.Unmarshal([]byte(payload), &config)
}
// Fallback to Env
if config.WebhookSecret == "" {
config.WebhookSecret = os.Getenv("STRIPE_WEBHOOK_SECRET")
}
if config.WebhookSecret == "" {
http.Error(w, "Webhook secret not configured", http.StatusInternalServerError)
return
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Verify signature
signature := r.Header.Get("Stripe-Signature")
if !verifyStripeSignature(body, signature, config.WebhookSecret) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Parse event
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
eventType, ok := event["type"].(string)
if !ok {
http.Error(w, "Missing event type", http.StatusBadRequest)
return
}
// Handle event types
switch eventType {
case "checkout.session.completed":
h.handleCheckoutComplete(event)
case "payment_intent.succeeded":
h.handlePaymentSuccess(event)
case "payment_intent.payment_failed":
h.handlePaymentFailed(event)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}
func (h *PaymentHandler) handleCheckoutComplete(event map[string]interface{}) {
// Extract session data and update job payment status
data, _ := event["data"].(map[string]interface{})
obj, _ := data["object"].(map[string]interface{})
sessionID, _ := obj["id"].(string)
metadata, _ := obj["metadata"].(map[string]interface{})
jobIDStr, _ := metadata["job_id"].(string)
if jobIDStr != "" && sessionID != "" {
// TODO: Update job_payments table to mark as completed
fmt.Printf("Payment completed for job %s, session %s\n", jobIDStr, sessionID)
}
}
func (h *PaymentHandler) handlePaymentSuccess(event map[string]interface{}) {
// Payment succeeded
fmt.Println("Payment succeeded")
}
func (h *PaymentHandler) handlePaymentFailed(event map[string]interface{}) {
// Payment failed
fmt.Println("Payment failed")
}
// GetPaymentStatus returns the status of a payment
// @Summary Get payment status
// @Description Get the status of a job posting payment
// @Tags Payments
// @Produce json
// @Param id path string true "Payment ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {string} string "Not Found"
// @Router /api/v1/payments/status/{id} [get]
func (h *PaymentHandler) GetPaymentStatus(w http.ResponseWriter, r *http.Request) {
paymentID := r.PathValue("id")
if paymentID == "" {
http.Error(w, "Payment ID is required", http.StatusBadRequest)
return
}
// TODO: Query job_payments table for status
response := map[string]interface{}{
"id": paymentID,
"status": "pending",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Helper function to create Stripe checkout session via API
func createStripeCheckoutSession(secretKey string, req CreateCheckoutRequest) (string, string, error) {
client := &http.Client{}
// Build form data
data := fmt.Sprintf(
"mode=payment&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1&metadata[job_id]=%d",
req.SuccessURL, req.CancelURL, req.PriceID, req.JobID,
)
httpReq, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions",
io.NopCloser(io.Reader(nil)))
if err != nil {
return "", "", err
}
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Body = io.NopCloser(strings.NewReader(data))
resp, err := client.Do(httpReq)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("Stripe API error: %s", string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
sessionID, _ := result["id"].(string)
checkoutURL, _ := result["url"].(string)
return sessionID, checkoutURL, nil
}
// Verify Stripe webhook signature
func verifyStripeSignature(payload []byte, header, secret string) bool {
if header == "" {
return false
}
// Parse signature header
var timestamp string
var signatures []string
parts := splitHeader(header)
for _, p := range parts {
if len(p) > 2 && p[0] == 't' && p[1] == '=' {
timestamp = p[2:]
} else if len(p) > 3 && p[0] == 'v' && p[1] == '1' && p[2] == '=' {
signatures = append(signatures, p[3:])
}
}
if timestamp == "" || len(signatures) == 0 {
return false
}
// Check timestamp (5 min tolerance)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if time.Now().Unix()-ts > 300 {
return false
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Compare signatures
for _, sig := range signatures {
if hmac.Equal([]byte(sig), []byte(expectedSig)) {
return true
}
}
return false
}
func splitHeader(header string) []string {
return strings.Split(header, ",")
}