gohorsejobs/backend/internal/services/subscription_service.go
Tiago Yamamoto 6fbd1f5ffc feat: implement full auth system with HTTPOnly cookies + JWT, fix migrations to UUID v7, remove mock data from frontend
Backend:
- Fix migrations 037-041 to use UUID v7 (uuid_generate_v7)
- Fix CORS defaults to include localhost:8963
- Fix FRONTEND_URL default to localhost:8963
- Update superadmin password hash with pepper
- Add PASSWORD_PEPPER environment variable

Frontend:
- Replace mockJobs with real API calls in home page
- Replace mockNotifications with notificationsApi in context
- Replace mockApplications with applicationsApi in dashboard
- Fix register/user page to call real registerCandidate API
- Fix hardcoded values in backoffice and messages pages

Auth:
- Support both HTTPOnly cookie and Bearer token authentication
- Login returns token + sets HTTPOnly cookie
- Logout clears HTTPOnly cookie
- Token valid for 24h
2026-02-16 05:20:46 -06:00

165 lines
4.7 KiB
Go

package services
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/checkout/session"
"github.com/stripe/stripe-go/v76/webhook"
)
type SubscriptionService struct {
DB *sql.DB
}
func NewSubscriptionService(db *sql.DB) *SubscriptionService {
// Initialize Stripe
stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
return &SubscriptionService{DB: db}
}
// CreateCheckoutSession создает сессию checkout для подписки
func (s *SubscriptionService) CreateCheckoutSession(companyID int, planID string, userEmail string) (string, error) {
// Define price ID based on plan
var priceID string
switch planID {
case "professional":
priceID = os.Getenv("STRIPE_PRICE_PROFESSIONAL")
case "enterprise":
priceID = os.Getenv("STRIPE_PRICE_ENTERPRISE")
default: // starter
priceID = os.Getenv("STRIPE_PRICE_STARTER")
}
if priceID == "" {
// Fallback for demo/development if envs are missing
// In production this should error out
if planID == "starter" {
return "", errors.New("starter plan is free")
}
return "", fmt.Errorf("price id not configured for plan %s", planID)
}
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:8963"
}
params := &stripe.CheckoutSessionParams{
CustomerEmail: stripe.String(userEmail),
PaymentMethodTypes: stripe.StringSlice([]string{
"card",
}),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
SuccessURL: stripe.String(frontendURL + "/dashboard?payment=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(frontendURL + "/dashboard?payment=cancelled"),
Metadata: map[string]string{
"company_id": fmt.Sprintf("%d", companyID),
"plan_id": planID,
},
}
// Add Pix if configured (usually requires BRL currency and specialized setup)
// checking if we should add it dynamically or just rely on Stripe Dashboard settings
// For API 2022-11-15+ payment_method_types is often inferred from dashboard
// but adding it explicitly if we want to force it.
// Note: Pix for subscriptions might have limitations.
// Standard approach: use "card" and "boleto" or others if supported by the price currency (BRL).
// If we want to support Pix, we might need to check if it's a one-time payment or subscription.
// Recurring Pix is not fully standard in Stripe Checkout yet for all regions.
// Let's stick generic for now and user can enable methods in Dashboard.
sess, err := session.New(params)
if err != nil {
return "", err
}
return sess.URL, nil
}
// HandleWebhook processes Stripe events
func (s *SubscriptionService) HandleWebhook(payload []byte, signature string) error {
endpointSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
event, err := webhook.ConstructEvent(payload, signature, endpointSecret)
if err != nil {
return err
}
switch event.Type {
case "checkout.session.completed":
var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
return err
}
// Extract company ID from metadata
companyIDStr := session.Metadata["company_id"]
planID := session.Metadata["plan_id"]
if companyIDStr != "" && planID != "" {
// Update company status
_, err := s.DB.Exec(`
UPDATE companies
SET stripe_customer_id = $1,
subscription_plan = $2,
subscription_status = 'active',
updated_at = NOW()
WHERE id = $3
`, session.Customer.ID, planID, companyIDStr)
if err != nil {
log.Printf("Error updating company subscription: %v", err)
return err
}
}
case "invoice.payment_succeeded":
var invoice stripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
return err
}
if invoice.Subscription != nil {
// Maintain active status
// In a more complex system, we'd lookup company by stripe_customer_id
_, err := s.DB.Exec(`
UPDATE companies
SET subscription_status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1
`, invoice.Customer.ID)
if err != nil {
log.Printf("Error updating subscription status: %v", err)
}
}
case "invoice.payment_failed":
var invoice stripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
return err
}
if invoice.Subscription != nil {
// Mark as past_due or canceled
_, err := s.DB.Exec(`
UPDATE companies
SET subscription_status = 'past_due', updated_at = NOW()
WHERE stripe_customer_id = $1
`, invoice.Customer.ID)
if err != nil {
log.Printf("Error updating subscription status: %v", err)
}
}
}
return nil
}