Skip to content

Commit

Permalink
List invoices backend (#4592)
Browse files Browse the repository at this point in the history
Co-authored-by: jusrhee <[email protected]>
  • Loading branch information
MauAraujo and jusrhee authored May 6, 2024
1 parent b90fd91 commit b1d7344
Show file tree
Hide file tree
Showing 16 changed files with 947 additions and 334 deletions.
66 changes: 66 additions & 0 deletions api/server/handlers/billing/invoices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package billing

import (
"fmt"
"net/http"

"github.com/porter-dev/porter/api/server/handlers"
"github.com/porter-dev/porter/api/server/shared"
"github.com/porter-dev/porter/api/server/shared/apierrors"
"github.com/porter-dev/porter/api/server/shared/config"
"github.com/porter-dev/porter/api/types"
"github.com/porter-dev/porter/internal/models"
"github.com/porter-dev/porter/internal/telemetry"
)

// ListCustomerInvoicesHandler is a handler for listing payment methods
type ListCustomerInvoicesHandler struct {
handlers.PorterHandlerReadWriter
}

// NewListCustomerInvoicesHandler will create a new ListCustomerInvoicesHandler
func NewListCustomerInvoicesHandler(
config *config.Config,
decoderValidator shared.RequestDecoderValidator,
writer shared.ResultWriter,
) *ListCustomerInvoicesHandler {
return &ListCustomerInvoicesHandler{
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
}
}

func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-list-payment-methods")
defer span.End()

proj, _ := ctx.Value(types.ProjectScope).(*models.Project)

telemetry.WithAttributes(span,
telemetry.AttributeKV{Key: "billing-config-exists", Value: c.Config().BillingManager.StripeConfigLoaded},
telemetry.AttributeKV{Key: "billing-enabled", Value: proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient)},
telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
)

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 {
err := telemetry.Error(ctx, span, nil, "error decoding list customer usage request")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}

invoices, err := c.Config().BillingManager.StripeClient.ListCustomerInvoices(ctx, proj.BillingID, req.Status)
if err != nil {
err = telemetry.Error(ctx, span, err, fmt.Sprintf("error listing invoices for customer %s", proj.BillingID))
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}

// Write the response to the frontend
c.WriteResult(w, r, invoices)
}
50 changes: 50 additions & 0 deletions api/server/handlers/billing/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
56 changes: 56 additions & 0 deletions api/server/router/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,62 @@ 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{
Verb: types.APIVerbGet,
Method: types.HTTPVerbGet,
Path: &types.Path{
Parent: basePath,
RelativePath: relPath + "/billing/invoices",
},
Scopes: []types.PermissionScope{
types.UserScope,
types.ProjectScope,
},
},
)

listCustomerInvoicesHandler := billing.NewListCustomerInvoicesHandler(
config,
factory.GetDecoderValidator(),
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: listCustomerInvoicesEndpoint,
Handler: listCustomerInvoicesHandler,
Router: r,
})

// POST /api/projects/{project_id}/billing/ingest -> project.NewGetUsageDashboardHandler
ingestEventsEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Expand Down
35 changes: 34 additions & 1 deletion api/types/billing_metronome.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
17 changes: 16 additions & 1 deletion api/types/billing_stripe.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@ package types

// PaymentMethod is a subset of the Stripe PaymentMethod type,
// with only the fields used in the dashboard
type PaymentMethod = struct {
type PaymentMethod struct {
ID string `json:"id"`
DisplayBrand string `json:"display_brand"`
Last4 string `json:"last4"`
ExpMonth int64 `json:"exp_month"`
ExpYear int64 `json:"exp_year"`
Default bool `json:"is_default"`
}

// Invoice represents an invoice in the billing system.
type Invoice struct {
// The URL to view the hosted invoice.
HostedInvoiceURL string `json:"hosted_invoice_url"`
// The status of the invoice.
Status string `json:"status"`
// RFC 3339 timestamp for when the invoice was created.
Created string `json:"created"`
}

// ListCustomerInvoicesRequest is the request to list invoices for a customer
type ListCustomerInvoicesRequest struct {
Status string `schema:"status"`
}
12 changes: 7 additions & 5 deletions dashboard/src/components/TabRegion.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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());
Expand All @@ -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 />
Expand Down
20 changes: 18 additions & 2 deletions dashboard/src/lib/billing/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -51,6 +51,22 @@ 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({
hosted_invoice_url: z.string(),
status: z.string(),
created: z.string(),
});

export const ClientSecretResponse = z.string();

export type ReferralDetails = z.infer<typeof ReferralDetailsValidator>;
Expand Down
Loading

0 comments on commit b1d7344

Please sign in to comment.