Browse Source

Consolidate usage and billing tabs (#4596)

Co-authored-by: Mauricio Araujo <mauricio.araujo@proton.me>
jusrhee 2 năm trước cách đây
mục cha
commit
219407a09f

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

@@ -153,3 +153,53 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	}
 	c.WriteResult(w, r, usage)
 }
+
+// ListCustomerCostsHandler returns customer usage aggregations like CPU and RAM hours.
+type ListCustomerCostsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListCustomerCostsHandler returns a new ListCustomerCostsHandler
+func NewListCustomerCostsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListCustomerCostsHandler {
+	return &ListCustomerCostsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-costs")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	req := &types.ListCustomerCostsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding list customer costs request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing customer costs")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+	c.WriteResult(w, r, usage)
+}

+ 28 - 0
api/server/router/project.go

@@ -452,6 +452,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing/costs -> project.NewListCustomerCostsHandler
+	listCustomerCostsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/costs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listCustomerCostsHandler := billing.NewListCustomerCostsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listCustomerCostsEndpoint,
+		Handler:  listCustomerCostsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/billing/invoices -> project.NewListCustomerInvoicesHandler
 	listCustomerInvoicesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 34 - 1
api/types/billing_metronome.go

@@ -116,7 +116,40 @@ type BillableMetric struct {
 	Name string    `json:"name"`
 }
 
