ソースを参照

Merge branch 'master' into inference-fe

Feroze Mohideen 2 年 前
コミット
4500261a5f

+ 132 - 0
api/server/handlers/user/create_ory.go

@@ -0,0 +1,132 @@
+package user
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/analytics"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+// OryUserCreateHandler is the handler for user creation triggered by an ory action
+type OryUserCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewOryUserCreateHandler generates a new OryUserCreateHandler
+func NewOryUserCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OryUserCreateHandler {
+	return &OryUserCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CreateOryUserRequest is the expected request body for user creation triggered by an ory action
+type CreateOryUserRequest struct {
+	OryId    string `json:"ory_id"`
+	Email    string `json:"email"`
+	Referral string `json:"referral"`
+}
+
+// ServeHTTP handles the user creation triggered by an ory action
+func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-ory-user")
+	defer span.End()
+
+	// this endpoint is not authenticated through middleware; instead, we check
+	// for the presence of an ory action cookie that matches env
+	oryActionCookie, err := r.Cookie("ory_action")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "invalid ory action cookie")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	if oryActionCookie.Value != u.Config().OryActionKey {
+		err = telemetry.Error(ctx, span, nil, "cookie does not match")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	request := &CreateOryUserRequest{}
+	ok := u.DecodeAndValidate(w, r, request)
+	if !ok {
+		err = telemetry.Error(ctx, span, nil, "invalid request")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "email", Value: request.Email},
+		telemetry.AttributeKV{Key: "ory-id", Value: request.OryId},
+		telemetry.AttributeKV{Key: "referral", Value: request.Referral},
+	)
+
+	if request.Email == "" {
+		err = telemetry.Error(ctx, span, nil, "email is required")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.OryId == "" {
+		err = telemetry.Error(ctx, span, nil, "ory_id is required")
+		u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	user := &models.User{
+		Model:         gorm.Model{},
+		Email:         request.Email,
+		EmailVerified: false,
+		AuthProvider:  models.AuthProvider_Ory,
+		ExternalId:    request.OryId,
+	}
+
+	existingUser, err := u.Repo().User().ReadUserByEmail(user.Email)
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		err = telemetry.Error(ctx, span, err, "error reading user by email")
+		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if existingUser == nil || existingUser.ID == 0 {
+		user, err = u.Repo().User().CreateUser(user)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating user")
+			u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		_ = u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user))
+
+		_ = u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+			ReferralMethod:      request.Referral,
+		}))
+	} else {
+		existingUser.AuthProvider = models.AuthProvider_Ory
+		existingUser.ExternalId = request.OryId
+		_, err = u.Repo().User().UpdateUser(existingUser)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error updating user")
+			u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 24 - 0
api/server/router/base.go

@@ -197,6 +197,30 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
+	// POST /api/users/ory -> user.NewOryUserCreateHandler
+	createOryUserEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/users/ory",
+			},
+		},
+	)
+
+	createOryUserHandler := user.NewOryUserCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createOryUserEndpoint,
+		Handler:  createOryUserHandler,
+		Router:   r,
+	})
+
 	// POST /api/login -> user.NewUserLoginHandler
 	loginUserEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -126,6 +126,7 @@ type Config struct {
 
 	Ory                     ory.APIClient
 	OryApiKeyContextWrapper func(ctx context.Context) context.Context
+	OryActionKey            string
 }
 
 type ConfigLoader interface {

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

@@ -177,6 +177,8 @@ type ServerConf struct {
 	OryEnabled bool   `env:"ORY_ENABLED,default=false"`
 	OryUrl     string `env:"ORY_URL,default=http://localhost:4000"`
 	OryApiKey  string `env:"ORY_API_KEY"`
+	// OryActionKey is the key used to authenticate api requests from Ory Actions to the Porter API
+	OryActionKey string `env:"ORY_ACTION_KEY"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 1 - 0
api/server/shared/config/loader/loader.go

@@ -402,6 +402,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.OryApiKeyContextWrapper = func(ctx context.Context) context.Context {
 			return context.WithValue(ctx, ory.ContextAccessToken, InstanceEnvConf.ServerConf.OryApiKey)
 		}
+		res.OryActionKey = InstanceEnvConf.ServerConf.OryActionKey
 		res.Logger.Info().Msg("Created Ory client")
 	}
 

+ 9 - 0
api/types/billing_usage.go

@@ -16,6 +16,15 @@ type ListCustomerUsageRequest struct {
 	CurrentPeriod bool `json:"current_period,omitempty"`
 }
 
+// Subscription is the subscription for a customer
+type Subscription struct {
+	ExternalID         string `json:"external_id"`
+	ExternalCustomerID string `json:"external_customer_id"`
+	Status             string `json:"status"`
+	SubscriptionAt     string `json:"subscription_at"`
+	EndingAt           string `json:"ending_at"`
+}
+
 // Usage is the aggregated usage for a customer
 type Usage struct {
 	FromDatetime     string        `json:"from_datetime"`

+ 7 - 0
dashboard/src/components/ProvisionerSettings.tsx

@@ -148,6 +148,13 @@ const machineTypeOptions = [
   { value: "c7g.8xlarge", label: "c7g.8xlarge" },
   { value: "c7g.12xlarge", label: "c7g.12xlarge" },
   { value: "c7g.16xlarge", label: "c7g.16xlarge" },
+  { value: "c7gn.large", label: "c7gn.large" },
+  { value: "c7gn.xlarge", label: "c7gn.xlarge" },
+  { value: "c7gn.2xlarge", label: "c7gn.2xlarge" },
+  { value: "c7gn.4xlarge", label: "c7gn.4xlarge" },
+  { value: "c7gn.8xlarge", label: "c7gn.8xlarge" },
+  { value: "c7gn.12xlarge", label: "c7gn.12xlarge" },
+  { value: "c7gn.16xlarge", label: "c7gn.16xlarge" },
 ];
 
 const defaultCidrVpc = "10.78.0.0/16";

+ 64 - 0
dashboard/src/lib/clusters/constants.ts

@@ -801,6 +801,70 @@ const SUPPORTED_AWS_MACHINE_TYPES: ClientMachineType[] = [
     cpuCores: 64,
     ramMegabytes: 131072,
   },
+  {
+    name: "c7gn.medium",
+    displayName: "c7gn.medium",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 1,
+    ramMegabytes: 2048,
+  },
+  {
+    name: "c7gn.large",
+    displayName: "c7gn.large",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 2,
+    ramMegabytes: 4096,
+  },
+  {
+    name: "c7gn.xlarge",
+    displayName: "c7gn.xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 4,
+    ramMegabytes: 8192,
+  },
+  {
+    name: "c7gn.2xlarge",
+    displayName: "c7gn.2xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 8,
+    ramMegabytes: 16384,
+  },
+  {
+    name: "c7gn.4xlarge",
+    displayName: "c7gn.4xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 16,
+    ramMegabytes: 32768,
+  },
+  {
+    name: "c7gn.8xlarge",
+    displayName: "c7gn.8xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 32,
+    ramMegabytes: 65536,
+  },
+  {
+    name: "c7gn.12xlarge",
+    displayName: "c7gn.12xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 48,
+    ramMegabytes: 98304,
+  },
+  {
+    name: "c7gn.16xlarge",
+    displayName: "c7gn.16xlarge",
+    supportedRegions: SUPPORTED_AWS_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 64,
+    ramMegabytes: 131072,
+  },
   {
     name: "g4dn.xlarge",
     displayName: "g4dn.xlarge",

+ 8 - 0
dashboard/src/lib/clusters/types.ts

@@ -202,6 +202,14 @@ const awsMachineTypeValidator = z.enum([
   "c7g.8xlarge",
   "c7g.12xlarge",
   "c7g.16xlarge",
+  "c7gn.medium",
+  "c7gn.large",
+  "c7gn.xlarge",
+  "c7gn.2xlarge",
+  "c7gn.4xlarge",
+  "c7gn.8xlarge",
+  "c7gn.12xlarge",
+  "c7gn.16xlarge",
   // gpu types
   "g4dn.xlarge",
   "g4dn.2xlarge",

+ 5 - 1
dashboard/src/lib/hooks/useAddon.ts

@@ -1,6 +1,6 @@
 import { useEffect, useRef, useState } from "react";
 import { Addon, AddonWithEnvVars } from "@porter-dev/api-contracts";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import Anser, { type AnserJsonEntry } from "anser";
 import { match } from "ts-pattern";
 import { z } from "zod";
@@ -200,6 +200,8 @@ export const useAddon = (): {
     isError: boolean;
   };
 } => {
+  const queryClient = useQueryClient();
+
   const updateAddon = async ({
     projectId,
     deploymentTargetId,
@@ -241,6 +243,8 @@ export const useAddon = (): {
         addonName: addon.name.value,
       }
     );
+
+    await queryClient.invalidateQueries(["listAddons"]);
   };
 
   const getAddon = ({

+ 2 - 20
dashboard/src/main/home/add-on-dashboard/AddonContextProvider.tsx

@@ -1,5 +1,4 @@
-import React, { createContext, useCallback, useContext } from "react";
-import { useQueryClient } from "@tanstack/react-query";
+import React, { createContext, useContext } from "react";
 import styled from "styled-components";
 
 import Loading from "components/Loading";
@@ -26,7 +25,6 @@ type AddonContextType = {
   projectId: number;
   deploymentTarget: DeploymentTarget;
   status: ClientAddonStatus;
-  deleteAddon: () => Promise<void>;
 };
 
 const AddonContext = createContext<AddonContextType | null>(null);
@@ -53,7 +51,7 @@ export const AddonContextProvider: React.FC<AddonContextProviderProps> = ({
   const { currentProject } = useContext(Context);
   const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
     useDefaultDeploymentTarget();
-  const { getAddon, deleteAddon } = useAddon();
+  const { getAddon } = useAddon();
   const {
     addon,
     isLoading: isAddonLoading,
@@ -64,7 +62,6 @@ export const AddonContextProvider: React.FC<AddonContextProviderProps> = ({
     addonName,
     refreshIntervalSeconds: 5,
   });
-  const queryClient = useQueryClient();
 
   const status = useAddonStatus({
     projectId: currentProject?.id,
@@ -78,20 +75,6 @@ export const AddonContextProvider: React.FC<AddonContextProviderProps> = ({
     !!currentProject &&
     currentProject.id !== -1;
 
-  const deleteContextAddon = useCallback(async () => {
-    if (!paramsExist || !addon) {
-      return;
-    }
-
-    await deleteAddon({
-      projectId: currentProject.id,
-      deploymentTargetId: defaultDeploymentTarget.id,
-      addon,
-    });
-
-    await queryClient.invalidateQueries(["listAddons"]);
-  }, [paramsExist]);
-
   if (isDefaultDeploymentTargetLoading || isAddonLoading || !paramsExist) {
     return <Loading />;
   }
@@ -118,7 +101,6 @@ export const AddonContextProvider: React.FC<AddonContextProviderProps> = ({
         projectId: currentProject.id,
         deploymentTarget: defaultDeploymentTarget,
         status,
-        deleteAddon: deleteContextAddon,
       }}
     >
       {children}

+ 8 - 2
dashboard/src/main/home/add-on-dashboard/common/Settings.tsx

@@ -8,6 +8,7 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { Code } from "main/home/managed-addons/tabs/shared";
+import { useAddon } from "lib/hooks/useAddon";
 import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
 import { useIntercom } from "lib/hooks/useIntercom";
 
@@ -18,7 +19,8 @@ import { useAddonContext } from "../AddonContextProvider";
 import { useAddonFormContext } from "../AddonFormContextProvider";
 
 const Settings: React.FC = () => {
-  const { addon, deleteAddon } = useAddonContext();
+  const { deleteAddon } = useAddon();
+  const { addon, projectId, deploymentTarget } = useAddonContext();
   const { updateAddonButtonProps } = useAddonFormContext();
   const history = useHistory();
   const { setCurrentOverlay = () => ({}) } = useContext(Context);
@@ -30,7 +32,11 @@ const Settings: React.FC = () => {
     try {
       setCurrentOverlay(null);
       setIsDeleting(true);
-      await deleteAddon();
+      await deleteAddon({
+        projectId,
+        deploymentTargetId: deploymentTarget.id,
+        addon,
+      });
       history.push("/addons");
     } catch (err) {
       showIntercomWithMessage({

+ 56 - 21
internal/billing/usage.go

@@ -137,7 +137,7 @@ func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, e
 		if lagoErr.ErrorCode == "customer_not_found" {
 			return false, nil
 		}
-		return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer")
+		return exists, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer")
 	}
 
 	return true, nil
@@ -153,34 +153,34 @@ func (m LagoClient) GetCustomerActivePlan(ctx context.Context, projectID uint, s
 	}
 
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
-	subscriptionListInput := lago.SubscriptionListInput{
-		ExternalCustomerID: customerID,
-	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "customer_id", Value: customerID},
+	)
 
-	activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
-	if lagoErr != nil {
-		return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
+	activeSubscriptions, err := m.getCustomerActiveSubscription(ctx, customerID)
+	if err != nil {
+		return plan, telemetry.Error(ctx, span, err, "failed to get active subscriptions")
 	}
 
 	if activeSubscriptions == nil {
 		return plan, telemetry.Error(ctx, span, err, "no active subscriptions found")
 	}
 
-	for _, subscription := range activeSubscriptions.Subscriptions {
-		if subscription.Status != lago.SubscriptionStatusActive {
+	for _, subscription := range activeSubscriptions {
+		if subscription.Status != string(lago.SubscriptionStatusActive) {
 			continue
 		}
 
 		plan.ID = subscription.ExternalID
 		plan.CustomerID = subscription.ExternalCustomerID
-		plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
+		plan.StartingOn = subscription.SubscriptionAt
 
-		if subscription.EndingAt != nil {
-			plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+		if subscription.EndingAt != "" {
+			plan.EndingBefore = subscription.EndingAt
 		}
 
 		if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
-			plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+			plan.TrialInfo.EndingBefore = subscription.EndingAt
 		}
 
 		break
@@ -201,7 +201,7 @@ func (m LagoClient) DeleteCustomer(ctx context.Context, projectID uint, sandboxE
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
 	_, lagoErr := m.client.Customer().Delete(ctx, customerID)
 	if lagoErr != nil {
-		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
+		return telemetry.Error(ctx, span, lagoErr.Err, "failed to terminate subscription")
 	}
 
 	return nil
@@ -286,7 +286,7 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 
 		_, lagoErr := m.client.Wallet().Create(ctx, walletInput)
 		if lagoErr != nil {
-			return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create wallet")
+			return telemetry.Error(ctx, span, lagoErr.Err, "failed to create wallet")
 		}
 
 		return nil
@@ -302,7 +302,7 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 	// If the wallet already exists, we need to update the balance
 	_, lagoErr := m.client.WalletTransaction().Create(ctx, walletTransactionInput)
 	if lagoErr != nil {
-		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to update credits grant")
+		return telemetry.Error(ctx, span, lagoErr.Err, "failed to update credits grant")
 	}
 
 	return nil
@@ -324,7 +324,7 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, su
 
 		currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
 		if lagoErr != nil {
-			return usageList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
+			return usageList, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage")
 		}
 
 		if currentUsage == nil {
@@ -423,13 +423,13 @@ func (m LagoClient) ListCustomerFinalizedInvoices(ctx context.Context, projectID
 
 	invoices, lagoErr := m.client.Invoice().GetList(ctx, invoiceListInput)
 	if lagoErr != nil {
-		return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to list invoices")
+		return invoiceList, telemetry.Error(ctx, span, lagoErr.Err, "failed to list invoices")
 	}
 
 	for _, invoice := range invoices.Invoices {
 		invoiceReq, lagoErr := m.client.Invoice().Download(ctx, invoice.LagoID.String())
 		if lagoErr != nil {
-			return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to download invoice")
+			return invoiceList, telemetry.Error(ctx, span, lagoErr.Err, "failed to download invoice")
 		}
 
 		var fileURL string
@@ -470,7 +470,7 @@ func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projec
 
 	_, lagoErr := m.client.Customer().Create(ctx, customerInput)
 	if lagoErr != nil {
-		return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
+		return customerID, telemetry.Error(ctx, span, lagoErr.Err, "failed to create lago customer")
 	}
 	return customerID, nil
 }
@@ -495,12 +495,47 @@ func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, plan
 
 	_, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
 	if lagoErr != nil {
-		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
+		return telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription")
 	}
 
 	return nil
 }
 
+func (m LagoClient) getCustomerActiveSubscription(ctx context.Context, customerID string) (subscriptions []types.Subscription, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-active-subscriptions")
+	defer span.End()
+
+	url := fmt.Sprintf("%s/api/v1/subscriptions?external_customer_id=%s&status[]=%s", lagoBaseURL, customerID, lago.SubscriptionStatusActive)
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return subscriptions, telemetry.Error(ctx, span, err, "failed to create list subscriptions request")
+	}
+
+	req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return subscriptions, telemetry.Error(ctx, span, err, "failed to get customer subscriptions")
+	}
+
+	var response struct {
+		Subscriptions []types.Subscription `json:"subscriptions"`
+	}
+
+	err = json.NewDecoder(resp.Body).Decode(&response)
+	if err != nil {
+		return subscriptions, telemetry.Error(ctx, span, err, "failed to decode subscriptions list response")
+	}
+
+	err = resp.Body.Close()
+	if err != nil {
+		return subscriptions, telemetry.Error(ctx, span, err, "failed to close response body")
+	}
+
+	return response.Subscriptions, nil
+}
+
 func (m LagoClient) listCustomerWallets(ctx context.Context, customerID string) (walletList []types.Wallet, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-lago-customer-wallets")
 	defer span.End()