瀏覽代碼

Add trials logic

Mauricio Araujo 2 年之前
父節點
當前提交
3be49056ec

+ 1 - 9
api/server/handlers/billing/invoices.go

@@ -1,7 +1,6 @@
 package billing
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -54,13 +53,6 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	invoices, err := c.Config().BillingManager.StripeClient.ListCustomerInvoices(ctx, proj.BillingID, req.Status)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, fmt.Sprintf("error listing invoices for customer %s", proj.BillingID))
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
 	// Write the response to the frontend
-	c.WriteResult(w, r, invoices)
+	c.WriteResult(w, r, "invoices")
 }

+ 6 - 5
api/server/shared/config/env/envconfs.go

@@ -69,11 +69,12 @@ type ServerConf struct {
 	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
-	StripeSecretKey      string `env:"STRIPE_SECRET_KEY"`
-	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
-	LagoAPIKey           string `env:"LAGO_API_KEY"`
-	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
-	PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"`
+	StripeSecretKey        string `env:"STRIPE_SECRET_KEY"`
+	StripePublishableKey   string `env:"STRIPE_PUBLISHABLE_KEY"`
+	LagoAPIKey             string `env:"LAGO_API_KEY"`
+	PorterCloudPlanCode    string `env:"PORTER_CLOUD_PLAN_CODE"`
+	PorterStandardPlanCode string `env:"PORTER_STANDARD_PLAN_CODE"`
+	PorterTrialCode        string `env:"PORTER_TRIAL_CODE"`
 
 	// The URL of the webhook to verify ingesting events works
 	IngestStatusWebhookUrl string `env:"INGEST_STATUS_WEBHOOK_URL"`

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

@@ -371,15 +371,15 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled")
 	}
 
-	if sc.LagoAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" {
-		lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID)
+	if sc.LagoAPIKey != "" && sc.PorterCloudPlanCode != "" && sc.PorterStandardPlanCode != "" && sc.PorterTrialCode != "" {
+		lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanCode, InstanceEnvConf.ServerConf.PorterStandardPlanCode, InstanceEnvConf.ServerConf.PorterTrialCode)
 		if err != nil {
 			return nil, fmt.Errorf("unable to create Lago client: %w", err)
 		}
 		metronomeEnabled = true
 		res.Logger.Info().Msg("Lago configuration loaded")
 	} else {
-		res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled")
+		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")
 	}
 
 	res.Logger.Info().Msg("Creating billing manager")

+ 0 - 39
internal/billing/stripe.go

@@ -4,13 +4,11 @@ import (
 	"context"
 	"fmt"
 	"strconv"
-	"time"
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/stripe/stripe-go/v76"
 	"github.com/stripe/stripe-go/v76/customer"
-	"github.com/stripe/stripe-go/v76/invoice"
 	"github.com/stripe/stripe-go/v76/paymentmethod"
 	"github.com/stripe/stripe-go/v76/setupintent"
 )
@@ -245,43 +243,6 @@ func (s StripeClient) GetPublishableKey(ctx context.Context) (key string) {
 	return s.PublishableKey
 }
 
