- Backend: - Add Stripe subscription fields to companies (migration 019) - Implement Stripe Checkout and Webhook handlers - Add Metrics API (view count, recording) - Update Company and Job models - Frontend: - Add Google Analytics component - Implement User CRUD in Backoffice (Dashboard) - Add 'Featured' badge to JobCard - Docs: Update Roadmap and artifacts
165 lines
4.7 KiB
Go
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:3000"
|
|
}
|
|
|
|
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
|
|
}
|