-// Plan is a pricing plan to which a user is currently subscribed
+// ListCustomerCostsRequest is the request to list costs for a customer
+type ListCustomerCostsRequest struct {
+	StartingOn   string `schema:"starting_on"`
+	EndingBefore string `schema:"ending_before"`
+	Limit        int    `schema:"limit"`
+}
+
+// Cost is the cost for a customer in a specific time range
+type Cost struct {
+	StartTimestamp string                    `json:"start_timestamp"`
+	EndTimestamp   string                    `json:"end_timestamp"`
+	CreditTypes    map[string]CreditTypeCost `json:"credit_types"`
+}
+
+// CreditTypeCost is the cost for a specific credit type (e.g. CPU hours)
+type CreditTypeCost struct {
+	Name              string                  `json:"name"`
+	Cost              float64                 `json:"cost"`
+	LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"`
+}
+
+// LineItemBreakdownCost is the cost breakdown by line item
+type LineItemBreakdownCost struct {
+	Name string  `json:"name"`
+	Cost float64 `json:"cost"`
+}
+
+// FormattedCost is the cost for a customer in a specific time range, flattened from the Metronome response
+type FormattedCost struct {
+	StartTimestamp string  `json:"start_timestamp"`
+	EndTimestamp   string  `json:"end_timestamp"`
+	Cost           float64 `json:"cost"`
+}
+
 type Plan struct {
 	ID                  uuid.UUID `json:"id"`
 	PlanID              uuid.UUID `json:"plan_id"`

+ 7 - 5
dashboard/src/components/TabRegion.tsx

@@ -1,13 +1,13 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import TabSelector from "./TabSelector";
 import Loading from "./Loading";
+import TabSelector from "./TabSelector";
 
-export interface TabOption {
+export type TabOption = {
   label: string;
   value: string;
-}
+};
 
 type PropsType = {
   options: TabOption[];
@@ -31,7 +31,7 @@ export default class TabRegion extends Component<PropsType, StateType> {
       : "";
 
   componentDidUpdate(prevProps: PropsType) {
-    let { options, currentTab } = this.props;
+    const { options, currentTab } = this.props;
     if (prevProps.options !== options) {
       if (options.filter((x) => x.value === currentTab).length === 0) {
         this.props.setCurrentTab(this.defaultTab());
@@ -50,7 +50,9 @@ export default class TabRegion extends Component<PropsType, StateType> {
               options={this.props.options}
               color={this.props.color}
               currentTab={this.props.currentTab}
-              setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
+              setCurrentTab={(x: string) => {
+                this.props.setCurrentTab(x);
+              }}
               addendum={this.props.addendum}
             />
             <Gap />

+ 10 - 2
dashboard/src/lib/billing/types.tsx

@@ -29,10 +29,10 @@ export const PlanValidator = z
 
 export type UsageMetric = z.infer<typeof UsageMetricValidator>;
 export const UsageMetricValidator = z.object({
-  // starting_on and ending_before are ISO 8601 date strings
+  // starting_on and ending_before are RFC 3339 date strings
   // that represent the timeframe where the metric was ingested.
   // If the granularity is set per day, the starting_on field
-  // represents the dat the metric was ingested.
+  // represents the day the metric was ingested.
   starting_on: z.string(),
   ending_before: z.string(),
   value: z.number(),
@@ -51,6 +51,14 @@ export const CreditGrantsValidator = z.object({
   remaining_credits: z.number(),
 });
 
+export type CostList = Cost[];
+export type Cost = z.infer<typeof CostValidator>;
+export const CostValidator = z.object({
+  start_timestamp: z.string(),
+  end_timestamp: z.string(),
+  cost: z.number(),
+});
+
 export type InvoiceList = Invoice[];
 export type Invoice = z.infer<typeof InvoiceValidator>;
 export const InvoiceValidator = z.object({

+ 283 - 0
dashboard/src/lib/hooks/useMetronome.ts

@@ -0,0 +1,283 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import {
+  CostValidator,
+  CreditGrantsValidator,
+  InvoiceValidator,
+  PlanValidator,
+  ReferralDetailsValidator,
+  UsageValidator,
+  type CostList,
+  type CreditGrants,
+  type InvoiceList,
+  type Plan,
+  type ReferralDetails,
+  type UsageList,
+} from "lib/billing/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TGetCredits = {
+  creditGrants: CreditGrants | null;
+};
+
+type TGetPlan = {
+  plan: Plan | null;
+};
+
+type TGetInvoices = {
+  invoiceList: InvoiceList | null;
+};
+
+type TGetUsage = {
+  usage: UsageList | null;
+};
+
+type TGetCosts = {
+  costs: CostList | null;
+};
+
+type TGetReferralDetails = {
+  referralDetails: ReferralDetails;
+};
+
+export const usePorterCredits = (): TGetCredits => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch available credits
+  const creditsReq = useQuery(
+    ["getPorterCredits", currentProject?.id],
+    async (): Promise<CreditGrants | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id || currentProject.id === -1) {
+        return null;
+      }
+
+      try {
+        const res = await api.getPorterCredits(
+          "<token>",
+          {},
+          {
+            project_id: currentProject?.id,
+          }
+        );
+        const creditGrants = CreditGrantsValidator.parse(res.data);
+        return creditGrants;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    creditGrants: creditsReq.data ?? null,
+  };
+};
+
+export const useCustomerPlan = (): TGetPlan => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch current plan
+  const planReq = useQuery(
+    ["getCustomerPlan", currentProject?.id],
+    async (): Promise<Plan | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id) {
+        return null;
+      }
+
+      try {
+        const res = await api.getCustomerPlan(
+          "<token>",
+          {},
+          { project_id: currentProject.id }
+        );
+
+        const plan = PlanValidator.parse(res.data);
+        return plan;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    plan: planReq.data ?? null,
+  };
+};
+
+export const useCustomerUsage = (
+  startingOn: Date | null,
+  endingBefore: Date | null,
+  windowSize: string
+): TGetUsage => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer usage
+  const usageReq = useQuery(
+    ["listCustomerUsage", currentProject?.id],
+    async (): Promise<UsageList | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id || currentProject.id === -1) {
+        return null;
+      }
+
+      if (startingOn === null || endingBefore === null) {
+        return null;
+      }
+
+      try {
+        const res = await api.getCustomerUsage(
+          "<token>",
+          {
+            starting_on: startingOn.toISOString(),
+            ending_before: endingBefore.toISOString(),
+            window_size: windowSize,
+          },
+          {
+            project_id: currentProject?.id,
+          }
+        );
+        const usage = UsageValidator.array().parse(res.data);
+        return usage;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    usage: usageReq.data ?? null,
+  };
+};
+
+export const useCustomerCosts = (
+  startingOn: Date | null,
+  endingBefore: Date | null,
+  limit: number
+): TGetCosts => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer costs
+  const usageReq = useQuery(
+    ["listCustomerCosts", currentProject?.id],
+    async (): Promise<CostList | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id || currentProject.id === -1) {
+        return null;
+      }
+
+      if (startingOn === null || endingBefore === null) {
+        return null;
+      }
+
+      try {
+        const res = await api.getCustomerCosts(
+          "<token>",
+          {},
+          {
+            project_id: currentProject?.id,
+            starting_on: startingOn.toISOString(),
+            ending_before: endingBefore.toISOString(),
+            limit,
+          }
+        );
+
+        const costs = CostValidator.array().parse(res.data);
+        return costs;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    costs: usageReq.data ?? null,
+  };
+};
+
+export const useReferralDetails = (): TGetReferralDetails => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch user's referral code
+  const referralsReq = useQuery(
+    ["getReferralDetails", currentProject?.id],
+    async (): Promise<ReferralDetails | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id || currentProject.id === -1) {
+        return null;
+      }
+
+      try {
+        const res = await api.getReferralDetails(
+          "<token>",
+          {},
+          { project_id: currentProject?.id }
+        );
+
+        const referraldetails = ReferralDetailsValidator.parse(res.data);
+        return referraldetails;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    referralDetails: referralsReq.data ?? null,
+  };
+};
+
+export const useCustomerInvoices = (): TGetInvoices => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer invoices
+  const invoicesReq = useQuery(
+    ["getCustomerInvoices", currentProject?.id],
+    async (): Promise<InvoiceList | null> => {
+      if (!currentProject?.metronome_enabled) {
+        return null;
+      }
+
+      if (!currentProject?.id) {
+        return null;
+      }
+
+      try {
+        const res = await api.getCustomerInvoices(
+          "<token>",
+          {
+            status: "paid",
+          },
+          { project_id: currentProject.id }
+        );
+
+        const invoices = InvoiceValidator.array().parse(res.data);
+        return invoices;
+      } catch (error) {
+        return null;
+      }
+    }
+  );
+
+  return {
+    invoiceList: invoicesReq.data ?? null,
+  };
+};

+ 0 - 215
dashboard/src/lib/hooks/useStripe.tsx → dashboard/src/lib/hooks/useStripe.ts

@@ -4,19 +4,9 @@ import { z } from "zod";
 
 import {
   ClientSecretResponse,
-  CreditGrantsValidator,
-  InvoiceValidator,
   PaymentMethodValidator,
-  PlanValidator,
-  ReferralDetailsValidator,
-  UsageValidator,
-  type CreditGrants,
-  type InvoiceList,
   type PaymentMethod,
   type PaymentMethodList,
-  type Plan,
-  type ReferralDetails,
-  type UsageList,
 } from "lib/billing/types";
 
 import api from "shared/api";
@@ -52,26 +42,6 @@ type TGetPublishableKey = {
   publishableKey: string | null;
 };
 
-type TGetCredits = {
-  creditGrants: CreditGrants | null;
-};
-
-type TGetPlan = {
-  plan: Plan | null;
-};
-
-type TGetInvoices = {
-  invoiceList: InvoiceList | null;
-};
-
-type TGetUsage = {
-  usage: UsageList | null;
-};
-
-type TGetReferralDetails = {
-  referralDetails: ReferralDetails;
-};
-
 export const usePaymentMethods = (): TUsePaymentMethod => {
   const { currentProject } = useContext(Context);
 
@@ -269,188 +239,3 @@ export const usePublishableKey = (): TGetPublishableKey => {
     publishableKey: keyReq.data ?? null,
   };
 };
-
-export const usePorterCredits = (): TGetCredits => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch available credits
-  const creditsReq = useQuery(
-    ["getPorterCredits", currentProject?.id],
-    async (): Promise<CreditGrants | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id || currentProject.id === -1) {
-        return null;
-      }
-
-      try {
-        const res = await api.getPorterCredits(
-          "<token>",
-          {},
-          {
-            project_id: currentProject?.id,
-          }
-        );
-        const creditGrants = CreditGrantsValidator.parse(res.data);
-        return creditGrants;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    creditGrants: creditsReq.data ?? null,
-  };
-};
-
-export const useCustomerPlan = (): TGetPlan => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch current plan
-  const planReq = useQuery(
-    ["getCustomerPlan", currentProject?.id],
-    async (): Promise<Plan | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id) {
-        return null;
-      }
-
-      try {
-        const res = await api.getCustomerPlan(
-          "<token>",
-          {},
-          { project_id: currentProject.id }
-        );
-
-        const plan = PlanValidator.parse(res.data);
-        return plan;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    plan: planReq.data ?? null,
-  };
-};
-
-export const useCustomerUsage = (
-  windowSize: string,
-  currentPeriod: boolean
-): TGetUsage => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch customer usage
-  const usageReq = useQuery(
-    ["listCustomerUsage", currentProject?.id],
-    async (): Promise<UsageList | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id || currentProject.id === -1) {
-        return null;
-      }
-
-      try {
-        const res = await api.getCustomerUsage(
-          "<token>",
-          {
-            window_size: windowSize,
-            current_period: currentPeriod,
-          },
-          {
-            project_id: currentProject?.id,
-          }
-        );
-        const usage = UsageValidator.array().parse(res.data);
-        return usage;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    usage: usageReq.data ?? null,
-  };
-};
-
-export const useReferralDetails = (): TGetReferralDetails => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch user's referral code
-  const referralsReq = useQuery(
-    ["getReferralDetails", currentProject?.id],
-    async (): Promise<ReferralDetails | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id || currentProject.id === -1) {
-        return null;
-      }
-
-      try {
-        const res = await api.getReferralDetails(
-          "<token>",
-          {},
-          { project_id: currentProject?.id }
-        );
-
-        const referraldetails = ReferralDetailsValidator.parse(res.data);
-        return referraldetails;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    referralDetails: referralsReq.data ?? null,
-  };
-};
-
-export const useCustomerInvoices = (): TGetInvoices => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch customer invoices
-  const invoicesReq = useQuery(
-    ["getCustomerInvoices", currentProject?.id],
-    async (): Promise<InvoiceList | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id) {
-        return null;
-      }
-
-      try {
-        const res = await api.getCustomerInvoices(
-          "<token>",
-          {
-            status: "paid",
-          },
-          { project_id: currentProject.id }
-        );
-
-        const invoices = InvoiceValidator.array().parse(res.data);
-        return invoices;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    invoiceList: invoicesReq.data ?? null,
-  };
-};