-// ListCustomerInvoices will return all invoices for the customer with the given status
-func (s StripeClient) ListCustomerInvoices(ctx context.Context, customerID string, status string) (invoiceList []types.Invoice, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls")
-	defer span.End()
-
-	if customerID == "" {
-		return invoiceList, telemetry.Error(ctx, span, err, "customer id cannot be empty")
-	}
-
-	stripe.Key = s.SecretKey
-
-	params := &stripe.InvoiceListParams{
-		Customer: stripe.String(customerID),
-		Status:   stripe.String(status),
-	}
-
-	result := invoice.List(params)
-
-	for result.Next() {
-		invoice := result.Current().(*stripe.Invoice)
-
-		if invoice == nil {
-			continue
-		}
-
-		createdTimestamp := time.Unix(invoice.Created, 0)
-
-		invoiceList = append(invoiceList, types.Invoice{
-			HostedInvoiceURL: invoice.HostedInvoiceURL,
-			Status:           string(invoice.Status),
-			Created:          createdTimestamp.Format(time.RFC3339),
-		})
-	}
-
-	return invoiceList, nil
-}
-
 func (s StripeClient) checkDefaultPaymentMethod(customerID string) (defaultPaymentExists bool, defaultPaymentID string, err error) {
 	// Get customer to check default payment method
 	customer, err := customer.Get(customerID, nil)

+ 66 - 32
internal/billing/usage.go

@@ -17,10 +17,15 @@ const (
 	defaultMaxRetries        = 10
 	maxIngestEventLimit      = 100
 
+	// porterStandardTrialDays is the number of days for the trial
+	porterStandardTrialDays = 15
+
 	// These prefixes are used to build the customer and subscription IDs
 	// in Lago. This way we can reuse the project IDs instead of storing
 	// the Lago IDs in the database.
 
+	// TrialIDPrefix is the prefix for the trial ID
+	TrialIDPrefix = "trial"
 	// SubscriptionIDPrefix is the prefix for the subscription ID
 	SubscriptionIDPrefix = "sub"
 	// CustomerIDPrefix is the prefix for the customer ID
@@ -29,9 +34,10 @@ const (
 
 // LagoClient is the client used to call the Lago API
 type LagoClient struct {
-	client               lago.Client
-	PorterCloudPlanID    string
-	PorterStandardPlanID string
+	client                 lago.Client
+	PorterCloudPlanCode    string
+	PorterStandardPlanCode string
+	PorterTrialCode        string
 
 	// DefaultRewardAmountCents is the default amount in USD cents rewarded to users
 	// who successfully refer a new user
@@ -40,48 +46,65 @@ type LagoClient struct {
 	MaxReferralRewards int64
 }
 
-// NewLagoClient returns a new Metronome client
-func NewLagoClient(lagoApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client LagoClient, err error) {
+// 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__")
 
 	return LagoClient{
 		client:                   *lagoClient,
-		PorterCloudPlanID:        porterCloudPlanID,
-		PorterStandardPlanID:     porterStandardPlanID,
+		PorterCloudPlanCode:      porterCloudPlanCode,
+		PorterStandardPlanCode:   porterStandardPlanCode,
+		PorterTrialCode:          porterTrialCode,
 		DefaultRewardAmountCents: defaultRewardAmountCents,
 		MaxReferralRewards:       maxReferralRewards,
 	}, nil
 }
 
-// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
+// CreateCustomerWithPlan will create the customer in Lago and immediately add it to the plan
 func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
+	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
 	defer span.End()
 
-	planID := m.PorterStandardPlanID
+	customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error while creating customer")
+	}
+
+	trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled)
+	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
+	now := time.Now()
+	trialEndTime := now.Add(time.Hour * 24 * porterStandardTrialDays)
+
 	if sandboxEnabled {
-		planID = m.PorterCloudPlanID
+		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))
+		}
+		return nil
 	}
 
-	customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
+	// First, start the new customer on the trial
+	err = m.addCustomerPlan(ctx, customerID, m.PorterTrialCode, trialID, &now, &trialEndTime)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID))
+		return telemetry.Error(ctx, span, err, fmt.Sprintf("error while starting customer trial %s", m.PorterTrialCode))
 	}
 
-	subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
-
-	err = m.addCustomerPlan(ctx, customerID, planID, subscriptionID)
+	// Then, add the customer to the actual plan. The date of the subscription will be the end of the trial
+	err = m.addCustomerPlan(ctx, customerID, m.PorterStandardPlanCode, subscriptionID, &trialEndTime, nil)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterStandardPlanCode))
+	}
 
 	return err
 }
 
-// createCustomer will create the customer in Metronome
+// 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-metronome-customer")
+	ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
 	defer span.End()
 
-	customerID = m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
 
 	customerInput := &lago.CustomerInput{
 		ExternalID: customerID,
@@ -101,20 +124,20 @@ func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projec
 }
 
 // addCustomerPlan will create a plan subscription for the customer
-func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
+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")
 	defer span.End()
 
 	if projectID == "" || planID == "" {
 		return telemetry.Error(ctx, span, err, "project and plan id are required")
 	}
 
