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
290 lines
8 KiB
Go
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, ",")
|
|
}
|