+ 2 - 1
dashboard/src/main/home/Home.tsx

@@ -18,7 +18,8 @@ import Link from "components/porter/Link";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { checkIfProjectHasPayment, useCustomerPlan } from "lib/hooks/useStripe";
+import { useCustomerPlan } from "lib/hooks/useMetronome";
+import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";
 import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";

+ 64 - 150
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useState } from "react";
 import dayjs from "dayjs";
 import relativeTime from "dayjs/plugin/relativeTime";
 import styled from "styled-components";
@@ -16,13 +16,14 @@ import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
-  checkIfProjectHasPayment,
   useCustomerInvoices,
   useCustomerPlan,
-  useCustomerUsage,
-  usePaymentMethods,
   usePorterCredits,
   useReferralDetails,
+} from "lib/hooks/useMetronome";
+import {
+  checkIfProjectHasPayment,
+  usePaymentMethods,
   useSetDefaultPaymentMethod,
 } from "lib/hooks/useStripe";
 
@@ -32,7 +33,6 @@ import gift from "assets/gift.svg";
 import trashIcon from "assets/trash.png";
 
 import BillingModal from "../modals/BillingModal";
-import Bars from "./Bars";
 
 dayjs.extend(relativeTime);
 
@@ -57,45 +57,10 @@ function BillingPage(): JSX.Element {
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
-  const { usage } = useCustomerUsage("day", true);
-
-  const processedData = useMemo(() => {
-    const before = usage;
-    const resultMap = new Map();
-
-    before?.forEach(
-      (metric: {
-        metric_name: string;
-        usage_metrics: Array<{ starting_on: string; value: number }>;
-      }) => {
-        const metricName = metric.metric_name.toLowerCase().replace(" ", "_");
-        metric.usage_metrics.forEach(({ starting_on, value }) => {
-          if (resultMap.has(starting_on)) {
-            resultMap.get(starting_on)[metricName] = value;
-          } else {
-            resultMap.set(starting_on, {
-              starting_on: new Date(starting_on).toLocaleDateString("en-US", {
-                month: "short",
-                day: "numeric",
-              }),
-              [metricName]: value,
-            });
-          }
-        });
-      }
-    );
-
-    // Convert the map to an array of values
-    const x = Array.from(resultMap.values());
-    return x;
-  }, [usage]);
-
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
   };
 
