Переглянути джерело

Add customer costs endpoint

Mauricio Araujo 2 роки тому
батько
коміт
9a8b2b3505

+ 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{

+ 26 - 1
api/types/billing_metronome.go

@@ -116,7 +116,32 @@ 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"`
+}
+
+type Cost struct {
+	StartTimestamp string                    `json:"start_timestamp"`
+	EndTimestamp   string                    `json:"end_timestamp"`
+	CreditTypes    map[string]CreditTypeCost `json:"credit_types"`
+}
+
+type CreditTypeCost struct {
+	Name              string                  `json:"name"`
+	Cost              float64                 `json:"cost"`
+	LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"`
+}
+
+type LineItemBreakdownCost struct {
+	Name       string  `json:"name"`
+	Cost       float64 `json:"cost"`
+	GroupKey   string  `json:"group_key"`
+	GroupValue string  `json:"group_value"`
+}
+
 type Plan struct {
 	ID                  uuid.UUID `json:"id"`
 	PlanID              uuid.UUID `json:"plan_id"`

+ 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(),
+  credit_types: z.any(),
+});
+
 export type InvoiceList = Invoice[];
 export type Invoice = z.infer<typeof InvoiceValidator>;
 export const InvoiceValidator = z.object({

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

@@ -0,0 +1,273 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import {
+  CostList,
+  CostValidator,
+  CreditGrantsValidator,
+  InvoiceValidator,
+  PlanValidator,
+  ReferralDetailsValidator,
+  UsageValidator,
+  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 = (
+  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 useCustomerCosts = (
+  startingOn: string,
+  endingBefore: string,
+  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;
+      }
+
+      try {
+        const res = await api.getCustomerCosts(
+          "<token>",
+          {},
+          {
+            project_id: currentProject?.id,
+            starting_on: startingOn,
+            ending_before: endingBefore,
+            limit: 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";

+ 4 - 2
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -16,12 +16,14 @@ import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
-  checkIfProjectHasPayment,
   useCustomerInvoices,
   useCustomerPlan,
-  usePaymentMethods,
   usePorterCredits,
   useReferralDetails,
+} from "lib/hooks/useMetronome";
+import {
+  checkIfProjectHasPayment,
+  usePaymentMethods,
   useSetDefaultPaymentMethod,
 } from "lib/hooks/useStripe";
 

+ 17 - 5
dashboard/src/main/home/project-settings/UsagePage.tsx

@@ -1,18 +1,30 @@
-import React, { useContext, useMemo, useState } from "react";
+import React, { useMemo, useState } from "react";
 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 { useCustomerUsage } from "lib/hooks/useStripe";
+import {
+  useCustomerCosts,
+  useCustomerPlan,
+  useCustomerUsage,
+} from "lib/hooks/useMetronome";
 
 import Bars from "./Bars";
 
 function UsagePage(): JSX.Element {
-  const [currentPeriod, setCurrentPeriod] = useState("4-17-24");
+  const [currentPeriodStart, setCurrentPeriodStart] = useState("4-17-24");
+  const [currentPeriodEnd, setCurrentPeriodEnd] = useState("4-17-24");
+  const costLimitDays = 30;
 
   const { usage } = useCustomerUsage("day", true);
+  const { costs } = useCustomerCosts(
+    currentPeriodStart,
+    currentPeriodEnd,
+    costLimitDays
+  );
+  const { plan } = useCustomerPlan();
 
   const processedData = useMemo(() => {
     const before = usage;
@@ -55,9 +67,9 @@ function UsagePage(): JSX.Element {
           { value: "1-17-24", label: "1/17/24 - 2/17/24" },
           { value: "12-17-23", label: "12/17/23 - 1/17/24" },
         ]}
-        value={currentPeriod}
+        value={currentPeriodStart}
         setValue={(value) => {
-          setCurrentPeriod(value);
+          setCurrentPeriodStart(value);
         }}
         width="fit-content"
         prefix={<>Billing period</>}

+ 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,

+ 41 - 11
internal/billing/metronome.go

@@ -119,7 +119,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")
 	}
@@ -159,7 +159,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")
 	}
@@ -182,7 +182,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")
 	}
@@ -214,7 +214,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")
 	}
@@ -243,7 +243,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")
 	}
@@ -289,7 +289,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")
@@ -341,7 +341,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")
 		}
@@ -355,6 +355,31 @@ 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.Cost, 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")
+	}
+
+	return result.Data, 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")
@@ -368,7 +393,7 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 
 	var currentAttempts int
 	for currentAttempts < defaultMaxRetries {
-		statusCode, err := m.do(http.MethodPost, path, events, nil)
+		statusCode, err := m.do(http.MethodPost, path, "", events, 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")
@@ -407,7 +432,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")
 	}
@@ -425,7 +450,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")
 	}
@@ -439,7 +464,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 {
@@ -454,6 +479,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