Przeglądaj źródła

Fix customer creation, add method for getting active subscription

Mauricio Araujo 2 lat temu
rodzic
commit
7a06be0a41

+ 2 - 2
api/server/handlers/billing/create.go

@@ -149,10 +149,10 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr
 	if referral != nil && referral.Status != models.ReferralStatusCompleted {
 		// Metronome requires an expiration to be passed in, so we set it to 5 years which in
 		// practice will mean the credits will most likely run out before expiring
-		expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339)
+		expiresAt := time.Now().AddDate(5, 0, 0)
 		name := "Referral reward"
 		rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents
-		err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, expiresAt, referrerProject.EnableSandbox)
+		err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, &expiresAt, referrerProject.EnableSandbox)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "failed to grand credits reward")
 		}

+ 16 - 5
api/server/handlers/billing/ingest.go

@@ -39,19 +39,19 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 
 		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+			telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+			telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 			telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
 		)
 		return
 	}
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: true},
 	)
 
 	ingestEventsRequest := struct {
@@ -75,7 +75,18 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	err := c.Config().BillingManager.LagoClient.IngestEvents(ctx, ingestEventsRequest.Events, proj.EnableSandbox)
+	subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID},
+	)
+
+	err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, subscriptionID, ingestEventsRequest.Events, proj.EnableSandbox)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error ingesting events")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 20 - 6
api/server/handlers/billing/list.go

@@ -127,9 +127,9 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro
 		}
 
 		// Create usage customer for project and set the usage ID if it doesn't exist
-		err = c.ensureMetronomeCustomerExists(ctx, adminUser.Email, proj)
+		err = c.ensureLagoCustomerExists(ctx, adminUser.Email, proj)
 		if err != nil {
-			return telemetry.Error(ctx, span, err, "error ensuring Metronome customer exists")
+			return telemetry.Error(ctx, span, err, "error ensuring Lago customer exists")
 		}
 	}
 
@@ -195,17 +195,31 @@ func (c *CheckPaymentEnabledHandler) ensureStripeCustomerExists(ctx context.Cont
 	return nil
 }
 
-func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists")
+func (c *CheckPaymentEnabledHandler) ensureLagoCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ensure-lago-customer-exists")
 	defer span.End()
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		return nil
+	}
+
+	// Check if the customer already exists
+	exists, err := c.Config().BillingManager.LagoClient.CheckIfCustomerExists(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error while checking if customer exists")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "customer-exists", Value: exists},
+	)
+
+	if exists {
 		return nil
 	}
 
 	err = c.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "error creating Metronome customer")
+		return telemetry.Error(ctx, span, err, "error creating Lago customer")
 	}
 
 	return nil

+ 23 - 20
api/server/handlers/billing/plan.go

@@ -33,21 +33,28 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
+
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
+		return
+	}
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID},
 	)
 
-	plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, proj.ID, proj.EnableSandbox)
+	plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, subscriptionID)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error listing plans")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -78,13 +85,13 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
@@ -95,10 +102,6 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// 	return
 	// }
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-	)
-
 	c.WriteResult(w, r, "")
 }
 
@@ -125,11 +128,11 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 	)
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 		return
 	}

+ 2 - 2
api/server/handlers/project/create.go

@@ -100,10 +100,10 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// Create Metronome customer and add to starter plan
-	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
 		err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating Metronome customer")
+			err = telemetry.Error(ctx, span, err, "error creating usage customer")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}

+ 1 - 1
api/server/handlers/project/delete.go