-  const readableDate = (s: string): string => new Date(s).toLocaleDateString();
-
   const onCreate = async (): Promise<void> => {
     await refetchPaymentMethods({ throwOnError: false, cancelRefetch: false });
     setShouldCreate(false);
@@ -250,107 +215,67 @@ function BillingPage(): JSX.Element {
       </Button>
       <Spacer y={2} />
 
-      {currentProject?.metronome_enabled && (
-        <div>
-          {currentProject?.sandbox_enabled && (
-            <div>
-              <Text size={16}>Porter credit grants</Text>
-              <Spacer y={1} />
-              <Text color="helper">
-                No usage data available for this billing period.
-              </Text>
-              <Spacer y={1} />
-
-              <Container>
-                <Image src={gift} style={{ marginTop: "-2px" }} />
-                <Spacer inline x={1} />
-                <Text size={20}>
-                  {creditGrants && 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>
+      <Text size={16}>Invoice history</Text>
+      <Spacer y={1} />
+      <Text color="helper">
+        View all invoices from Porter over the past 12 months.
+      </Text>
+      <Spacer y={1} />
+      {invoiceList?.map((invoice, i) => {
+        return (
+          <>
+            <Container row key={i}>
+              <Link target="_blank" to={invoice.hosted_invoice_url}>
+                {dayjs(invoice.created).format("DD/MM/YYYY")}
+              </Link>
+            </Container>
             <Spacer y={1} />
+          </>
+        );
+      })}
 
-            {plan && plan.plan_name !== "" ? (
-              <div>
-                <Text>Active Plan</Text>
-                <Spacer y={0.5} />
-                <Fieldset row>
-                  <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{" "}
-                          {dayjs().to(dayjs(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} />
-                {usage?.length &&
-                usage.length > 0 &&
-                usage[0].usage_metrics.length > 0 ? (
-                  <Flex>
-                    <BarWrapper>
-                      <Bars
-                        title="GiB Hours"
-                        fill="#8784D2"
-                        yKey="gib_hours"
-                        xKey="starting_on"
-                        data={processedData}
-                      />
-                    </BarWrapper>
-                    <Spacer x={1} inline />
-                    <BarWrapper>
-                      <Bars
-                        title="CPU Hours"
-                        fill="#5886E0"
-                        yKey="cpu_hours"
-                        xKey="starting_on"
-                        data={processedData}
-                      />
-                    </BarWrapper>
-                  </Flex>
-                ) : (
-                  <Fieldset>
-                    <Text color="helper">
-                      No usage data available for this billing period.
-                    </Text>
-                  </Fieldset>
-                )}
-                <Spacer y={2} />
-              </div>
-            ) : (
-              <Text>This project does not have an active billing plan.</Text>
-            )}
-          </div>
-        </div>
+      {showReferralModal && (
+        <Modal
+          closeModal={() => {
+            setShowReferralModal(false);
+          }}
+        >
+          <Text size={16}>Refer users to Porter</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Earn $10 in free credits for each user you refer to Porter. Referred
+            users need to connect a payment method for credits to be added to
+            your account.
+          </Text>
+          <Spacer y={1} />
+          <Container row>
+            <ReferralCode>
+              Referral code:{" "}
+              {currentProject?.referral_code ? (
+                <Code>{currentProject.referral_code}</Code>
+              ) : (
+                "n/a"
+              )}
+            </ReferralCode>
+            <Spacer inline x={1} />
+            <CopyToClipboard
+              text={
+                window.location.origin +
+                "/register?referral=" +
+                currentProject?.referral_code
+              }
+              tooltip="Copied to clipboard"
+            >
+              <CopyButton>Copy referral link</CopyButton>
+            </CopyToClipboard>
+          </Container>
+          <Spacer y={1} />
+          <Text color="helper">
+            You have referred{" "}
+            {referralDetails ? referralDetails.referral_count : "?"}/
+            {referralDetails?.max_allowed_referrals} users.
+          </Text>
+        </Modal>
       )}
     </>
   );
@@ -377,17 +302,6 @@ const ReferralCode = styled.div`
   width: fit-content;
 `;
 
-const Flex = styled.div`
-  display: flex;
-  flex-wrap: wrap;
-`;
-
-const BarWrapper = styled.div`
-  flex: 1;
-  height: 300px;
-  min-width: 450px;
-`;
-
 const I = styled.i`
   font-size: 16px;
   margin-right: 8px;

+ 14 - 1
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -29,6 +29,7 @@ import BillingPage from "./BillingPage";
 import InvitePage from "./InviteList";
 import Metadata from "./Metadata";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
+import UsagePage from "./UsagePage";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 type ValidationError = {
@@ -95,6 +96,16 @@ function ProjectSettings(props: any) {
         });
       }
 
+      if (
+        currentProject?.billing_enabled &&
+        currentProject?.metronome_enabled
+      ) {
+        tabOpts.push({
+          value: "usage",
+          label: "Usage",
+        });
+      }
+
       tabOpts.push({
         value: "additional-settings",
         label: "Additional settings",
@@ -166,7 +177,9 @@ function ProjectSettings(props: any) {
     } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
     } else if (currentTab === "billing") {
-      return <BillingPage></BillingPage>;
+      return <BillingPage />;
+    } else if (currentTab === "usage") {
+      return <UsagePage />;
     } else {
       return (
         <>

+ 200 - 0
dashboard/src/main/home/project-settings/UsagePage.tsx

@@ -0,0 +1,200 @@
+import React, { useMemo, useState } from "react";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import styled from "styled-components";
+
+import Fieldset from "components/porter/Fieldset";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  useCustomerCosts,
+  useCustomerPlan,
+  useCustomerUsage,
+} from "lib/hooks/useMetronome";
+
+import Bars from "./Bars";
+
+dayjs.extend(utc);
+
+function UsagePage(): JSX.Element {
+  const { plan } = useCustomerPlan();
+
+  const startDate = dayjs.utc(plan?.starting_on);
+  const endDate = dayjs().utc().startOf("day");
+  const numberOfDays = startDate.daysInMonth();
+
+  const [currentPeriodStart, setCurrentPeriodStart] = useState(
+    startDate.toDate()
+  );
+  const [currentPeriodEnd, setCurrentPeriodEnd] = useState(endDate.toDate());
+  const [currentPeriodDuration, setCurrentPeriodDuration] =
+    useState(numberOfDays);
+
+  const { usage } = useCustomerUsage(
+    currentPeriodStart,
+    currentPeriodEnd,
+    "day"
+  );
+  const { costs } = useCustomerCosts(
+    currentPeriodStart,
+    currentPeriodEnd,
+    currentPeriodDuration
+  );
+  let totalCost = 0;
+
+  const processedUsage = useMemo(() => {
+    const before = usage;
+    const resultMap = new Map();
+
+    before?.forEach(
+      (metric: {
+        metric_name: string;
+        usage_metrics: Array<{ starting_on: string; value: number }>;
+      }) => {
+        const metricName = metric.metric_name.toLowerCase().replace(" ", "_");
+        metric.usage_metrics.forEach(({ starting_on: startingOn, value }) => {
+          if (resultMap.has(startingOn)) {
+            resultMap.get(startingOn)[metricName] = value;
+          } else {
+            resultMap.set(startingOn, {
+              starting_on: new Date(startingOn).toLocaleDateString("en-US", {
+                month: "short",
+                day: "numeric",
+              }),
+              [metricName]: value,
+            });
+          }
+        });
+      }
+    );
+
+    // Convert the map to an array of values
+    const x = Array.from(resultMap.values());
+    return x;
+  }, [usage]);
+
+  const processedCosts = useMemo(() => {
+    return costs
+      ?.map((dailyCost) => {
+        dailyCost.start_timestamp = new Date(
+          dailyCost.start_timestamp
+        ).toLocaleDateString("en-US", {
+          month: "short",
+          day: "numeric",
+        });
+        dailyCost.cost = parseFloat((dailyCost.cost / 100).toFixed(4));
+        totalCost += dailyCost.cost;
+        return dailyCost;
+      })
+      .filter((dailyCost) => dailyCost.cost > 0);
+  }, [costs]);
+
+  const generateOptions = (): Array<{ value: string; label: string }> => {
+    const options = [];
+
+    let startDate = dayjs.utc(currentPeriodStart);
+    const endDate = dayjs.utc(currentPeriodEnd);
+
+    while (startDate.isBefore(endDate)) {
+      const nextDate = startDate.add(1, "month");
+      options.push({
+        value: startDate.format("M-D-YY"),
+        label: `${startDate.format("M/D/YY")} - ${nextDate.format("M/D/YY")}`,
+      });
+
+      startDate = startDate.add(1, "month");
+    }
+    return options;
+  };
+
+  const options = generateOptions();
+
+  return (
+    <>
+      <Select
+        options={options}
+        value={currentPeriodStart.toISOString()}
+        setValue={(value) => {
+          setCurrentPeriodStart(dayjs.utc(value).toDate());
+          setCurrentPeriodEnd(dayjs.utc(value).add(1, "month").toDate());
+          setCurrentPeriodDuration(dayjs.utc(value).daysInMonth());
+        }}
+        width="fit-content"
+        prefix={<>Billing period</>}
+      />
+      <Spacer y={1} />
+      {costs &&
+      costs.length > 0 &&
+      usage &&
+      usage.length > 0 &&
+      usage[0].usage_metrics.length > 0 ? (
+        <>
+          <BarWrapper>
+            <Total>Total cost: ${totalCost.toFixed(2)}</Total>
+            <Bars
+              fill="#8784D2"
+              yKey="cost"
+              xKey="start_timestamp"
+              data={processedCosts || []}
+            />
+          </BarWrapper>
+          <Spacer y={0.5} />
+          <Flex>
+            <BarWrapper>
+              <Bars
+                title="GiB Hours"
+                fill="#8784D2"
+                yKey="gib_hours"
+                xKey="starting_on"
+                data={processedUsage}
+              />
+            </BarWrapper>
+            <Spacer x={1} inline />
+            <BarWrapper>
+              <Bars
+                title="CPU Hours"
+                fill="#5886E0"
+                yKey="cpu_hours"
+                xKey="starting_on"
+                data={processedUsage}
+              />
+            </BarWrapper>
+          </Flex>
+        </>
+      ) : (
+        <Fieldset>
+          <Text color="helper">
+            No usage data available for this billing period.
+          </Text>
+        </Fieldset>
+      )}
+    </>
+  );
+}
+
+export default UsagePage;
+
+const Total = styled.div`
+  position: absolute;
+  top: 20px;
+  left: 15px;
+  font-size: 13px;
+  background: #42444933;
+  backdrop-filter: saturate(150%) blur(8px);
+  padding: 7px 10px;
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+`;
+
+const BarWrapper = styled.div`
+  flex: 1;
+  height: 300px;
+  min-width: 450px;
+  position: relative;
+`;

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

@@ -3527,6 +3527,20 @@ const getCustomerUsage = baseApi<
   }
 >("POST", ({ project_id }) => `/api/projects/${project_id}/billing/usage`);
 
+const getCustomerCosts = baseApi<
+  {},
+  {
+    project_id?: number;
+    starting_on: string;
+    ending_before: string;
+    limit: number;
+  }
+>(
+  "GET",
+  ({ project_id, starting_on, ending_before, limit }) =>
+    `/api/projects/${project_id}/billing/costs?starting_on=${starting_on}&ending_before=${ending_before}&limit=${limit}`
+);
+
 const getCustomerInvoices = baseApi<
   {
     status: string;
@@ -3995,6 +4009,7 @@ export default {
   getPorterCredits,
   getCustomerPlan,
   getCustomerUsage,
+  getCustomerCosts,
   getCustomerInvoices,
   listPaymentMethod,
   addPaymentMethod,

+ 52 - 11
internal/billing/metronome.go

@@ -120,7 +120,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p
 		Data types.Customer `json:"data"`
 	}
 
-	_, err = m.do(http.MethodPost, path, customer, &result)
+	_, err = m.do(http.MethodPost, path, "", customer, &result)
 	if err != nil {
 		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
 	}
@@ -160,7 +160,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 		} `json:"data"`
 	}
 
-	_, err = m.do(http.MethodPost, path, req, &result)
+	_, err = m.do(http.MethodPost, path, "", req, &result)
 	if err != nil {
 		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
 	}
@@ -183,7 +183,7 @@ func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.U
 		Data []types.Plan `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, nil, &result)
+	_, err = m.do(http.MethodGet, path, "", nil, &result)
 	if err != nil {
 		return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
 	}
@@ -215,7 +215,7 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 		EndingBeforeUTC: endBefore,
 	}
 
-	_, err = m.do(http.MethodPost, path, req, nil)
+	_, err = m.do(http.MethodPost, path, "", req, nil)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "failed to end customer plan")
 	}
@@ -244,7 +244,7 @@ func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uui
 		Data []types.CreditGrant `json:"data"`
 	}
 
-	_, err = m.do(http.MethodPost, path, req, &result)
+	_, err = m.do(http.MethodPost, path, "", req, &result)
 	if err != nil {
 		return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
 	}
@@ -290,7 +290,7 @@ func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid
 		Priority:  1,
 	}
 
-	statusCode, err := m.do(http.MethodPost, path, req, nil)
+	statusCode, err := m.do(http.MethodPost, path, "", req, nil)
 	if err != nil && statusCode != http.StatusConflict {
 		// a conflict response indicates the grant already exists
 		return telemetry.Error(ctx, span, err, "failed to create credits grant")
@@ -342,7 +342,7 @@ func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.
 		}
 
 		baseReq.BillableMetricID = billableMetric.ID
-		_, err = m.do(http.MethodPost, path, baseReq, &result)
+		_, err = m.do(http.MethodPost, path, "", baseReq, &result)
 		if err != nil {
 			return usage, telemetry.Error(ctx, span, err, "failed to get customer usage")
 		}
@@ -356,6 +356,42 @@ func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.
 	return usage, nil
 }
 
+// ListCustomerCosts will return the costs for a customer over a time period
+func (m MetronomeClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-costs")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return costs, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	path := fmt.Sprintf("customers/%s/costs", customerID)
+
+	var result struct {
+		Data []types.Cost `json:"data"`
+	}
+
+	queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOn, endingBefore, limit)
+
+	_, err = m.do(http.MethodGet, path, queryParams, nil, &result)
+	if err != nil {
+		return costs, telemetry.Error(ctx, span, err, "failed to create credits grant")
+	}
+
+	for _, customerCost := range result.Data {
+		formattedCost := types.FormattedCost{
+			StartTimestamp: customerCost.StartTimestamp,
+			EndTimestamp:   customerCost.EndTimestamp,
+		}
+		for _, creditType := range customerCost.CreditTypes {
+			formattedCost.Cost += creditType.Cost
+		}
+		costs = append(costs, formattedCost)
+	}
+
+	return costs, nil
+}
+
 // IngestEvents sends a list of billing events to Metronome's ingest endpoint
 func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