-	now := time.Now()
 	subscriptionInput := &lago.SubscriptionInput{
 		ExternalCustomerID: projectID,
 		ExternalID:         subscriptionID,
 		PlanCode:           planID,
-		SubscriptionAt:     &now,
+		SubscriptionAt:     startingAt,
+		EndingAt:           endingAt,
 		BillingTime:        lago.Calendar,
 	}
 
@@ -135,7 +158,7 @@ func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandbo
 		return plan, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
+	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")
@@ -150,14 +173,14 @@ func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandbo
 
 // EndCustomerPlan will immediately end the plan for the given customer
 func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
+	ctx, span := telemetry.NewSpan(ctx, "end-lago-customer-plan")
 	defer span.End()
 
 	if projectID == 0 {
 		return telemetry.Error(ctx, span, err, "subscription id empty")
 	}
 
-	subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, false)
+	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, false)
 	subscriptionTerminateInput := lago.SubscriptionTerminateInput{
 		ExternalID: subscriptionID,
 	}
@@ -206,7 +229,7 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 		return telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	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")
@@ -238,12 +261,12 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre
 		return usage, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
+	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
 	customerUsageInput := &lago.CustomerUsageInput{
 		ExternalSubscriptionID: subscriptionID,
 	}
 
-	customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	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")
@@ -252,7 +275,7 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre
 	return usage, nil
 }
 
-// IngestEvents sends a list of billing events to Metronome's ingest endpoint
+// 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) {
 	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
 	defer span.End()
@@ -275,7 +298,7 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven
 			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,
@@ -302,7 +325,18 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven
 	return nil
 }
 
-func (m LagoClient) GenerateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
+// ListCustomerInvoices will return all invoices for the customer with the given status
+func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint) (invoiceList []types.Invoice, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls")
+	defer span.End()
+
+	if projectID == 0 {
+		return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty")
+	}
+	return invoiceList, nil
+}
+
+func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
 	if sandboxEnabled {
 		return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
 	}

+ 1 - 5
internal/models/project.go

@@ -146,8 +146,7 @@ type Project struct {
 	Roles []Role `json:"roles"`
 
 	// BillingID corresponds to the id generated by the billing provider
-	BillingID      string
-	BillingEnabled bool
+	BillingID string
 
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
@@ -243,8 +242,6 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return p.AzureEnabled
 		case "capi_provisioner_enabled":
 			return p.CapiProvisionerEnabled
-		case "billing_enabled":
-			return p.BillingEnabled
 		case "db_enabled":
 			return false
 		case "enable_reprovision":
@@ -357,7 +354,6 @@ func (p *Project) ToProjectListType() *types.ProjectList {
 		// note: all of these fields should be considered deprecated
 		// in an api response
 		Roles:                  roles,
-		BillingEnabled:         p.BillingEnabled,
 		PreviewEnvsEnabled:     p.PreviewEnvsEnabled,
 		RDSDatabasesEnabled:    p.RDSDatabasesEnabled,
 		ManagedInfraEnabled:    p.ManagedInfraEnabled,

+ 9 - 6
zarf/helm/.serverenv

@@ -73,14 +73,17 @@ STRIPE_SECRET_KEY=
 # STRIPE_PUBLISHABLE_KEY is used in the frontend to create Stripe Web Elements
 STRIPE_PUBLISHABLE_KEY=
 
-# METRONOME_API_KEY is used to create customers in Metronome. If empty all functionality will be disabled.
-METRONOME_API_KEY=
+# LAGO_API_KEY is used to create customers in Lago. If empty all functionality will be disabled.
+LAGO_API_KEY=
 
-# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Metronome. Only used if METRONOME_API_KEY is set
-PORTER_CLOUD_PLAN_ID=
+# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_CLOUD_PLAN_CODE=
 
-# PORTER_STANDARD_PLAN_ID is the id of the standard plan in Metronome. Only used if METRONOME_API_KEY is set
-PORTER_STANDARD_PLAN_ID=
+# PORTER_STANDARD_PLAN_CODE is the id of the standard plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_STANDARD_PLAN_CODE=
+
+# PORTER_TRIAL_CODE is the id of the trial plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_TRIAL_CODE=
 
 # UPSTASH_ENABLED is used to enable the Upstash integration
 UPSTASH_ENABLED=false