فهرست منبع

Add plan details endpoint, add plan to frontend, fix credits endpoint

Mauricio Araujo 2 سال پیش
والد
کامیت
6854fd4e1c

+ 68 - 22
api/server/handlers/billing/credits.go → api/server/handlers/billing/plan.go

@@ -12,39 +12,69 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 )
 
 
-// GetCreditsHandler is a handler for getting available credits
-type GetCreditsHandler struct {
+// ListPlansHandler is a handler for getting customer plans
+type ListPlansHandler struct {
 	handlers.PorterHandlerWriter
 	handlers.PorterHandlerWriter
 }
 }
 
 
-// GetUsageDashboardHandler returns an embeddable dashboard to display information related to customer usage.
-type GetUsageDashboardHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-// NewGetCreditsHandler will create a new GetCreditsHandler
-func NewGetCreditsHandler(
+// NewListCreditsHandler will create a new ListPlansHandler
+func NewListPlansHandler(
 	config *config.Config,
 	config *config.Config,
 	writer shared.ResultWriter,
 	writer shared.ResultWriter,
-) *GetCreditsHandler {
-	return &GetCreditsHandler{
+) *ListPlansHandler {
+	return &ListPlansHandler{
 		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}
 	}
 }
 }
 
 
-// NewGetUsageDashboardHandler returns a new GetUsageDashboardHandler
-func NewGetUsageDashboardHandler(
+func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "list-plans-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
+	}
+
+	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
+	}
+
+	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, 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,
 	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
 	writer shared.ResultWriter,
