gohorsejobs/backend/internal/handlers/payment_handler.go
Tiago Yamamoto 38a94bcbce feat: implement high priority features
1. Advanced Search (backend)
   - Add salaryMin, salaryMax, currency, sortBy to JobFilterQuery
   - Add 5+ filters: visa, salary range, currency, language level
   - Add 4 sort options: recent, salary_asc, salary_desc, relevance

2. Email Service (backend)
   - Create Resend API integration (email_service.go)
   - 3 HTML email templates: welcome, password_reset, application_received
   - Add RESEND_API_KEY, EMAIL_FROM, APP_URL env vars

3. i18n (frontend)
   - Create 4 language files: pt-BR, en-US, es-ES, ja-JP
   - 100+ translation keys per language
   - Covers: common, nav, auth, jobs, profile, company, footer

4. Stripe Integration (backend)
   - Create payment_handler.go with checkout session creation
   - Webhook handler with signature verification
   - Support for checkout.session.completed, payment_intent events
2025-12-24 11:40:53 -03:00

290 lines
8 KiB
Go

package handlers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
// PaymentHandler handles Stripe payment operations
type PaymentHandler struct {
jobService *services.JobService
}
// NewPaymentHandler creates a new payment handler
func NewPaymentHandler(jobService *services.JobService) *PaymentHandler {
return &PaymentHandler{jobService: jobService}
}
// 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"`
}
// 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 secret key
stripeSecretKey := os.Getenv("STRIPE_SECRET_KEY")
if stripeSecretKey == "" {
http.Error(w, "Payment service not configured", http.StatusInternalServerError)
return
}
// Create Stripe checkout session via API
sessionID, checkoutURL, err := createStripeCheckoutSession(stripeSecretKey, 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) {
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
if 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, 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, ",")
}