소스 검색

Porter Standard Plan, new billing page (#4528)

Mauricio Araujo 2 년 전
부모
커밋
b4cfaafc65

+ 0 - 59
api/server/handlers/billing/credits.go

@@ -1,59 +0,0 @@
-package billing
-
-import (
-	"net/http"
-
-	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/telemetry"
-)
-
-// GetCreditsHandler is a handler for getting available credits
-type GetCreditsHandler struct {
-	handlers.PorterHandlerWriter
-}
-
-// NewGetCreditsHandler will create a new GetCreditsHandler
-func NewGetCreditsHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *GetCreditsHandler {
-	return &GetCreditsHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
-	}
-}
-
-func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "get-credits-endpoint")
-	defer span.End()
-
-	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
-
-	if !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
-
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
-		)
-		return
-	}
-
-	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerCredits(ctx, proj.UsageID)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting credits")
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
-	c.WriteResult(w, r, credits)
-}

+ 2 - 3
api/server/handlers/billing/list.go

@@ -84,9 +84,8 @@ func (c *CheckPaymentEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		)
 	}
 
-	if c.Config().ServerConf.MetronomeAPIKey != "" && c.Config().ServerConf.PorterCloudPlanID != "" &&
-		proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) && proj.UsageID == uuid.Nil {
-		customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, c.Config().ServerConf.PorterCloudPlanID)
+	if c.Config().BillingManager.MetronomeEnabled && proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) && proj.UsageID == uuid.Nil {
+		customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.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")
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 157 - 0
api/server/handlers/billing/plan.go

@@ -0,0 +1,157 @@
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListPlansHandler is a handler for getting customer plans
+type ListPlansHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewListPlansHandler will create a new ListPlansHandler
+func NewListPlansHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListPlansHandler {
+	return &ListPlansHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-plans")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !c.Config().BillingManager.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
+		)
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	plan, err := c.Config().BillingManager.MetronomeClient.ListCustomerPlan(ctx, proj.UsageID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing plans")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, plan)
+}
+
+// ListCreditsHandler is a handler for getting available credits
+type ListCreditsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewListCreditsHandler will create a new ListCreditsHandler
+func NewListCreditsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListCreditsHandler {
+	return &ListCreditsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-credits")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !c.Config().BillingManager.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
+		)
+		return
+	}
+
+	credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing credits")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	c.WriteResult(w, r, credits)
+}
+
+// GetUsageDashboardHandler returns an embeddable dashboard to display information related to customer usage.
+type GetUsageDashboardHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewGetUsageDashboardHandler returns a new GetUsageDashboardHandler
+func NewGetUsageDashboardHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetUsageDashboardHandler {
+	return &GetUsageDashboardHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !c.Config().BillingManager.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
+		)
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	request := &types.EmbeddableDashboardRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding embeddable usage dashboard request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerDashboard(ctx, proj.UsageID, request.DashboardType, request.Options, request.ColorOverrides)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting customer dashboard")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, credits)
+}

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

@@ -98,8 +98,8 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// Create Metronome customer and add to starter plan
-	if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
-		customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, p.Config().ServerConf.PorterCloudPlanID)
+	if p.Config().BillingManager.MetronomeEnabled && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
+		customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.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")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

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

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

+ 59 - 4
api/server/router/project.go

@@ -341,8 +341,35 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing/plan -> project.NewListPlansHandler
+	listPlanEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/plan",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listPlanHandler := billing.NewListPlansHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listPlanEndpoint,
+		Handler:  listPlanHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/billing/credits -> project.NewGetCreditsHandler
-	getCreditsEndpoint := factory.NewAPIEndpoint(
+	listCreditsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
@@ -357,14 +384,42 @@ func getProjectRoutes(
 		},
 	)
 
