Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
318 lines
9.0 KiB
Go
318 lines
9.0 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/breakpilot/billing-service/internal/models"
|
|
"github.com/google/uuid"
|
|
"github.com/stripe/stripe-go/v76"
|
|
"github.com/stripe/stripe-go/v76/billingportal/session"
|
|
checkoutsession "github.com/stripe/stripe-go/v76/checkout/session"
|
|
"github.com/stripe/stripe-go/v76/customer"
|
|
"github.com/stripe/stripe-go/v76/price"
|
|
"github.com/stripe/stripe-go/v76/product"
|
|
"github.com/stripe/stripe-go/v76/subscription"
|
|
)
|
|
|
|
// StripeService handles Stripe API interactions
|
|
type StripeService struct {
|
|
secretKey string
|
|
webhookSecret string
|
|
successURL string
|
|
cancelURL string
|
|
trialPeriodDays int64
|
|
subService *SubscriptionService
|
|
mockMode bool // If true, don't make real Stripe API calls
|
|
}
|
|
|
|
// NewStripeService creates a new StripeService
|
|
func NewStripeService(secretKey, webhookSecret, successURL, cancelURL string, trialPeriodDays int, subService *SubscriptionService) *StripeService {
|
|
// Initialize Stripe with the secret key (only if not empty)
|
|
if secretKey != "" {
|
|
stripe.Key = secretKey
|
|
}
|
|
|
|
return &StripeService{
|
|
secretKey: secretKey,
|
|
webhookSecret: webhookSecret,
|
|
successURL: successURL,
|
|
cancelURL: cancelURL,
|
|
trialPeriodDays: int64(trialPeriodDays),
|
|
subService: subService,
|
|
mockMode: false,
|
|
}
|
|
}
|
|
|
|
// NewMockStripeService creates a mock StripeService for development
|
|
func NewMockStripeService(successURL, cancelURL string, trialPeriodDays int, subService *SubscriptionService) *StripeService {
|
|
return &StripeService{
|
|
secretKey: "",
|
|
webhookSecret: "",
|
|
successURL: successURL,
|
|
cancelURL: cancelURL,
|
|
trialPeriodDays: int64(trialPeriodDays),
|
|
subService: subService,
|
|
mockMode: true,
|
|
}
|
|
}
|
|
|
|
// IsMockMode returns true if running in mock mode
|
|
func (s *StripeService) IsMockMode() bool {
|
|
return s.mockMode
|
|
}
|
|
|
|
// CreateCheckoutSession creates a Stripe Checkout session for trial start
|
|
func (s *StripeService) CreateCheckoutSession(ctx context.Context, userID uuid.UUID, email string, planID models.PlanID) (string, string, error) {
|
|
// Mock mode: return a fake URL for development
|
|
if s.mockMode {
|
|
mockSessionID := fmt.Sprintf("mock_cs_%s", uuid.New().String()[:8])
|
|
mockURL := fmt.Sprintf("%s?session_id=%s&mock=true&plan=%s", s.successURL, mockSessionID, planID)
|
|
return mockURL, mockSessionID, nil
|
|
}
|
|
|
|
// Get plan details
|
|
plan, err := s.subService.GetPlanByID(ctx, string(planID))
|
|
if err != nil || plan == nil {
|
|
return "", "", fmt.Errorf("plan not found: %s", planID)
|
|
}
|
|
|
|
// Ensure we have a Stripe price ID
|
|
if plan.StripePriceID == "" {
|
|
// Create product and price in Stripe if not exists
|
|
priceID, err := s.ensurePriceExists(ctx, plan)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create stripe price: %w", err)
|
|
}
|
|
plan.StripePriceID = priceID
|
|
}
|
|
|
|
// Create checkout session parameters
|
|
params := &stripe.CheckoutSessionParams{
|
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
{
|
|
Price: stripe.String(plan.StripePriceID),
|
|
Quantity: stripe.Int64(1),
|
|
},
|
|
},
|
|
SuccessURL: stripe.String(s.successURL + "?session_id={CHECKOUT_SESSION_ID}"),
|
|
CancelURL: stripe.String(s.cancelURL),
|
|
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
|
TrialPeriodDays: stripe.Int64(s.trialPeriodDays),
|
|
Metadata: map[string]string{
|
|
"user_id": userID.String(),
|
|
"plan_id": string(planID),
|
|
},
|
|
},
|
|
PaymentMethodCollection: stripe.String(string(stripe.CheckoutSessionPaymentMethodCollectionAlways)),
|
|
Metadata: map[string]string{
|
|
"user_id": userID.String(),
|
|
"plan_id": string(planID),
|
|
},
|
|
}
|
|
|
|
// Set customer email if provided
|
|
if email != "" {
|
|
params.CustomerEmail = stripe.String(email)
|
|
}
|
|
|
|
// Create the session
|
|
sess, err := checkoutsession.New(params)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to create checkout session: %w", err)
|
|
}
|
|
|
|
return sess.URL, sess.ID, nil
|
|
}
|
|
|
|
// ensurePriceExists creates a Stripe product and price if they don't exist
|
|
func (s *StripeService) ensurePriceExists(ctx context.Context, plan *models.BillingPlan) (string, error) {
|
|
// Create product
|
|
productParams := &stripe.ProductParams{
|
|
Name: stripe.String(plan.Name),
|
|
Description: stripe.String(plan.Description),
|
|
Metadata: map[string]string{
|
|
"plan_id": string(plan.ID),
|
|
},
|
|
}
|
|
|
|
prod, err := product.New(productParams)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create product: %w", err)
|
|
}
|
|
|
|
// Create price
|
|
priceParams := &stripe.PriceParams{
|
|
Product: stripe.String(prod.ID),
|
|
UnitAmount: stripe.Int64(int64(plan.PriceCents)),
|
|
Currency: stripe.String(plan.Currency),
|
|
Recurring: &stripe.PriceRecurringParams{
|
|
Interval: stripe.String(plan.Interval),
|
|
},
|
|
Metadata: map[string]string{
|
|
"plan_id": string(plan.ID),
|
|
},
|
|
}
|
|
|
|
pr, err := price.New(priceParams)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create price: %w", err)
|
|
}
|
|
|
|
// Update plan with Stripe IDs
|
|
if err := s.subService.UpdatePlanStripePriceID(ctx, string(plan.ID), pr.ID, prod.ID); err != nil {
|
|
// Log but don't fail
|
|
fmt.Printf("Warning: Failed to update plan with Stripe IDs: %v\n", err)
|
|
}
|
|
|
|
return pr.ID, nil
|
|
}
|
|
|
|
// GetOrCreateCustomer gets or creates a Stripe customer for a user
|
|
func (s *StripeService) GetOrCreateCustomer(ctx context.Context, email, name string, userID uuid.UUID) (string, error) {
|
|
// Search for existing customer
|
|
params := &stripe.CustomerSearchParams{
|
|
SearchParams: stripe.SearchParams{
|
|
Query: fmt.Sprintf("email:'%s'", email),
|
|
},
|
|
}
|
|
|
|
iter := customer.Search(params)
|
|
for iter.Next() {
|
|
cust := iter.Customer()
|
|
// Check if this customer belongs to our user
|
|
if cust.Metadata["user_id"] == userID.String() {
|
|
return cust.ID, nil
|
|
}
|
|
}
|
|
|
|
// Create new customer
|
|
customerParams := &stripe.CustomerParams{
|
|
Email: stripe.String(email),
|
|
Name: stripe.String(name),
|
|
Metadata: map[string]string{
|
|
"user_id": userID.String(),
|
|
},
|
|
}
|
|
|
|
cust, err := customer.New(customerParams)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create customer: %w", err)
|
|
}
|
|
|
|
return cust.ID, nil
|
|
}
|
|
|
|
// ChangePlan changes a subscription to a new plan
|
|
func (s *StripeService) ChangePlan(ctx context.Context, stripeSubID string, newPlanID models.PlanID) error {
|
|
// Mock mode: just return success
|
|
if s.mockMode {
|
|
return nil
|
|
}
|
|
|
|
// Get new plan details
|
|
plan, err := s.subService.GetPlanByID(ctx, string(newPlanID))
|
|
if err != nil || plan == nil {
|
|
return fmt.Errorf("plan not found: %s", newPlanID)
|
|
}
|
|
|
|
if plan.StripePriceID == "" {
|
|
return fmt.Errorf("plan %s has no Stripe price ID", newPlanID)
|
|
}
|
|
|
|
// Get current subscription
|
|
sub, err := subscription.Get(stripeSubID, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get subscription: %w", err)
|
|
}
|
|
|
|
// Update subscription with new price
|
|
params := &stripe.SubscriptionParams{
|
|
Items: []*stripe.SubscriptionItemsParams{
|
|
{
|
|
ID: stripe.String(sub.Items.Data[0].ID),
|
|
Price: stripe.String(plan.StripePriceID),
|
|
},
|
|
},
|
|
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
|
Metadata: map[string]string{
|
|
"plan_id": string(newPlanID),
|
|
},
|
|
}
|
|
|
|
_, err = subscription.Update(stripeSubID, params)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update subscription: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CancelSubscription cancels a subscription at period end
|
|
func (s *StripeService) CancelSubscription(ctx context.Context, stripeSubID string) error {
|
|
// Mock mode: just return success
|
|
if s.mockMode {
|
|
return nil
|
|
}
|
|
|
|
params := &stripe.SubscriptionParams{
|
|
CancelAtPeriodEnd: stripe.Bool(true),
|
|
}
|
|
|
|
_, err := subscription.Update(stripeSubID, params)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to cancel subscription: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReactivateSubscription removes the cancel_at_period_end flag
|
|
func (s *StripeService) ReactivateSubscription(ctx context.Context, stripeSubID string) error {
|
|
// Mock mode: just return success
|
|
if s.mockMode {
|
|
return nil
|
|
}
|
|
|
|
params := &stripe.SubscriptionParams{
|
|
CancelAtPeriodEnd: stripe.Bool(false),
|
|
}
|
|
|
|
_, err := subscription.Update(stripeSubID, params)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reactivate subscription: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateCustomerPortalSession creates a Stripe Customer Portal session
|
|
func (s *StripeService) CreateCustomerPortalSession(ctx context.Context, customerID string) (string, error) {
|
|
// Mock mode: return a mock URL
|
|
if s.mockMode {
|
|
return fmt.Sprintf("%s?mock_portal=true", s.successURL), nil
|
|
}
|
|
|
|
params := &stripe.BillingPortalSessionParams{
|
|
Customer: stripe.String(customerID),
|
|
ReturnURL: stripe.String(s.successURL),
|
|
}
|
|
|
|
sess, err := session.New(params)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create portal session: %w", err)
|
|
}
|
|
|
|
return sess.URL, nil
|
|
}
|
|
|
|
// GetSubscription retrieves a subscription from Stripe
|
|
func (s *StripeService) GetSubscription(ctx context.Context, stripeSubID string) (*stripe.Subscription, error) {
|
|
sub, err := subscription.Get(stripeSubID, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get subscription: %w", err)
|
|
}
|
|
return sub, nil
|
|
}
|