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(_ map[string]interface{}) { // Payment succeeded fmt.Println("Payment succeeded") } func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) { // Payment failed fmt.Println("Payment failed") } // SubscriptionCheckoutRequest represents a subscription checkout session request type SubscriptionCheckoutRequest struct { PriceID string `json:"priceId"` // Stripe Price ID for the plan SuccessURL string `json:"successUrl"` // Redirect URL after payment success CancelURL string `json:"cancelUrl"` // Redirect URL if payment is cancelled } // SubscriptionCheckout creates a Stripe checkout session for a subscription plan // @Summary Create subscription checkout session // @Description Create a Stripe checkout session for a subscription plan (no job required) // @Tags Payments // @Accept json // @Produce json // @Security BearerAuth // @Param request body SubscriptionCheckoutRequest true "Subscription checkout request" // @Success 200 {object} CreateCheckoutResponse // @Failure 400 {string} string "Bad Request" // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/payments/subscription-checkout [post] func (h *PaymentHandler) SubscriptionCheckout(w http.ResponseWriter, r *http.Request) { var req SubscriptionCheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.PriceID == "" { http.Error(w, "priceId is 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) } if config.SecretKey == "" { config.SecretKey = os.Getenv("STRIPE_SECRET_KEY") } if config.SecretKey == "" { http.Error(w, "Payment service not configured", http.StatusInternalServerError) return } successURL := req.SuccessURL if successURL == "" { frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "http://localhost:3000" } successURL = frontendURL + "/dashboard?payment=success" } cancelURL := req.CancelURL if cancelURL == "" { frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "http://localhost:3000" } cancelURL = frontendURL + "/register?payment=cancelled" } // Build Stripe checkout session (subscription mode) sessionID, checkoutURL, err := createStripeSubscriptionCheckout(config.SecretKey, req.PriceID, successURL, cancelURL) if err != nil { http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CreateCheckoutResponse{ SessionID: sessionID, CheckoutURL: checkoutURL, }) } // createStripeSubscriptionCheckout creates a Stripe subscription checkout session func createStripeSubscriptionCheckout(secretKey, priceID, successURL, cancelURL string) (string, string, error) { data := fmt.Sprintf( "mode=subscription&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1", successURL, cancelURL, priceID, ) req, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions", strings.NewReader(data)) if err != nil { return "", "", err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := (&http.Client{}).Do(req) 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 } // 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, ",") }