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

+ 26 - 1
api/server/handlers/billing/invoices.go

@@ -35,6 +35,24 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "billing-config-exists", Value: c.Config().BillingManager.StripeConfigLoaded},
+		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
+		telemetry.AttributeKV{Key: "billing-enabled", Value: proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
+	)
+
+	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	if !c.Config().BillingManager.StripeConfigLoaded || !proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+		return
+	}
+
 	req := &types.ListCustomerInvoicesRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, req); !ok {
@@ -43,12 +61,19 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	invoices, err := c.Config().BillingManager.MetronomeClient.ListCustomerInvoices(ctx, proj.UsageID, req.Status, req.StartingOn, req.EndingBefore)
+	invoices, err := c.Config().BillingManager.MetronomeClient.ListCustomerInvoices(ctx, proj.UsageID, req.Status)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error listing customer invoices")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing customer invoices: %w", err)))
 		return
 	}
 
+	invoices, err = c.Config().BillingManager.StripeClient.PopulateInvoiceURLs(ctx, invoices)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error populating invoice urls")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error populating invoice urls: %w", err)))
+		return
+	}
+
 	c.WriteResult(w, r, invoices)
 }

+ 2 - 7
api/types/billing_metronome.go

@@ -215,19 +215,14 @@ type Invoice struct {
 	Subtotal        float64         `json:"subtotal"`
 	Total           float64         `json:"total"`
 	Type            string          `json:"type"`
-	LineItems       []LineItem      `json:"line_items"`
 	ExternalInvoice ExternalInvoice `json:"external_invoice"`
 }
 
+// ExternalInvoice represents an external invoice in the billing provider (e.g. Stripe)
 type ExternalInvoice struct {
 	BillingProviderType string `json:"billing_provider_type"`
 	InvoiceID           string `json:"invoice_id"`
 	IssuedAt            string `json:"issued_at"`
 	ExternalStatus      string `json:"external_status"`
-}
-
-type LineItem struct {
-	Name       string     `json:"name"`
-	Total      float64    `json:"total"`
-	CreditType CreditType `json:"credit_type"`
+	ExternalURL         string `json:"external_url"`
 }

+ 1 - 8
dashboard/src/lib/hooks/useStripe.tsx

@@ -431,21 +431,14 @@ export const useCustomerInvoices = (): TGetInvoices => {
       }
 
       try {
-        const now = new Date();
-        const startingDate = new Date(now.setMonth(now.getMonth() - 1)).toISOString();
-        const endingDate = now.toISOString();
         const res = await api.getCustomerInvoices(
           "<token>",
           {
-            status: "DRAFT",
-            starting_on: startingDate,
-            ending_before: endingDate,
+            status: "FINALIZED",
           },
           { project_id: currentProject.id }
         );
 
-        console.log(res)
-
         const invoices = InvoiceValidator.array().parse(res.data);
         return invoices;
       } catch (error) {

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

@@ -3530,8 +3530,6 @@ const getCustomerUsage = baseApi<
 const getCustomerInvoices = baseApi<
   {
     status: string;
-    starting_on: string;
-    ending_before: string;
   },
   {
     project_id?: number;

+ 13 - 34
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, nil, 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, nil, 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, 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, nil, 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, nil, 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, nil, 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, nil, 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")
 		}
@@ -357,7 +357,7 @@ func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.
 }
 
 // ListCustomerInvoices will return the invoices for a customer for the given status and time range
-func (m MetronomeClient) ListCustomerInvoices(ctx context.Context, customerID uuid.UUID, status string, startingOn string, endingBefore string) (invoices []types.Invoice, err error) {
+func (m MetronomeClient) ListCustomerInvoices(ctx context.Context, customerID uuid.UUID, status string) (invoices []types.Invoice, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-customer-invoices")
 	defer span.End()
 
@@ -366,29 +366,16 @@ func (m MetronomeClient) ListCustomerInvoices(ctx context.Context, customerID uu
 	}
 
 	path := fmt.Sprintf("customers/%s/invoices", customerID)
-
-	queryParams := map[string]string{
-		"status": status,
-	}
-
-	if startingOn != "" {
-		queryParams["starting_on"] = startingOn
-	}
-
-	if endingBefore != "" {
-		queryParams["ending_before"] = endingBefore
-	}
-
 	var result struct {
 		Data []types.Invoice `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, queryParams, nil, &result)
+	_, err = m.do(http.MethodGet, path, nil, &result)
 	if err != nil {
 		return invoices, telemetry.Error(ctx, span, err, "failed to list customer invoices")
 	}
 
-	return invoices, nil
+	return result.Data, nil
 }
 
 // IngestEvents sends a list of billing events to Metronome's ingest endpoint
@@ -457,7 +444,7 @@ func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID u
 		Data []types.BillableMetric `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, nil, 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")
 	}
@@ -475,7 +462,7 @@ func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode strin
 		Data []types.PricingUnit `json:"data"`
 	}
 
-	_, err = m.do(http.MethodGet, path, nil, 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")
 	}
@@ -489,7 +476,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, queryParams map[string]string, body interface{}, data interface{}) (statusCode int, err error) {
+func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	if err != nil {
@@ -504,14 +491,6 @@ func (m MetronomeClient) do(method string, path string, queryParams map[string]s
 		}
 	}
 
-	if queryParams != nil {
-		q := url.Values{}
-		for k, v := range queryParams {
-			q.Add(k, v)
-		}
-		endpoint = fmt.Sprintf("%s?%s", endpoint, q.Encode())
-	}
-
 	req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
 	if err != nil {
 		return statusCode, err

+ 32 - 0
internal/billing/stripe.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/stripe/stripe-go/v76"
 	"github.com/stripe/stripe-go/v76/customer"
+	"github.com/stripe/stripe-go/v76/invoice"
 	"github.com/stripe/stripe-go/v76/paymentmethod"
 	"github.com/stripe/stripe-go/v76/setupintent"
 )
@@ -243,6 +244,37 @@ func (s StripeClient) GetPublishableKey(ctx context.Context) (key string) {
 	return s.PublishableKey
 }
 
+func (s StripeClient) PopulateInvoiceURLs(ctx context.Context, invoices []types.Invoice) (invoiceList []types.Invoice, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls")
+	defer span.End()
+
+	if len(invoices) == 0 {
+		return invoiceList, nil
+	}
+
+	stripe.Key = s.SecretKey
+
+	for _, usageInvoice := range invoices {
+		if usageInvoice.ExternalInvoice.InvoiceID == "" {
+			continue
+		}
+
+		if usageInvoice.ExternalInvoice.ExternalStatus == "SKIPPED" {
+			continue
+		}
+
+		stripeInvoice, err := invoice.Get(usageInvoice.ExternalInvoice.InvoiceID, &stripe.InvoiceParams{})
+		if err != nil {
+			return invoiceList, telemetry.Error(ctx, span, err, "failed to get Stripe invoice")
+		}
+
+		usageInvoice.ExternalInvoice.ExternalURL = stripeInvoice.HostedInvoiceURL
+		invoiceList = append(invoiceList, usageInvoice)
+	}
+
+	return invoiceList, nil
+}
+
 func (s StripeClient) checkDefaultPaymentMethod(customerID string) (defaultPaymentExists bool, defaultPaymentID string, err error) {
 	// Get customer to check default payment method
 	customer, err := customer.Get(customerID, nil)