@@ -92,7 +92,7 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
 		err = p.Config().BillingManager.LagoClient.EndCustomerPlan(ctx, proj.ID)
 		if err != nil {
 			e := "error ending billing plan"

+ 2 - 2
api/server/handlers/project/referrals.go

@@ -33,12 +33,12 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox {
 		c.WriteResult(w, r, "")
 
 		telemetry.WithAttributes(span,
 			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 		)
 		return
 	}

+ 6 - 6
api/server/shared/config/loader/loader.go

@@ -358,10 +358,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 
 	var (
-		stripeClient     billing.StripeClient
-		stripeEnabled    bool
-		lagoClient       billing.LagoClient
-		metronomeEnabled bool
+		stripeClient  billing.StripeClient
+		stripeEnabled bool
+		lagoClient    billing.LagoClient
+		lagoEnabled   bool
 	)
 	if sc.StripeSecretKey != "" {
 		stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
@@ -376,7 +376,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		if err != nil {
 			return nil, fmt.Errorf("unable to create Lago client: %w", err)
 		}
-		metronomeEnabled = true
+		lagoEnabled = true
 		res.Logger.Info().Msg("Lago configuration loaded")
 	} else {
 		res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_CODE, PORTER_STANDARD_PLAN_CODE and PORTER_TRIAL_CODE must be set, all Lago functionality will be disabled")
@@ -387,7 +387,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		StripeClient:       stripeClient,
 		StripeConfigLoaded: stripeEnabled,
 		LagoClient:         lagoClient,
-		LagoConfigLoaded:   metronomeEnabled,
+		LagoConfigLoaded:   lagoEnabled,
 	}
 	res.Logger.Info().Msg("Created billing manager")
 

+ 1 - 1
api/types/project.go

@@ -39,7 +39,7 @@ type Project struct {
 	BetaFeaturesEnabled             bool    `json:"beta_features_enabled"`
 	CapiProvisionerEnabled          bool    `json:"capi_provisioner_enabled"`
 	BillingEnabled                  bool    `json:"billing_enabled"`
-	MetronomeEnabled                bool    `json:"metronome_enabled"`
+	LagoEnabled                     bool    `json:"metronome_enabled"`
 	InfisicalEnabled                bool    `json:"infisical_enabled"`
 	FreezeEnabled                   bool    `json:"freeze_enabled"`
 	DBEnabled                       bool    `json:"db_enabled"`

+ 156 - 68
internal/billing/usage.go

@@ -3,6 +3,7 @@ package billing
 import (
 	"context"
 	"fmt"
+	"log"
 	"strconv"
 	"time"
 
@@ -12,10 +13,11 @@ import (
 )
 
 const (
-	defaultRewardAmountCents = 1000
-	maxReferralRewards       = 10
-	defaultMaxRetries        = 10
-	maxIngestEventLimit      = 100
+	defaultStarterCreditsCents = 500
+	defaultRewardAmountCents   = 1000
+	maxReferralRewards         = 10
+	defaultMaxRetries          = 10
+	maxIngestEventLimit        = 100
 
 	// porterStandardTrialDays is the number of days for the trial
 	porterStandardTrialDays = 15
@@ -48,8 +50,11 @@ type LagoClient struct {
 
 // NewLagoClient returns a new Lago client
 func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) {
-	lagoClient := lago.New().
-		SetApiKey("__YOU_API_KEY__")
+	lagoClient := lago.New().SetApiKey(lagoApiKey)
+
+	if lagoClient == nil {
+		return client, fmt.Errorf("failed to create lago client")
+	}
 
 	return LagoClient{
 		client:                   *lagoClient,
@@ -66,6 +71,10 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string
 	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
 	defer span.End()
 
+	if projectID == 0 {
+		return telemetry.Error(ctx, span, err, "project id empty")
+	}
+
 	customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "error while creating customer")
@@ -73,14 +82,23 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string
 
 	trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled)
 	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
-	now := time.Now()
-	trialEndTime := now.Add(time.Hour * 24 * porterStandardTrialDays)
+
+	// The dates need to be at midnight UTC
+	now := time.Now().UTC()
+	now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+	trialEndTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(time.Hour * 24 * porterStandardTrialDays).UTC()
 
 	if sandboxEnabled {
 		err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode))
 		}
+
+		starterWalletName := "Free Starter Credits"
+		expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour)
+
+		err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled)
+
 		return nil
 	}
 
@@ -99,75 +117,91 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string
 	return err
 }
 
-// createCustomer will create the customer in Lago
-func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
+func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists")
 	defer span.End()
 
