Initial commit: breakpilot-core - Shared Infrastructure
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>
This commit is contained in:
317
billing-service/internal/services/stripe_service.go
Normal file
317
billing-service/internal/services/stripe_service.go
Normal file
@@ -0,0 +1,317 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user