Mauricio Araujo 2 лет назад
Родитель
Сommit
ff10a0d547

+ 1 - 1
api/server/handlers/billing/plan.go

@@ -149,7 +149,7 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
 	)
 
-	usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod)
+	usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod, req.PreviousPeriods)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error listing customer usage")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 3 - 0
api/types/billing_usage.go

@@ -8,6 +8,9 @@ type ListCreditGrantsResponse struct {
 
 // ListCustomerUsageRequest is the request to list usage for a customer
 type ListCustomerUsageRequest struct {
+	// PreviousPeriods is the number of previous periods to include in the response.
+	PreviousPeriods int `json:"previous_periods,omitempty"`
+	// CurrentPeriod is whether to return only usage for the current billing period.
 	CurrentPeriod bool `json:"current_period,omitempty"`
 }
 

+ 7 - 13
dashboard/src/lib/hooks/useLago.ts

@@ -30,7 +30,7 @@ type TGetInvoices = {
 };
 
 type TGetUsage = {
-  usage: Usage | null;
+  usageList: Usage[] | null;
 };
 
 type TGetReferralDetails = {
@@ -108,16 +108,15 @@ export const useCustomerPlan = (): TGetPlan => {
 };
 
 export const useCustomerUsage = (
-  startingOn: Date | null,
-  endingBefore: Date | null,
+  previousPeriods: number,
   currentPeriod: boolean
 ): TGetUsage => {
   const { currentProject } = useContext(Context);
 
   // Fetch customer usage
   const usageReq = useQuery(
-    ["listCustomerUsage", currentProject?.id],
-    async (): Promise<Usage | null> => {
+    ["listCustomerUsage", currentProject?.id, previousPeriods, currentPeriod],
+    async (): Promise<Usage[] | null> => {
       if (!currentProject?.metronome_enabled) {
         return null;
       }
@@ -126,23 +125,18 @@ export const useCustomerUsage = (
         return null;
       }
 
-      if (startingOn === null || endingBefore === null) {
-        return null;
-      }
-
       try {
         const res = await api.getCustomerUsage(
           "<token>",
           {
-            starting_on: startingOn.toISOString(),
-            ending_before: endingBefore.toISOString(),
+            previous_periods: previousPeriods,
             current_period: currentPeriod,
           },
           {
             project_id: currentProject?.id,
           }
         );
-        const usage = UsageValidator.parse(res.data);
+        const usage = UsageValidator.array().parse(res.data);
         return usage;
       } catch (error) {
         return null;
@@ -151,7 +145,7 @@ export const useCustomerUsage = (
   );
 
   return {
-    usage: usageReq.data ?? null,
+    usageList: usageReq.data ?? null,
   };
 };
 

+ 76 - 142
dashboard/src/main/home/project-settings/UsagePage.tsx

@@ -1,168 +1,127 @@
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import dayjs from "dayjs";
 import utc from "dayjs/plugin/utc";
-import styled from "styled-components";
 
 import Container from "components/porter/Container";
 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 { type CostList } from "lib/billing/types";
-import {
-  useCustomerCosts,
-  useCustomerPlan,
-  useCustomerUsage,
-} from "lib/hooks/useLago";
-
-import Bars from "./Bars";
+import { useCustomerPlan, useCustomerUsage } from "lib/hooks/useLago";
 
 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 planStartDate = dayjs.utc(plan?.starting_on);
+
+  const [currentPeriod, setCurrentPeriod] = useState(planStartDate);
+  const [options, setOptions] = useState<
+    Array<{ value: string; label: string }>
+  >([]);
+  const [previousPeriodCount, setPreviousPeriodCount] = useState(0);
+  const [showCurrentPeriod, setShowCurrentPeriod] = useState(true);
+
+  const { usageList } = useCustomerUsage(
+    previousPeriodCount,
+    showCurrentPeriod
   );
-  const { costs } = useCustomerCosts(
-    currentPeriodStart,
-    currentPeriodEnd,
-    currentPeriodDuration
-  );
-
-  const computeTotalCost = (costs: CostList): number => {
-    const total = costs.reduce((acc, curr) => acc + curr.cost, 0);
-    return parseFloat(total.toFixed(2));
-  };
-
-  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));
-        return dailyCost;
-      })
-      .filter((dailyCost) => dailyCost.cost > 0);
-  }, [costs]);
+  useEffect(() => {
+    const newOptions = generateOptions();
+    setOptions(newOptions);
+  }, [previousPeriodCount, showCurrentPeriod]);
 
   const generateOptions = (): Array<{ value: string; label: string }> => {
     const options = [];
+    const monthsElapsed = dayjs
+      .utc()
+      .startOf("month")
+      .diff(planStartDate.utc().startOf("month"), "month");
 
-    let startDate = dayjs.utc(currentPeriodStart);
-    const endDate = dayjs.utc(currentPeriodEnd);
-
-    while (startDate.isBefore(endDate)) {
-      const nextDate = startDate.add(1, "month");
+    if (monthsElapsed <= 0) {
       options.push({
-        value: startDate.toISOString(),
-        label: `${startDate.format("M/D/YY")} - ${nextDate.format("M/D/YY")}`,
+        value: currentPeriod.toISOString(),
+        label: dayjs().utc().format("MMMM YYYY"),
       });
+      setShowCurrentPeriod(true);
+      return options;
+    }
 
-      startDate = startDate.add(1, "month");
+    setPreviousPeriodCount(monthsElapsed);
+    for (let i = 0; i <= monthsElapsed; i++) {
+      const optionDate = planStartDate.add(i, "month");
+      options.push({
+        value: optionDate.toISOString(),
+        label: optionDate.format("MMMM YYYY"),
+      });
     }
+
     return options;
   };
 
-  const options = generateOptions();
+  const processedUsage = useMemo(() => {
+    if (!usageList || !usageList.length) {
+      return null;
+    }
+
+    const periodUsage = usageList.find((usage) =>
+      dayjs(usage.from_datetime).isSame(currentPeriod.month(), "month")
+    );
+    const totalCost = periodUsage?.total_amount_cents
+      ? (periodUsage.total_amount_cents / 100).toFixed(4)
+      : "";
+    const totalCpuHours =
+      periodUsage?.charges_usage.find((x) =>
+        x.billable_metric.name.includes("CPU")
+      )?.units ?? "";
+    const totalGibHours =
+      periodUsage?.charges_usage.find((x) =>
+        x.billable_metric.name.includes("GiB")
+      )?.units ?? "";
+    const currency = periodUsage?.charges_usage[0].amount_currency ?? "";
+    return {
+      total_cost: totalCost,
+      total_cpu_hours: totalCpuHours,
+      total_gib_hours: totalGibHours,
+      currency,
+    };
+  }, [usageList]);
 
   return (
     <>
       <Select
         options={options}
-        value={currentPeriodStart.toISOString()}
+        value={currentPeriod.toISOString()}
         setValue={(value) => {
-          setCurrentPeriodStart(dayjs.utc(value).toDate());
-          setCurrentPeriodEnd(dayjs.utc(value).add(1, "month").toDate());
-          setCurrentPeriodDuration(dayjs.utc(value).daysInMonth());
+          setCurrentPeriod(dayjs.utc(value));
+          if (dayjs(value).isSame(dayjs(), "month")) {
+            setShowCurrentPeriod(true);
+          } else {
+            setShowCurrentPeriod(false);
+          }
         }}
         width="fit-content"
         prefix={<>Billing period</>}
       />
       <Spacer y={1} />
-      {processedCosts &&
-      processedCosts.length > 0 &&
-      processedUsage &&
-      processedUsage.length > 0 ? (
+      {processedUsage ? (
         <>
           <Text color="helper">Total usage (selected period):</Text>
           <Spacer y={0.5} />
           <Container row>
             <Fieldset>
-              <Text size={16}>$ 26.78</Text>
-            </Fieldset>
-            <Spacer inline x={1} />
-            <Fieldset>
-              <Text size={16}>5.18 GiB hours</Text>
-            </Fieldset>
-            <Spacer inline x={1} />
-            <Fieldset>
-              <Text size={16}>1.78 CPU hours</Text>
-            </Fieldset>
-          </Container>
-          <Spacer y={1} />
-          <Text color="helper">Daily average (selected period):</Text>
-          <Spacer y={0.5} />
-          <Container row>
-            <Fieldset>
-              <Text size={16}>$ 3.62</Text>
+              <Text size={16}>
+                $ {processedUsage.total_cost} {processedUsage.currency}
+              </Text>
             </Fieldset>
             <Spacer inline x={1} />
             <Fieldset>
-              <Text size={16}>0.51 GiB hours</Text>
+              <Text size={16}>{processedUsage.total_gib_hours} GiB hours</Text>
             </Fieldset>
             <Spacer inline x={1} />
             <Fieldset>
-              <Text size={16}>0.18 CPU hours</Text>
+              <Text size={16}>{processedUsage.total_cpu_hours} CPU hours</Text>
             </Fieldset>
           </Container>
         </>
@@ -178,28 +137,3 @@ function UsagePage(): JSX.Element {
 }
 
 export default UsagePage;
-
-const Total = styled.div`
-  position: absolute;
-  top: 20px;
-  left: 55px;
-  font-size: 13px;
-  background: #42444933;
-  backdrop-filter: saturate(150%) blur(8px);
-  padding: 7px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  z-index: 999;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  flex-wrap: wrap;
-`;
-
-const BarWrapper = styled.div`
-  flex: 1;
-  height: 300px;
-  min-width: 450px;
-  position: relative;
-`;

+ 1 - 2
dashboard/src/shared/api.tsx

@@ -3542,8 +3542,7 @@ const getPublishableKey = baseApi<
 
 const getCustomerUsage = baseApi<
   {
-    starting_on?: string;
-    ending_before?: string;
+    previous_periods?: number;
     current_period?: boolean;
   },
   {

+ 50 - 26
internal/billing/usage.go

@@ -58,7 +58,7 @@ func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandard
 	if lagoClient == nil {
 		return client, fmt.Errorf("failed to create lago client")
 	}
-	// lagoClient.Debug = true
+	lagoClient.Debug = true
 
 	return LagoClient{
 		lagoApiKey:               lagoApiKey,
@@ -151,11 +151,6 @@ func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sa
 		return plan, telemetry.Error(ctx, span, err, "project id empty")
 	}
 
-	if sandboxEnabled {
-		subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
-		return types.Plan{ID: subscriptionID}, nil
-	}
-
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
 	subscriptionListInput := lago.SubscriptionListInput{
 		ExternalCustomerID: customerID,
@@ -178,7 +173,10 @@ func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sa
 		plan.ID = subscription.ExternalID
 		plan.CustomerID = subscription.ExternalCustomerID
 		plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
-		plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+
+		if subscription.EndingAt != nil {
+			plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+		}
 
 		if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
 			plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
@@ -296,12 +294,12 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 }
 
 // ListCustomerUsage will return the aggregated usage for a customer
-func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool) (usage types.Usage, err error) {
+func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool, previousPeriods int) (usageList []types.Usage, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
 	defer span.End()
 
 	if subscriptionID == "" {
-		return usage, telemetry.Error(ctx, span, err, "subscription id empty")
+		return usageList, telemetry.Error(ctx, span, err, "subscription id empty")
 	}
 
 	if currentPeriod {
@@ -311,27 +309,32 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, su
 
 		currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
 		if lagoErr != nil {
-			return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
+			return usageList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
 		}
 
-		usage.FromDatetime = currentUsage.FromDatetime.Format(time.RFC3339)
-		usage.ToDatetime = currentUsage.ToDatetime.Format(time.RFC3339)
-		usage.TotalAmountCents = int64(currentUsage.TotalAmountCents)
-		usage.ChargesUsage = make([]types.ChargeUsage, len(currentUsage.ChargesUsage))
-
-		for i, charge := range currentUsage.ChargesUsage {
-			usage.ChargesUsage[i] = types.ChargeUsage{
-				Units:          charge.Units,
-				AmountCents:    int64(charge.AmountCents),
-				AmountCurrency: string(charge.AmountCurrency),
-				BillableMetric: types.BillableMetric{
-					Name: charge.BillableMetric.Name,
-				},
-			}
+		if currentUsage == nil {
+			return usageList, nil
+		}
+
+		usage := createUsageFromLagoUsage(*currentUsage)
+		usageList = append(usageList, usage)
+	} else {
+		customerPastUsageInput := &lago.CustomerPastUsageInput{
+			PeriodsCount:           previousPeriods,
+			ExternalSubscriptionID: subscriptionID,
 		}
-	}
 
-	return usage, nil
+		previousUsage, lagoErr := m.client.Customer().PastUsage(ctx, customerID, customerPastUsageInput)
+		if lagoErr != nil {
+			return usageList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
+		}
+
+		for _, pastUsage := range previousUsage.UsagePeriods {
+			usage := createUsageFromLagoUsage(pastUsage)
+			usageList = append(usageList, usage)
+		}
+	}
+	return usageList, nil
 }
 
 // IngestEvents sends a list of billing events to Lago's ingest endpoint
@@ -486,6 +489,27 @@ func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, plan
 	return nil
 }
 
+func createUsageFromLagoUsage(lagoUsage lago.CustomerUsage) types.Usage {
+	usage := types.Usage{}
+	usage.FromDatetime = lagoUsage.FromDatetime.Format(time.RFC3339)
+	usage.ToDatetime = lagoUsage.ToDatetime.Format(time.RFC3339)
+	usage.TotalAmountCents = int64(lagoUsage.TotalAmountCents)
+	usage.ChargesUsage = make([]types.ChargeUsage, len(lagoUsage.ChargesUsage))
+
+	for i, charge := range lagoUsage.ChargesUsage {
+		usage.ChargesUsage[i] = types.ChargeUsage{
+			Units:          charge.Units,
+			AmountCents:    int64(charge.AmountCents),
+			AmountCurrency: string(charge.AmountCurrency),
+			BillableMetric: types.BillableMetric{
+				Name: charge.BillableMetric.Name,
+			},
+		}
+	}
+
+	return usage
+}
+
 func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
 	if sandboxEnabled {
 		return fmt.Sprintf("cloud_%s_%d", prefix, projectID)