-) *GetUsageDashboardHandler {
-	return &GetUsageDashboardHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+) *ListCreditsHandler {
+	return &ListCreditsHandler{
+		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")
+func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "list-credits-endpoint")
 	defer span.End()
 	defer span.End()
 
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
@@ -58,9 +88,9 @@ func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerCredits(ctx, proj.UsageID)
+	credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID)
 	if err != nil {
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting credits")
+		err := telemetry.Error(ctx, span, err, "error listing credits")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
@@ -74,6 +104,22 @@ func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	c.WriteResult(w, r, credits)
 	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) {
 func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
 	ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
 	defer span.End()
 	defer span.End()
@@ -97,7 +143,7 @@ func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
-	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerDashboard(ctx, proj.UsageID, request.DashboardType)
+	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerDashboard(ctx, proj.UsageID, request.DashboardType, request.Options, request.ColorOverrides)
 	if err != nil {
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error getting customer dashboard")
 		err := telemetry.Error(ctx, span, err, "error getting customer dashboard")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

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

@@ -341,8 +341,35 @@ func getProjectRoutes(
 		Router:   r,
 		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
 	// GET /api/projects/{project_id}/billing/credits -> project.NewGetCreditsHandler
-	getCreditsEndpoint := factory.NewAPIEndpoint(
+	listCreditsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Method: types.HTTPVerbGet,
@@ -357,14 +384,14 @@ func getProjectRoutes(
 		},
 		},
 	)
 	)
 
 
-	getCreditsHandler := billing.NewGetCreditsHandler(
+	listCreditsHandler := billing.NewListCreditsHandler(
 		config,
 		config,
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
 
 
 	routes = append(routes, &router.Route{
 	routes = append(routes, &router.Route{
-		Endpoint: getCreditsEndpoint,
-		Handler:  getCreditsHandler,
+		Endpoint: listCreditsEndpoint,
+		Handler:  listCreditsHandler,
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 

+ 32 - 20
api/types/billing_metronome.go

@@ -71,16 +71,21 @@ type EmbeddableDashboardRequest struct {
 	ColorOverrides []ColorOverrides `json:"color_overrides,omitempty"`
 	ColorOverrides []ColorOverrides `json:"color_overrides,omitempty"`
 }
 }
 
 
-// DashboardOptions are optional dashboard specific options
-type DashboardOptions struct {
-	Key   string
-	Value string
+// 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"`
 }
 }
 
 
-// ColorOverrides is an optional list of colors to override
-type ColorOverrides struct {
-	Name  string
-	Value string
+// 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
 // CreditType is the type of the credit used in the credit grant
@@ -89,19 +94,13 @@ type CreditType struct {
 	ID   string `json:"id"`
 	ID   string `json:"id"`
 }
 }
 
 
-// GrantAmount represents the amount of credits granted
-type GrantAmount struct {
-	Amount     int64      `json:"amount"`
-	CreditType CreditType `json:"credit_type"`
-}
-
 // Balance represents the effective balance of the grant as of the end of the customer's
 // Balance represents the effective balance of the grant as of the end of the customer's
 // current billing period.
 // current billing period.
 type Balance struct {
 type Balance struct {
 	// ExcludingPending is the grant's current balance excluding pending deductions
 	// 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 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 is a RFC3339 timestamp that can be used to filter credit grants by effective date
 	EffectiveAt string `json:"effective_at"`
 	EffectiveAt string `json:"effective_at"`
 }
 }
@@ -109,8 +108,21 @@ type Balance struct {
 // CreditGrant is a grant given to a specific user on a specific plan
 // CreditGrant is a grant given to a specific user on a specific plan
 type CreditGrant struct {
 type CreditGrant struct {
 	ID          uuid.UUID `json:"id"`
 	ID          uuid.UUID `json:"id"`
-	Name        string
-	CustomerID  uuid.UUID
-	GrantAmount GrantAmount
-	Balance     Balance
+	Name        string    `json:"name"`
+	Balance     Balance   `json:"balance"`
+	Reason      string    `json:"reason"`
+	EffectiveAt string    `json:"effective_at"`
+	ExpiresAt   string    `json:"expires_at"`
+}
+
+// DashboardOptions are optional dashboard specific options
+type DashboardOptions struct {
+	Key   string `json:"key"`
+	Value string `json:"value"`
+}
+
+// ColorOverrides is an optional list of colors to override
+type ColorOverrides struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
 }
 }

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

@@ -1,7 +1,6 @@
 import { z } from "zod";
 import { z } from "zod";
 
 
 export type PaymentMethodList = PaymentMethod[];
 export type PaymentMethodList = PaymentMethod[];
-
 export type PaymentMethod = z.infer<typeof PaymentMethodValidator>;
 export type PaymentMethod = z.infer<typeof PaymentMethodValidator>;
 
 
 export const PaymentMethodValidator = z.object({
 export const PaymentMethodValidator = z.object({
@@ -13,4 +12,34 @@ export const PaymentMethodValidator = z.object({
   is_default: z.boolean(),
   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 CreditGrantList = CreditGrant[];
+export type CreditGrant = z.infer<typeof CreditGrantValidator>;
+export const CreditGrantValidator = z.object({
+  id: z.string(),
+  name: z.string(),
+  balance: z.object({
+    excluding_pending: z.number(),
+    including_pending: z.number(),
+    effective_at: z.string(),
+  }),
+  reason: z.string(),
+  effective_at: z.string(),
+  expires_at: z.string(),
+});
+
 export const ClientSecretResponse = z.string();
 export const ClientSecretResponse = z.string();

+ 65 - 3
dashboard/src/lib/hooks/useStripe.tsx

@@ -4,8 +4,10 @@ import { z } from "zod";
 
 
 import {
 import {
   ClientSecretResponse,
   ClientSecretResponse,
+  CreditGrantList,
   PaymentMethodList,
   PaymentMethodList,
   PaymentMethodValidator,
   PaymentMethodValidator,
+  Plan,
 } from "lib/billing/types";
 } from "lib/billing/types";
 
 
 import api from "shared/api";
 import api from "shared/api";
@@ -40,7 +42,30 @@ type TGetUsageDashboard = {
 };
 };
 
 
 type TGetCredits = {
 type TGetCredits = {
-  credits: number;
+  creditGrantsList: CreditGrantList;
+};
+
+type TGetPlan = {
+  plan: Plan;
+};
+
+const embeddableDashboardColors = {
+  standardText: "Gray_dark",
+  greyMedium: "Gray_medium",
+  borders: "Gray_light",
+  hover: "Gray_extralight",
+  background: "White",
+  primaryMedium: "Primary_medium",
+  primaryLight: "Primary_light",
+  usageLine0: "Usageline_0",
+  usageLine1: "Usageline_1",
+  usageLine2: "Usageline_2",
+  usageLine3: "Usageline_3",
+  usageLine4: "Usageline_4",
+  usageLine5: "Usageline_5",
+  usageLine6: "Usageline_6",
+  usageLine7: "Usageline_7",
+  usageLine8: "Usageline_8",
 };
 };
 
 
 export const usePaymentMethods = (): TUsePaymentMethod => {
 export const usePaymentMethods = (): TUsePaymentMethod => {
@@ -161,6 +186,16 @@ export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
 export const useCustomerDashboard = (dashboard: string): TGetUsageDashboard => {
 export const useCustomerDashboard = (dashboard: string): TGetUsageDashboard => {
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
 
 
+  const colorOverrides = [
+    { name: embeddableDashboardColors.background, value: "#121212" },
+    { name: embeddableDashboardColors.borders, value: "#121212" },
+    { name: embeddableDashboardColors.hover, value: "#DFDFE1" },
+    { name: embeddableDashboardColors.greyMedium, value: "#121212" },
+    { name: embeddableDashboardColors.primaryLight, value: "#121212" },
+    { name: embeddableDashboardColors.primaryMedium, value: "#DFDFE1" },
+    { name: embeddableDashboardColors.standardText, value: "#DFDFE1" },
+  ];
+
   // Return an embeddable dashboard for the customer
   // Return an embeddable dashboard for the customer
   const dashboardReq = useQuery(
   const dashboardReq = useQuery(
     ["getUsageDashboard", currentProject?.id, dashboard],
     ["getUsageDashboard", currentProject?.id, dashboard],
@@ -172,6 +207,7 @@ export const useCustomerDashboard = (dashboard: string): TGetUsageDashboard => {
         "<token>",
         "<token>",
         {
         {
           dashboard,
           dashboard,
+          color_overrides: colorOverrides,
         },
         },
         {
         {
           project_id: currentProject?.id,
           project_id: currentProject?.id,
@@ -191,7 +227,7 @@ export const useCustomerDashboard = (dashboard: string): TGetUsageDashboard => {
 };
 };
 
 
 export const usePublishableKey = (): TGetPublishableKey => {
 export const usePublishableKey = (): TGetPublishableKey => {
-  const { user, currentProject } = useContext(Context);
+  const { currentProject } = useContext(Context);
 
 
   // Fetch list of payment methods
   // Fetch list of payment methods
   const keyReq = useQuery(
   const keyReq = useQuery(
@@ -238,7 +274,33 @@ export const usePorterCredits = (): TGetCredits => {
   );
   );
 
 
   return {
   return {
-    credits: creditsReq.data,
+    creditGrantsList: 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,
+        }
+      );
+      return res.data;
+    }
+  );
+
+  return {
+    plan: planReq.data,
   };
   };
 };
 };
 
 

+ 84 - 18
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -12,6 +12,7 @@ import Text from "components/porter/Text";
 import {
 import {
   checkIfProjectHasPayment,
   checkIfProjectHasPayment,
   useCustomerDashboard,
   useCustomerDashboard,
+  useCustomerPlan,
   usePaymentMethods,
   usePaymentMethods,
   usePorterCredits,
   usePorterCredits,
   useSetDefaultPaymentMethod,
   useSetDefaultPaymentMethod,
@@ -20,6 +21,7 @@ import {
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import cardIcon from "assets/credit-card.svg";
 import cardIcon from "assets/credit-card.svg";
 import gift from "assets/gift.svg";
 import gift from "assets/gift.svg";
+import time from "assets/time.png";
 import trashIcon from "assets/trash.png";
 import trashIcon from "assets/trash.png";
 
 
 import BillingModal from "../modals/BillingModal";
 import BillingModal from "../modals/BillingModal";
@@ -29,7 +31,8 @@ function BillingPage(): JSX.Element {
   const [shouldCreate, setShouldCreate] = useState(false);
   const [shouldCreate, setShouldCreate] = useState(false);
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
 
 
-  const { credits } = usePorterCredits();
+  const { creditGrantsList } = usePorterCredits();
+  const { plan } = useCustomerPlan();
 
 
   const {
   const {
     paymentMethodList,
     paymentMethodList,
@@ -40,15 +43,42 @@ function BillingPage(): JSX.Element {
   const { setDefaultPaymentMethod } = useSetDefaultPaymentMethod();
   const { setDefaultPaymentMethod } = useSetDefaultPaymentMethod();
 
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
-
-  const { url: creditsDashboard } = useCustomerDashboard("credits");
-  const { url: invoicesDashboard } = useCustomerDashboard("invoices");
   const { url: usageDashboard } = useCustomerDashboard("usage");
   const { url: usageDashboard } = useCustomerDashboard("usage");
 
 
   const formatCredits = (credits: number): string => {
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
     return (credits / 100).toFixed(2);
   };
   };
 
 
+  const monthDiff = (d1: Date, d2: Date) => {
+    var months;
+    months = (d2.getFullYear() - d1.getFullYear()) * 12;
+    months -= d1.getMonth();
+    months += d2.getMonth();
+    return months <= 0 ? 0 : months;
+  };
+
+  const daysDiff = (d1: Date, d2: Date) => {
+    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 onCreate = async () => {
   const onCreate = async () => {
     await refetchPaymentMethods();
     await refetchPaymentMethods();
     setShouldCreate(false);
     setShouldCreate(false);
@@ -70,26 +100,62 @@ function BillingPage(): JSX.Element {
     <>
     <>
       {currentProject?.metronome_enabled ? (
       {currentProject?.metronome_enabled ? (
         <div>
         <div>
-          <Text size={16}>Porter credit balance</Text>
+          <Text size={16}>Porter credit grants</Text>
           <Spacer y={1} />
           <Spacer y={1} />
           <Text color="helper">
           <Text color="helper">
             View the amount of Porter credits you have available to spend on
             View the amount of Porter credits you have available to spend on
             resources within this project.
             resources within this project.
           </Text>
           </Text>
           <Spacer y={1} />
           <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} />
-          <iframe src={creditsDashboard} />
-          <Spacer y={2} />
-          <iframe src={invoicesDashboard} />
-          <Spacer y={2} />
-          <iframe src={usageDashboard} />
+
+          <div>
+            {creditGrantsList === undefined ? (
+              <Loading></Loading>
+            ) : creditGrantsList.length === 0 ? (
+              <div>No credit grants available.</div>
+            ) : (
+              creditGrantsList.map((grant) => (
+                <Container>
+                  <Image src={gift} style={{ marginTop: "-2px" }} />
+                  <Spacer inline x={1} />
+                  <Text size={20}>
+                    {grant.balance.including_pending > 0
+                      ? `$${formatCredits(grant.balance.including_pending)}`
+                      : "$ 0.00"}
+                  </Text>
+                </Container>
+              ))
+            )}
+            <Spacer y={1} />
+          </div>
+
+          <Text size={16}>Plan Details</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            View the details of the current plan you are subscribed to.
+          </Text>
+          <Spacer y={1} />
+
+          <div>
+            {plan === undefined ? (
+              <Loading></Loading>
+            ) : (
+              <Fieldset>
+                <Container row>
+                  <Text size={14}>{plan.plan_name}</Text>
+                </Container>
+                {plan.trial_info !== undefined ? (
+                  <div>
+                    <Text size={13}>
+                      Trial Ending {relativeTime(plan.trial_info.ending_before)}
+                    </Text>
+                  </div>
+                ) : (
+                  <div></div>
+                )}
+              </Fieldset>
+            )}
+          </div>
           <Spacer y={2} />
           <Spacer y={2} />
         </div>
         </div>
       ) : (
       ) : (

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

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

+ 51 - 26
internal/billing/metronome.go

@@ -35,6 +35,26 @@ func NewMetronomeClient(metronomeApiKey string) MetronomeClient {
 	}
 	}
 }
 }
 
 
+// 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")
+	defer span.End()
+
+	porterCloudPlanID, err := uuid.Parse(planID)
+	if err != nil {
+		return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error parsing starter plan id")
+	}
+
+	customerID, err = m.createCustomer(ctx, userEmail, projectName, projectID, billingID)
+	if err != nil {
+		return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error while creatinc customer with plan")
+	}
+
+	customerPlanID, err = m.addCustomerPlan(ctx, customerID, porterCloudPlanID)
+
+	return customerID, customerPlanID, err
+}
+
 // createCustomer will create the customer in Metronome
 // createCustomer will create the customer in Metronome
 func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string) (customerID uuid.UUID, err error) {
 func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string) (customerID uuid.UUID, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
 	ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
@@ -63,7 +83,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p
 		Data types.Customer `json:"data"`
 		Data types.Customer `json:"data"`
 	}
 	}
 
 
-	err = post(path, m.ApiKey, customer, &result)
+	err = do(http.MethodPost, path, m.ApiKey, customer, &result)
 	if err != nil {
 	if err != nil {
 		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
 		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
 	}
 	}
@@ -97,7 +117,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 		} `json:"data"`
 		} `json:"data"`
 	}
 	}
 
 
-	err = post(path, m.ApiKey, req, &result)
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
 	if err != nil {
 	if err != nil {
 		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
 		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
 	}
 	}
@@ -105,24 +125,27 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 	return result.Data.CustomerPlanID, nil
 	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()
 	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)
-	if err != nil {
-		return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error while creatinc customer with plan")
+	path := fmt.Sprintf("/customers/%s/plans", customerID)
+
+	var result struct {
+		Data []types.Plan `json:"data"`
 	}
 	}
 
 
-	customerPlanID, err = m.addCustomerPlan(ctx, customerID, porterCloudPlanID)
+	err = do(http.MethodGet, path, m.ApiKey, nil, &result)
+	if err != nil {
+		return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
+	}
 
 
-	return customerID, customerPlanID, err
+	return result.Data[0], nil
 }
 }
 
 
 // EndCustomerPlan will immediately end the plan for the given customer
 // EndCustomerPlan will immediately end the plan for the given customer
@@ -145,7 +168,7 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 		EndingBeforeUTC: endBefore,
 		EndingBeforeUTC: endBefore,
 	}
 	}
 
 
-	err = post(path, m.ApiKey, req, nil)
+	err = do(http.MethodPost, path, m.ApiKey, req, nil)
 	if err != nil {
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "failed to end customer plan")
 		return telemetry.Error(ctx, span, err, "failed to end customer plan")
 	}
 	}
@@ -153,9 +176,9 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 	return nil
 	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 list of credit grants for the customer
+func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits []types.CreditGrant, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
 	defer span.End()
 	defer span.End()
 
 
 	if customerID == uuid.Nil {
 	if customerID == uuid.Nil {
@@ -174,15 +197,15 @@ func (m MetronomeClient) GetCustomerCredits(ctx context.Context, customerID uuid
 		Data []types.CreditGrant `json:"data"`
 		Data []types.CreditGrant `json:"data"`
 	}
 	}
 
 
-	err = post(path, m.ApiKey, req, &result)
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
 	if err != nil {
 	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
+	return result.Data, nil
 }
 }
 
 
-func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uuid.UUID, dashboardType string) (url string, err error) {
+func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uuid.UUID, dashboardType string, options []types.DashboardOptions, colorOverrides []types.ColorOverrides) (url string, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "get-customer-usage-dashboard")
 	ctx, span := telemetry.NewSpan(ctx, "get-customer-usage-dashboard")
 	defer span.End()
 	defer span.End()
 
 
@@ -193,15 +216,17 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 	path := "dashboards/getEmbeddableUrl"
 	path := "dashboards/getEmbeddableUrl"
 
 
 	req := types.EmbeddableDashboardRequest{
 	req := types.EmbeddableDashboardRequest{
-		CustomerID:    customerID,
-		DashboardType: dashboardType,
+		CustomerID:     customerID,
+		Options:        options,
+		DashboardType:  dashboardType,
+		ColorOverrides: colorOverrides,
 	}
 	}
 
 
 	var result struct {
 	var result struct {
 		Data map[string]string `json:"data"`
 		Data map[string]string `json:"data"`
 	}
 	}
 
 
-	err = post(path, m.ApiKey, req, &result)
+	err = do(http.MethodPost, path, m.ApiKey, req, &result)
 	if err != nil {
 	if err != nil {
 		return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
 		return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
 	}
 	}
@@ -209,7 +234,7 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 	return result.Data["url"], nil
 	return result.Data["url"], nil
 }
 }
 
 
-func post(path string, apiKey string, body interface{}, data interface{}) (err error) {
+func do(method string, path string, apiKey string, body interface{}, data interface{}) (err error) {
 	client := http.Client{}
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	if err != nil {
 	if err != nil {
@@ -224,7 +249,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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}