-	getCreditsHandler := billing.NewGetCreditsHandler(
+	listCreditsHandler := billing.NewListCreditsHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getCreditsEndpoint,
-		Handler:  getCreditsHandler,
+		Endpoint: listCreditsEndpoint,
+		Handler:  listCreditsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
+	getUsageDashboardEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/dashboard",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getUsageDashboardHandler := billing.NewGetUsageDashboardHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getUsageDashboardEndpoint,
+		Handler:  getUsageDashboardHandler,
 		Router:   r,
 	})
 

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -73,6 +73,7 @@ type ServerConf struct {
 	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
 	MetronomeAPIKey      string `env:"METRONOME_API_KEY"`
 	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
+	PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"`
 
 	// This endpoint will be passed to the porter-agent so that
 	// the billing manager can query Prometheus.

+ 13 - 7
api/server/shared/config/loader/loader.go

@@ -334,8 +334,9 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 
 	var (
-		stripeClient    billing.StripeClient
-		metronomeClient billing.MetronomeClient
+		stripeClient     billing.StripeClient
+		metronomeClient  billing.MetronomeClient
+		metronomeEnabled bool
 	)
 	if sc.StripeSecretKey != "" {
 		stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
@@ -343,16 +344,21 @@ 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.MetronomeAPIKey != "" {
-		metronomeClient = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey)
+	if sc.MetronomeAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" {
+		metronomeClient, err = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID)
+		if err != nil {
+			return nil, fmt.Errorf("unable to create metronome client: %w", err)
+		}
+		metronomeEnabled = true
 	} else {
-		res.Logger.Info().Msg("METRONOME_API_KEY not set, all Metronome functionality will be disabled")
+		res.Logger.Info().Msg("METRONOME_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled")
 	}
 
 	res.Logger.Info().Msg("Creating billing manager")
 	res.BillingManager = billing.Manager{
-		StripeClient:    stripeClient,
-		MetronomeClient: metronomeClient,
+		StripeClient:     stripeClient,
+		MetronomeClient:  metronomeClient,
+		MetronomeEnabled: metronomeEnabled,
 	}
 	res.Logger.Info().Msg("Created billing manager")
 

+ 59 - 10
api/types/billing_metronome.go

@@ -33,8 +33,8 @@ type AddCustomerPlanRequest struct {
 	StartingOnUTC string `json:"starting_on"`
 	// EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight)
 	EndingBeforeUTC string `json:"ending_before,omitempty"`
-	// NetPaymentTermsDays is the number of days after issuance of invoice after which the invoice is due
-	NetPaymentTermsDays int `json:"net_payment_terms_days,omitempty"`
+	// NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due
+	NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"`
 }
 
 // EndCustomerPlanRequest represents a request to end the plan for a specific customer.
@@ -60,6 +60,41 @@ type ListCreditGrantsRequest struct {
 	EffectiveBefore string `json:"effective_before,omitempty"`
 }
 
+// ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
+type ListCreditGrantsResponse struct {
+	RemainingCredits float64 `json:"remaining_credits"`
+	GrantedCredits   float64 `json:"granted_credits"`
+}
+
+// EmbeddableDashboardRequest requests an embeddable customer dashboard to Metronome
+type EmbeddableDashboardRequest struct {
+	// CustomerID is the id of the customer
+	CustomerID uuid.UUID `json:"customer_id,omitempty"`
+	// DashboardType is the type of dashboard to retrieve
+	DashboardType string `json:"dashboard"`
+	// Options are optional dashboard specific options
+	Options []DashboardOption `json:"dashboard_options,omitempty"`
+	//  ColorOverrides is an optional list of colors to override
+	ColorOverrides []ColorOverride `json:"color_overrides,omitempty"`
+}
+
+// Plan is a pricing plan to which a user is currently subscribed
+type Plan struct {
+	ID                  uuid.UUID `json:"id"`
+	PlanID              uuid.UUID `json:"plan_id"`
+	PlanName            string    `json:"plan_name"`
+	PlanDescription     string    `json:"plan_description"`
+	StartingOn          string    `json:"starting_on"`
+	EndingBefore        string    `json:"ending_before"`
+	NetPaymentTermsDays int       `json:"net_payment_terms_days"`
+	TrialInfo           Trial     `json:"trial_info,omitempty"`
+}
+
+// Trial contains the information for a trial period
+type Trial struct {
+	EndingBefore string `json:"ending_before"`
+}
+
 // CreditType is the type of the credit used in the credit grant
 type CreditType struct {
 	Name string `json:"name"`
@@ -68,7 +103,7 @@ type CreditType struct {
 
 // GrantAmount represents the amount of credits granted
 type GrantAmount struct {
-	Amount     int64      `json:"amount"`
+	Amount     float64    `json:"amount"`
 	CreditType CreditType `json:"credit_type"`
 }
 
@@ -76,18 +111,32 @@ type GrantAmount struct {
 // current billing period.
 type Balance struct {
 	// ExcludingPending is the grant's current balance excluding pending deductions
-	ExcludingPending int64 `json:"excluding_pending"`
+	ExcludingPending float64 `json:"excluding_pending"`
 	// IncludingPending is the grant's current balance including pending deductions
-	IncludingPending int64 `json:"including_pending"`
+	IncludingPending float64 `json:"including_pending"`
 	// EffectiveAt is a RFC3339 timestamp that can be used to filter credit grants by effective date
 	EffectiveAt string `json:"effective_at"`
 }
 
 // CreditGrant is a grant given to a specific user on a specific plan
 type CreditGrant struct {
-	ID          uuid.UUID `json:"id"`
-	Name        string
-	CustomerID  uuid.UUID
-	GrantAmount GrantAmount
-	Balance     Balance
+	ID          uuid.UUID   `json:"id"`
+	Name        string      `json:"name"`
+	GrantAmount GrantAmount `json:"grant_amount"`
+	Balance     Balance     `json:"balance"`
+	Reason      string      `json:"reason"`
+	EffectiveAt string      `json:"effective_at"`
+	ExpiresAt   string      `json:"expires_at"`
+}
+
+// DashboardOption are optional dashboard specific options
+type DashboardOption struct {
+	Key   string `json:"key"`
+	Value string `json:"value"`
+}
+
+// ColorOverride is an optional list of colors to override
+type ColorOverride struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
 }

+ 21 - 1
dashboard/src/lib/billing/types.tsx

@@ -1,7 +1,6 @@
 import { z } from "zod";
 
 export type PaymentMethodList = PaymentMethod[];
-
 export type PaymentMethod = z.infer<typeof PaymentMethodValidator>;
 
 export const PaymentMethodValidator = z.object({
@@ -13,4 +12,25 @@ export const PaymentMethodValidator = z.object({
   is_default: z.boolean(),
 });
 
+type Trial = z.infer<typeof Trial>;
+
+const Trial = z.object({
+  ending_before: z.string(),
+});
+
+export type Plan = z.infer<typeof Plan>;
+export const Plan = z.object({
+  id: z.string(),
+  plan_name: z.string(),
+  plan_description: z.string(),
+  starting_on: z.string(),
+  trial_info: Trial,
+});
+
+export type CreditGrants = z.infer<typeof CreditGrantsValidator>;
+export const CreditGrantsValidator = z.object({
+  granted_credits: z.number(),
+  remaining_credits: z.number(),
+});
+
 export const ClientSecretResponse = z.string();

+ 117 - 14
dashboard/src/lib/hooks/useStripe.tsx

@@ -1,11 +1,15 @@
 import { useContext, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
 import { z } from "zod";
 
 import {
   ClientSecretResponse,
-  PaymentMethodList,
+  CreditGrantsValidator,
   PaymentMethodValidator,
+  Plan,
+  type CreditGrants,
+  type PaymentMethod,
+  type PaymentMethodList,
 } from "lib/billing/types";
 
 import api from "shared/api";
@@ -13,7 +17,10 @@ import { Context } from "shared/Context";
 
 type TUsePaymentMethod = {
   paymentMethodList: PaymentMethodList;
-  refetchPaymentMethods: any;
+  refetchPaymentMethods: (options: {
+    throwOnError: boolean;
+    cancelRefetch: boolean;
+  }) => Promise<UseQueryResult>;
   deletingIds: string[];
   deletePaymentMethod: (paymentMethodId: string) => Promise<void>;
 };
@@ -28,15 +35,36 @@ type TSetDefaultPaymentMethod = {
 
 type TCheckHasPaymentEnabled = {
   hasPaymentEnabled: boolean;
-  refetchPaymentEnabled: any;
+  refetchPaymentEnabled: (options: {
+    throwOnError: boolean;
+    cancelRefetch: boolean;
+  }) => Promise<UseQueryResult>;
 };
 
 type TGetPublishableKey = {
   publishableKey: string;
 };
 
+type TGetUsageDashboard = {
+  url: string;
+};
+
 type TGetCredits = {
-  credits: number;
+  creditGrants: CreditGrants | undefined;
+};
+
+type TGetPlan = {
+  plan: Plan | undefined;
+};
+
+const embeddableDashboardColors = {
+  grayDark: "Gray_dark",
+  grayMedium: "Gray_medium",
+  grayLight: "Gray_light",
+  grayExtraLigth: "Gray_extralight",
+  white: "White",
+  primaryMedium: "Primary_medium",
+  primaryLight: "Primary_light",
 };
 
 export const usePaymentMethods = (): TUsePaymentMethod => {
@@ -52,9 +80,9 @@ export const usePaymentMethods = (): TUsePaymentMethod => {
   // Fetch list of payment methods
   const paymentMethodReq = useQuery(
     ["getPaymentMethods", currentProject?.id],
-    async () => {
+    async (): Promise<PaymentMethod[]> => {
       if (!currentProject?.id || currentProject.id === -1) {
-        return;
+        return [];
       }
       const listResponse = await api.listPaymentMethod(
         "<token>",
@@ -70,7 +98,9 @@ export const usePaymentMethods = (): TUsePaymentMethod => {
   );
 
   // Delete list of payment methods
-  const deletePaymentMethod = async (paymentMethodId: string) => {
+  const deletePaymentMethod = async (
+    paymentMethodId: string
+  ): Promise<void> => {
     if (!currentProject?.id) {
       throw new Error("Project ID is missing");
     }
@@ -109,7 +139,7 @@ export const usePaymentMethods = (): TUsePaymentMethod => {
 export const useCreatePaymentMethod = (): TCreatePaymentMethod => {
   const { currentProject } = useContext(Context);
 
-  const createPaymentMethod = async () => {
+  const createPaymentMethod = async (): Promise<string> => {
     const resp = await api.addPaymentMethod(
       "<token>",
       {},
@@ -136,7 +166,7 @@ export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
   // Fetch list of payment methods
   const paymentEnabledReq = useQuery(
     ["checkPaymentEnabled", currentProject?.id],
-    async () => {
+    async (): Promise<boolean> => {
       const res = await api.getHasBilling(
         "<token>",
         {},
@@ -154,8 +184,52 @@ export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
   };
 };
 
+export const useCustomeUsageDashboard = (
+  dashboard: string
+): TGetUsageDashboard => {
+  const { currentProject } = useContext(Context);
+
+  const colorOverrides = [
+    { name: embeddableDashboardColors.grayDark, value: "#121212" },
+    { name: embeddableDashboardColors.grayMedium, value: "#DFDFE1" },
+    { name: embeddableDashboardColors.grayLight, value: "#DFDFE1" },
+    { name: embeddableDashboardColors.grayExtraLigth, value: "#DFDFE1" },
+    { name: embeddableDashboardColors.white, value: "#121212" },
+    { name: embeddableDashboardColors.primaryLight, value: "#121212" },
+    { name: embeddableDashboardColors.primaryMedium, value: "#DFDFE1" },
+  ];
+
+  // Return an embeddable dashboard for the customer
+  const dashboardReq = useQuery(
+    ["getUsageDashboard", currentProject?.id, dashboard],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getUsageDashboard(
+        "<token>",
+        {
+          dashboard,
+          color_overrides: colorOverrides,
+        },
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      return res.data;
+    },
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  return {
+    url: dashboardReq.data,
+  };
+};
+
 export const usePublishableKey = (): TGetPublishableKey => {
-  const { user, currentProject } = useContext(Context);
+  const { currentProject } = useContext(Context);
 
   // Fetch list of payment methods
   const keyReq = useQuery(
@@ -197,19 +271,48 @@ export const usePorterCredits = (): TGetCredits => {
           project_id: currentProject?.id,
         }
       );
-      return res.data;
+      return CreditGrantsValidator.parse(res.data);
+    }
+  );
+
+  return {
+    creditGrants: creditsReq.data,
+  };
+};
+
+export const useCustomerPlan = (): TGetPlan => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch current plan
+  const planReq = useQuery(
+    ["getCustomerPlan", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getCustomerPlan(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      const plan = Plan.parse(res.data);
+      return plan;
     }
   );
 
   return {
-    credits: creditsReq.data,
+    plan: planReq.data,
   };
 };
 
 export const useSetDefaultPaymentMethod = (): TSetDefaultPaymentMethod => {
   const { currentProject } = useContext(Context);
 
-  const setDefaultPaymentMethod = async (paymentMethodId: string) => {
+  const setDefaultPaymentMethod = async (
+    paymentMethodId: string
+  ): Promise<void> => {
     // Set payment method as default
     const res = await api.setDefaultPaymentMethod(
       "<token>",

+ 147 - 39
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -1,4 +1,5 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
 import styled from "styled-components";
 
 import Loading from "components/Loading";
@@ -11,6 +12,8 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
   checkIfProjectHasPayment,
+  useCustomerPlan,
+  useCustomeUsageDashboard,
   usePaymentMethods,
   usePorterCredits,
   useSetDefaultPaymentMethod,
@@ -28,7 +31,8 @@ function BillingPage(): JSX.Element {
   const [shouldCreate, setShouldCreate] = useState(false);
   const { currentProject } = useContext(Context);
 
-  const { credits } = usePorterCredits();
+  const { creditGrants } = usePorterCredits();
+  const { plan } = useCustomerPlan();
 
   const {
     paymentMethodList,
@@ -40,14 +44,47 @@ function BillingPage(): JSX.Element {
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
+  const { url: usageDashboard } = useCustomeUsageDashboard("usage");
+
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
   };
+  const monthDiff = (d1: Date, d2: Date): number => {
+    let months;
+    months = (d2.getFullYear() - d1.getFullYear()) * 12;
+    months -= d1.getMonth();
+    months += d2.getMonth();
+    return months <= 0 ? 0 : months;
+  };
+
+  const daysDiff = (d1: Date, d2: Date): number => {
+    const _MS_PER_DAY = 1000 * 60 * 60 * 24;
+    const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
+    const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
+
+    return Math.floor((utc2 - utc1) / _MS_PER_DAY);
+  };
+
+  const relativeTime = (timestampUTC: string): string => {
+    const tsDate = new Date(timestampUTC);
+    const now = new Date();
+
+    const remainingMonths = monthDiff(now, tsDate);
+    const remainingDays = daysDiff(now, tsDate);
+
+    const relativeFormat = remainingMonths > 0 ? "months" : "days";
+    const relativeValue = remainingMonths > 0 ? remainingMonths : remainingDays;
+
+    const rt = new Intl.RelativeTimeFormat("en", { style: "short" });
+    return rt.format(relativeValue, relativeFormat);
+  };
+
+  const readableDate = (s: string): string => new Date(s).toLocaleDateString();
 
-  const onCreate = async () => {
-    await refetchPaymentMethods();
+  const onCreate = async (): Promise<void> => {
+    await refetchPaymentMethods({ throwOnError: false, cancelRefetch: false });
     setShouldCreate(false);
-    refetchPaymentEnabled();
+    await refetchPaymentEnabled({ throwOnError: false, cancelRefetch: false });
   };
 
   if (shouldCreate) {
@@ -63,27 +100,6 @@ function BillingPage(): JSX.Element {
 
   return (
     <>
-      {currentProject?.metronome_enabled ? (
-        <div>
-          <Text size={16}>Porter credit balance</Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            View the amount of Porter credits you have available to spend on
-            resources within this project.
-          </Text>
-          <Spacer y={1} />
-          <Container row>
-            <Image src={gift} style={{ marginTop: "-2px" }} />
-            <Spacer inline x={1} />
-            <Text size={20}>
-              {credits > 0 ? `$${formatCredits(credits)}` : "$ 0.00"}
-            </Text>
-          </Container>
-          <Spacer y={2} />
-        </div>
-      ) : (
-        <div></div>
-      )}
       <Text size={16}>Payment methods</Text>
       <Spacer y={1} />
       <Text color="helper">
@@ -114,25 +130,30 @@ function BillingPage(): JSX.Element {
                     <Container row={true}>
                       <DeleteButton
                         onClick={() => {
-                          setCurrentOverlay({
-                            message: `Are you sure you want to remove this payment method?`,
-                            onYes: () => {
-                              deletePaymentMethod(paymentMethod.id);
-                              setCurrentOverlay(null);
-                            },
-                            onNo: () => {
-                              setCurrentOverlay(null);
-                            },
-                          });
+                          if (setCurrentOverlay) {
+                            setCurrentOverlay({
+                              message: `Are you sure you want to remove this payment method?`,
+                              onYes: async () => {
+                                await deletePaymentMethod(paymentMethod.id);
+                                setCurrentOverlay(null);
+                              },
+                              onNo: () => {
+                                setCurrentOverlay(null);
+                              },
+                            });
+                          }
                         }}
                       >
                         <Icon src={trashIcon} height={"18px"} />
                       </DeleteButton>
                       <Spacer inline x={1} />
                       <Button
-                        onClick={() => {
-                          setDefaultPaymentMethod(paymentMethod.id);
-                          refetchPaymentMethods();
+                        onClick={async () => {
+                          await setDefaultPaymentMethod(paymentMethod.id);
+                          await refetchPaymentMethods({
+                            throwOnError: false,
+                            cancelRefetch: false,
+                          });
                         }}
                       >
                         Set as default
@@ -156,6 +177,93 @@ function BillingPage(): JSX.Element {
         <I className="material-icons">add</I>
         Add Payment Method
       </Button>
+      <Spacer y={2} />
+
+      {currentProject?.metronome_enabled ? (
+        <div>
+          <div>
+            <Text size={16}>Porter credit grants</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              View the amount of Porter credits you have available to spend on
+              resources within this project.
+            </Text>
+            <Spacer y={1} />
+
+            <Container>
+              <Image src={gift} style={{ marginTop: "-2px" }} />
+              <Spacer inline x={1} />
+              <Text size={20}>
+                {creditGrants !== undefined &&
+                creditGrants.remaining_credits > 0
+                  ? `$${formatCredits(
+                      creditGrants.remaining_credits
+                    )}/$${formatCredits(creditGrants.granted_credits)}`
+                  : "$ 0.00"}
+              </Text>
+            </Container>
+            <Spacer y={2} />
+          </div>
+
+          <div>
+            <Text size={16}>Plan Details</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              View the details of the current billing plan of this project.
+            </Text>
+            <Spacer y={1} />
+
+            {plan !== undefined && plan.plan_name !== "" ? (
+              <div>
+                <Text>Active Plan</Text>
+                <Spacer y={0.5} />
+                <Fieldset>
+                  <Container row spaced>
+                    <Container row>
+                      <Text color="helper">{plan.plan_name}</Text>
+                    </Container>
+                    <Container row>
+                      {plan.trial_info !== undefined &&
+                      plan.trial_info.ending_before !== "" ? (
+                        <Text>
+                          Free trial ends{" "}
+                          {relativeTime(plan.trial_info.ending_before)}
+                        </Text>
+                      ) : (
+                        <Text>Started on {readableDate(plan.starting_on)}</Text>
+                      )}
+                    </Container>
+                  </Container>
+                </Fieldset>
+                <Spacer y={2} />
+                <Text size={16}>Current Usage</Text>
+                <Spacer y={1} />
+                <Text color="helper">
+                  View the current usage of this billing period.
+                </Text>
+                <Spacer y={1} />{" "}
+                <Container row style={{ width: "100%", height: "80vh" }}>
+                  <ParentSize>
+                    {({ width, height }) => (
+                      <iframe
+                        width={width}
+                        height={height}
+                        src={usageDashboard}
+                        scrolling="no"
+                        frameBorder={0}
+                      ></iframe>
+                    )}
+                  </ParentSize>
+                </Container>
+              </div>
+            ) : (
+              <Text>This project does not have an active billing plan.</Text>
+            )}
+          </div>
+        </div>
+      ) : (
+        <div></div>
+      )}
     </>
   );
 }

+ 20 - 0
dashboard/src/shared/api.tsx

@@ -3452,6 +3452,24 @@ const getPublishableKey = baseApi<
   ({ project_id }) => `/api/projects/${project_id}/billing/publishable_key`
 );
 
+const getUsageDashboard = baseApi<
+  {
+    dashboard: string;
+    dashboard_options?: { key: string; value: string }[];
+    color_overrides?: { name: string; value: string }[];
+  },
+  {
+    project_id?: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/billing/dashboard`);
+
+const getCustomerPlan = baseApi<
+  {},
+  {
+    project_id?: number;
+  }
+>("GET", ({ project_id }) => `/api/projects/${project_id}/billing/plan`);
+
 const getPorterCredits = baseApi<
   {},
   {
@@ -3857,6 +3875,8 @@ export default {
   // BILLING
   getPublishableKey,
   getPorterCredits,
+  getCustomerPlan,
+  getUsageDashboard,
   listPaymentMethod,
   addPaymentMethod,
   setDefaultPaymentMethod,

+ 3 - 2
internal/billing/billing.go

@@ -2,6 +2,7 @@ package billing
 
 // Manager contains methods for managing billing for a project
 type Manager struct {
-	StripeClient    StripeClient
-	MetronomeClient MetronomeClient
+	StripeClient     StripeClient
+	MetronomeClient  MetronomeClient
+	MetronomeEnabled bool
 }

+ 110 - 25
internal/billing/metronome.go

@@ -25,14 +25,48 @@ const (
 
 // MetronomeClient is the client used to call the Metronome API
 type MetronomeClient struct {
-	ApiKey string
+	ApiKey               string
+	PorterCloudPlanID    uuid.UUID
+	PorterStandardPlanID uuid.UUID
 }
 
 // NewMetronomeClient returns a new Metronome client
-func NewMetronomeClient(metronomeApiKey string) MetronomeClient {
+func NewMetronomeClient(metronomeApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client MetronomeClient, err error) {
+	porterCloudPlanUUID, err := uuid.Parse(porterCloudPlanID)
+	if err != nil {
+		return client, err
+	}
+
+	porterStandardPlanUUID, err := uuid.Parse(porterStandardPlanID)
+	if err != nil {
+		return client, err
+	}
+
 	return MetronomeClient{
-		ApiKey: metronomeApiKey,
+		ApiKey:               metronomeApiKey,
+		PorterCloudPlanID:    porterCloudPlanUUID,
+		PorterStandardPlanID: porterStandardPlanUUID,
+	}, nil
+}
+
+// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
+func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
+	defer span.End()
+
+	planID := m.PorterStandardPlanID
+	if sandboxEnabled {
+		planID = m.PorterCloudPlanID
+	}
+
+	customerID, err = m.createCustomer(ctx, userEmail, projectName, projectID, billingID)
+	if err != nil {
+		return customerID, customerPlanID, telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID))
 	}
+
+	customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID)
+
+	return customerID, customerPlanID, err
 }
 
 // createCustomer will create the customer in Metronome
@@ -63,7 +97,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p
 		Data types.Customer `json:"data"`
 	}
 
-	err = post(path, m.ApiKey, customer, &result)
+	err = do(http.MethodPost, path, m.ApiKey, customer, &result)
 	if err != nil {
 		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
 	}
@@ -97,7 +131,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 		} `json:"data"`
 	}
 
-	err = post(path, m.ApiKey, req, &result)
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
 	if err != nil {
 		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
 	}
@@ -105,24 +139,31 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 	return result.Data.CustomerPlanID, nil
 }
 
-// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
-func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, planID string) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
+// ListCustomerPlan will return the current active plan to which the user is subscribed
+func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.UUID) (plan types.Plan, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
 	defer span.End()
 
-	porterCloudPlanID, err := uuid.Parse(planID)
-	if err != nil {
-		return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error parsing starter plan id")
+	if customerID == uuid.Nil {
+		return plan, telemetry.Error(ctx, span, err, "customer id empty")
 	}
 
-	customerID, err = m.createCustomer(ctx, userEmail, projectName, projectID, billingID)
+	path := fmt.Sprintf("/customers/%s/plans", customerID)
+
+	var result struct {
+		Data []types.Plan `json:"data"`
+	}
+
+	err = do(http.MethodGet, path, m.ApiKey, nil, &result)
 	if err != nil {
-		return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error while creatinc customer with plan")
+		return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
 	}
 
-	customerPlanID, err = m.addCustomerPlan(ctx, customerID, porterCloudPlanID)
+	if len(result.Data) > 0 {
+		plan = result.Data[0]
+	}
 
-	return customerID, customerPlanID, err
+	return plan, nil
 }
 
 // EndCustomerPlan will immediately end the plan for the given customer
@@ -145,7 +186,7 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 		EndingBeforeUTC: endBefore,
 	}
 
-	err = post(path, m.ApiKey, req, nil)
+	err = do(http.MethodPost, path, m.ApiKey, req, nil)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "failed to end customer plan")
 	}
@@ -153,9 +194,9 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 	return nil
 }
 
-// GetCustomerCredits will return the first credit grant for the customer
-func (m MetronomeClient) GetCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits int64, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "get-customer-credits")
+// ListCustomerCredits will return the total number of credits for the customer
+func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits types.ListCreditGrantsResponse, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
 	defer span.End()
 
 	if customerID == uuid.Nil {
@@ -174,15 +215,51 @@ func (m MetronomeClient) GetCustomerCredits(ctx context.Context, customerID uuid
 		Data []types.CreditGrant `json:"data"`
 	}
 
-	err = post(path, m.ApiKey, req, &result)
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
 	if err != nil {
-		return credits, telemetry.Error(ctx, span, err, "failed to get customer credits")
+		return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
 	}
 
-	return result.Data[0].Balance.IncludingPending, nil
+	var response types.ListCreditGrantsResponse
+	for _, grant := range result.Data {
+		response.GrantedCredits += grant.GrantAmount.Amount
+		response.RemainingCredits += grant.Balance.IncludingPending
+	}
+
+	return response, nil
 }
 
-func post(path string, apiKey string, body interface{}, data interface{}) (err error) {
+// GetCustomerDashboard will return an embeddable Metronome dashboard
+func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uuid.UUID, dashboardType string, options []types.DashboardOption, colorOverrides []types.ColorOverride) (url string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-customer-usage-dashboard")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return url, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	path := "dashboards/getEmbeddableUrl"
+
+	req := types.EmbeddableDashboardRequest{
+		CustomerID:     customerID,
+		Options:        options,
+		DashboardType:  dashboardType,
+		ColorOverrides: colorOverrides,
+	}
+
+	var result struct {
+		Data map[string]string `json:"data"`
+	}
+
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
+	if err != nil {
+		return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
+	}
+
+	return result.Data["url"], nil
+}
+
+func do(method string, path string, apiKey string, body interface{}, data interface{}) (err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	if err != nil {
@@ -197,7 +274,7 @@ func post(path string, apiKey string, body interface{}, data interface{}) (err e
 		}
 	}
 
-	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(bodyJson))
+	req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
 	if err != nil {
 		return err
 	}
@@ -211,7 +288,15 @@ func post(path string, apiKey string, body interface{}, data interface{}) (err e
 	}
 
 	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("non 200 status code returned: %d", resp.StatusCode)
+		// If there is an error, try to decode the message
+		var message map[string]string
+		err = json.NewDecoder(resp.Body).Decode(&message)
+		if err != nil {
+			return fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
+		}
+		_ = resp.Body.Close()
+
+		return fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
 	}
 
 	if data != nil {

+ 3 - 0
zarf/helm/.serverenv

@@ -78,3 +78,6 @@ METRONOME_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_STANDARD_PLAN_ID is the id of the standard plan in Metronome. Only used if METRONOME_API_KEY is set
+PORTER_STANDARD_PLAN_ID=