@@ -378,7 +414,7 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 		// Retry each batch to make sure all events are ingested
 		var currentAttempts int
 		for currentAttempts < defaultMaxRetries {
-			statusCode, err := m.do(http.MethodPost, path, batch, nil)
+			statusCode, err := m.do(http.MethodPost, path, "", batch, nil)
 			// Check errors that are not from error http codes
 			if statusCode == 0 && err != nil {
 				return telemetry.Error(ctx, span, err, "failed to ingest billing events")
@@ -422,7 +458,7 @@ func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID u
 		Data []types.BillableMetric `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, nil, &result)
+	_, err = m.do(http.MethodGet, path, "", nil, &result)
 	if err != nil {
 		return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
 	}
@@ -440,7 +476,7 @@ func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode strin
 		Data []types.PricingUnit `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, nil, &result)
+	_, err = m.do(http.MethodGet, path, "", nil, &result)
 	if err != nil {
 		return creditTypeID, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
 	}
@@ -454,7 +490,7 @@ func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode strin
 	return creditTypeID, telemetry.Error(ctx, span, fmt.Errorf("credit type not found for currency code %s", currencyCode), "failed to find credit type")
 }
 
-func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
+func (m MetronomeClient) do(method string, path string, queryParams string, body interface{}, data interface{}) (statusCode int, err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	if err != nil {
@@ -469,6 +505,11 @@ func (m MetronomeClient) do(method string, path string, body interface{}, data i
 		}
 	}
 
+	// Add raw query parameters to the endpoint
+	if queryParams != "" {
+		endpoint += "?" + queryParams
+	}
+
 	req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
 	if err != nil {
 		return statusCode, err