-	customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
-
-	customerInput := &lago.CustomerInput{
-		ExternalID: customerID,
-		Name:       projectName,
-		Email:      userEmail,
-		BillingConfiguration: lago.CustomerBillingConfigurationInput{
-			PaymentProvider:    "stripe",
-			ProviderCustomerID: billingID,
-		},
+	if projectID == 0 {
+		return exists, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	_, lagoErr := m.client.Customer().Create(ctx, customerInput)
-	if err != nil {
-		return customerID, telemetry.Error(ctx, span, lagoErr.Err, "failed to create lago customer")
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
+	_, lagoErr := m.client.Customer().Get(ctx, customerID)
+	if lagoErr != nil {
+		return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer")
 	}
-	return customerID, nil
+
+	return true, nil
 }
 
-// addCustomerPlan will create a plan subscription for the customer
-func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
+func (m LagoClient) GetCustomeActiveSubscription(ctx context.Context, projectID uint, sandboxEnabled bool) (subscriptionID string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-active-subscription")
 	defer span.End()
 
-	if projectID == "" || planID == "" {
-		return telemetry.Error(ctx, span, err, "project and plan id are required")
+	if projectID == 0 {
+		return subscriptionID, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	subscriptionInput := &lago.SubscriptionInput{
-		ExternalCustomerID: projectID,
-		ExternalID:         subscriptionID,
-		PlanCode:           planID,
-		SubscriptionAt:     startingAt,
-		EndingAt:           endingAt,
-		BillingTime:        lago.Calendar,
+	if sandboxEnabled {
+		subscriptionID = m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
+		return subscriptionID, nil
 	}
 
-	_, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
-	if err != nil {
-		return telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription")
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	subscriptionListInput := lago.SubscriptionListInput{
+		ExternalCustomerID: customerID,
+		Status:             []string{"active"},
 	}
 
-	return nil
+	activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
+	if lagoErr != nil {
+		return subscriptionID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
+	}
+
+	if activeSubscriptions == nil {
+		return subscriptionID, telemetry.Error(ctx, span, err, "no active subscriptions found")
+	}
+
+	if len(activeSubscriptions.Subscriptions) > 0 {
+		subscriptionID = activeSubscriptions.Subscriptions[0].ExternalID
+	}
+
+	return subscriptionID, nil
 }
 
 // ListCustomerPlan will return the current active plan to which the user is subscribed
-func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) {
+func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) (plan types.Plan, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
 	defer span.End()
 
-	if projectID == 0 {
+	if subscriptionID == "" {
 		return plan, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
 	subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID)
-	if err != nil {
-		return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription")
+	if lagoErr != nil {
+		return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get subscription")
+	}
+
+	if subscription == nil {
+		return plan, nil
 	}
 
-	plan.StartingOn = subscription.StartedAt.Format(time.RFC3339)
-	plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
-	plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339)
+	log.Println("subscription", subscription)
 
+	if subscription.StartedAt != nil {
+		plan.StartingOn = subscription.StartedAt.Format(time.RFC3339)
+	}
+
+	if subscription.EndingAt != nil {
+		plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+	}
+
+	if subscription.TrialEndedAt != nil {
+		plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339)
+	}
+
+	log.Println("plan", plan)
 	return plan, nil
 }
 
@@ -186,8 +220,8 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err er
 	}
 
 	_, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput)
-	if lagoErr.Err != nil {
-		return telemetry.Error(ctx, span, lagoErr.Err, "failed to terminate subscription")
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
 	}
 
 	return nil
@@ -221,7 +255,7 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err er
 // }
 
 // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
-func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt string, sandboxEnabled bool) (err error) {
+func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
 	defer span.End()
 
@@ -230,23 +264,19 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 	}
 
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
-	expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp")
-	}
-
 	walletInput := &lago.WalletInput{
 		ExternalCustomerID: customerID,
 		Name:               name,
 		Currency:           lago.USD,
 		GrantedCredits:     strconv.FormatInt(grantAmount, 10),
-		RateAmount:         "1",
-		ExpirationAt:       &expiresAtTime,
+		// Rate is 1 credit = 1 cent
+		RateAmount:   "0.01",
+		ExpirationAt: expiresAt,
 	}
 
 	_, lagoErr := m.client.Wallet().Create(ctx, walletInput)
-	if lagoErr.Err != nil {
-		return telemetry.Error(ctx, span, lagoErr.Err, "failed to create credits grant")
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant")
 	}
 
 	return nil
@@ -268,15 +298,15 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre
 
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
 	_, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
-	if lagoErr.Err != nil {
-		return usage, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage")
+	if lagoErr != nil {
+		return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
 	}
 
 	return usage, nil
 }
 
 // IngestEvents sends a list of billing events to Lago's ingest endpoint
-func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent, enableSandbox bool) (err error) {
+func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
 	defer span.End()
 
@@ -294,11 +324,17 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven
 		batchInput := make([]lago.EventInput, len(batch))
 
 		for i := range batch {
-			customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64)
-			if err != nil {
-				return telemetry.Error(ctx, span, err, "failed to parse customer ID")
+
+			externalSubscriptionID := subscriptionID
+			if enableSandbox {
+				// This hack has to be done because we can't infer the project id from the
+				// context in Porter Cloud
+				customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64)
+				if err != nil {
+					return telemetry.Error(ctx, span, err, "failed to parse customer ID")
+				}
+				externalSubscriptionID = m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox)
 			}
-			externalSubscriptionID := m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox)
 
 			event := lago.EventInput{
 				TransactionID:          batch[i].TransactionID,
@@ -336,6 +372,58 @@ func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint)
 	return invoiceList, nil
 }
 
+// createCustomer will create the customer in Lago
+func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
+	defer span.End()
+
+	customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+
+	customerInput := &lago.CustomerInput{
+		ExternalID: customerID,
+		Name:       projectName,
+		Email:      userEmail,
+		BillingConfiguration: lago.CustomerBillingConfigurationInput{
+			PaymentProvider:    lago.PaymentProviderStripe,
+			ProviderCustomerID: billingID,
+			Sync:               false,
+			SyncWithProvider:   false,
+		},
+	}
+
+	_, lagoErr := m.client.Customer().Create(ctx, customerInput)
+	if lagoErr != nil {
+		return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
+	}
+	return customerID, nil
+}
+
+// addCustomerPlan will create a plan subscription for the customer
+func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
+	defer span.End()
+
+	if customerID == "" || planID == "" {
+		return telemetry.Error(ctx, span, err, "project and plan id are required")
+	}
+
+	subscriptionInput := &lago.SubscriptionInput{
+		ExternalCustomerID: customerID,
+		ExternalID:         subscriptionID,
+		PlanCode:           planID,
+		SubscriptionAt:     startingAt,
+		EndingAt:           endingAt,
+		BillingTime:        lago.Calendar,
+	}
+
+	_, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
+	}
+
+	return nil
+}
+
 func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
 	if sandboxEnabled {
 		return fmt.Sprintf("cloud_%s_%d", prefix, projectID)

+ 4 - 4
internal/models/project.go

@@ -28,8 +28,8 @@ const (
 	// BillingEnabled enables the "Billing" tab and all Stripe integrations
 	BillingEnabled FeatureFlagLabel = "billing_enabled"
 
-	// MetronomeEnabled enables all Metronome business logic
-	MetronomeEnabled FeatureFlagLabel = "metronome_enabled"
+	// LagoEnabled enables all Lago business logic. This is kept as "metronome_enabled" for compatibility reasons
+	LagoEnabled FeatureFlagLabel = "metronome_enabled"
 
 	// InfisicalEnabled enables the Infisical secrets operator integration
 	InfisicalEnabled FeatureFlagLabel = "infisical_enabled"
@@ -106,7 +106,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	BetaFeaturesEnabled:             false,
 	CapiProvisionerEnabled:          true,
 	BillingEnabled:                  false,
-	MetronomeEnabled:                false,
+	LagoEnabled:                     false,
 	InfisicalEnabled:                false,
 	FreezeEnabled:                   false,
 	DBEnabled:                       false,
@@ -311,7 +311,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		BetaFeaturesEnabled:             p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
 		CapiProvisionerEnabled:          p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient),
 		BillingEnabled:                  p.GetFeatureFlag(BillingEnabled, launchDarklyClient),
-		MetronomeEnabled:                p.GetFeatureFlag(MetronomeEnabled, launchDarklyClient),
+		LagoEnabled:                     p.GetFeatureFlag(LagoEnabled, launchDarklyClient),
 		InfisicalEnabled:                p.GetFeatureFlag(InfisicalEnabled, launchDarklyClient),
 		FreezeEnabled:                   p.GetFeatureFlag(FreezeEnabled, launchDarklyClient),
 		DBEnabled:                       p.GetFeatureFlag(DBEnabled, launchDarklyClient),