Skip to content

Commit

Permalink
Add endpoint for retrieving customer credits
Browse files Browse the repository at this point in the history
  • Loading branch information
MauAraujo committed Apr 3, 2024
1 parent 8bb2d3e commit 361f821
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 46 deletions.
44 changes: 44 additions & 0 deletions api/server/handlers/billing/credits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package billing

import (
"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"
)

// GetCreditsHandler is a handler for getting available credits
type GetCreditsHandler struct {
handlers.PorterHandlerWriter
}

// NewGetCreditsHandler will create a new GetCreditsHandler
func NewGetCreditsHandler(
config *config.Config,
writer shared.ResultWriter,
) *GetCreditsHandler {
return &GetCreditsHandler{
PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
}
}

func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "get-credits-endpoint")
defer span.End()

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

credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerCredits(proj.UsageID)
if err != nil {
err := telemetry.Error(ctx, span, err, "error getting credits")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}

c.WriteResult(w, r, credits)
}
32 changes: 32 additions & 0 deletions api/server/handlers/billing/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"

"github.com/google/uuid"
"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"
Expand Down Expand Up @@ -55,6 +56,37 @@ func (c *CreateBillingCustomerHandler) ServeHTTP(w http.ResponseWriter, r *http.
telemetry.AttributeKV{Key: "user-email", Value: user.Email},
)

// Create Metronome customer and add to starter plan
if c.Config().ServerConf.MetronomeAPIKey != "" && c.Config().ServerConf.PorterCloudPlanID != "" &&
c.Config().ServerConf.EnableSandbox {
// Create Metronome Customer
if c.Config().ServerConf.MetronomeAPIKey != "" {
usageID, err := c.Config().BillingManager.MetronomeClient.CreateCustomer(user.CompanyName, proj.Name, proj.ID, proj.BillingID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error creating billing customer")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
proj.UsageID = usageID
}

porterCloudPlanID, err := uuid.Parse(c.Config().ServerConf.PorterCloudPlanID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error parsing starter plan id")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}

// Add to starter plan
customerPlanID, err := c.Config().BillingManager.MetronomeClient.AddCustomerPlan(proj.UsageID, porterCloudPlanID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error adding customer to starter plan")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
proj.UsagePlanID = customerPlanID
}

// Update the project record with the customer ID
proj.BillingID = customerID
_, err = c.Repo().Project().UpdateProject(proj)
Expand Down
26 changes: 14 additions & 12 deletions api/server/handlers/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,28 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
)
}

// Create Metronome Customer
if p.Config().ServerConf.MetronomeAPIKey != "" {
usageID, err := p.Config().BillingManager.MetronomeClient.CreateCustomer(user.CompanyName, proj.Name, proj.ID, proj.BillingID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error creating billing customer")
p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
proj.UsageID = usageID
}

// Add customer to starter plan
// Create Metronome customer and add to starter plan
if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" &&
p.Config().ServerConf.EnableSandbox {
// Create Metronome Customer
if p.Config().ServerConf.MetronomeAPIKey != "" {
usageID, err := p.Config().BillingManager.MetronomeClient.CreateCustomer(user.CompanyName, proj.Name, proj.ID, proj.BillingID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error creating billing customer")
p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
proj.UsageID = usageID
}

porterCloudPlanID, err := uuid.Parse(p.Config().ServerConf.PorterCloudPlanID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error parsing starter plan id")
p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}

// Add to starter plan
customerPlanID, err := p.Config().BillingManager.MetronomeClient.AddCustomerPlan(proj.UsageID, porterCloudPlanID)
if err != nil {
err = telemetry.Error(ctx, span, err, "error adding customer to starter plan")
Expand Down
16 changes: 0 additions & 16 deletions api/server/handlers/project/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,6 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

err = p.Config().BillingManager.MetronomeClient.DeleteCustomer(proj.UsageID)
if err != nil {
e := "error deleting project in usage provider"
err = telemetry.Error(ctx, span, err, e)
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}
}

err = p.Config().BillingManager.StripeClient.DeleteCustomer(ctx, proj)
if err != nil {
e := "error deleting project in billing provider"
err = telemetry.Error(ctx, span, err, e)
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

deletedProject, err := p.Repo().Project().DeleteProject(proj)
Expand Down
43 changes: 43 additions & 0 deletions api/types/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,46 @@ type EndCustomerPlanRequest struct {
VoidInvoices bool `json:"void_invoices"` // If true, plan end date can be before the last finalized invoice date. Any invoices generated after the plan end date will be voided.
VoidStripeInvoices bool `json:"void_stripe_invoices"` // Will void Stripe invoices if VoidInvoices is set to true. Drafts will be deleted.
}

type ListCreditGrantsRequest struct {
// An array of credit type IDs. This must not be specified if
// credit_grant_ids is specified.
CreditTypeIDs []uuid.UUID `json:"credit_type_ids"`
// An array of Metronome customer IDs. This must not be specified if
// credit_grant_ids is specified.
CustomerIDs []uuid.UUID `json:"customer_ids"`
// An array of credit grant IDs. If this is specified, neither
// credit_type_ids nor customer_ids may be specified.
CreditGrantIDs []uuid.UUID `json:"credit_grant_ids"`
// Only return credit grants that expire at or after this RFC 3339 timestamp.
NotExpiringBefore string `json:"not_expiring_before"`
// Only return credit grants that are effective before this RFC 3339 timestamp
// (exclusive).
EffectiveBefore string `json:"effective_before"`
}

type CreditType struct {
Name string `json:"name"` // The name of the credit type
ID string `json:"id"` // The UUID of the credit type
}

type GrantAmount struct {
Amount int64 `json:"amount"` // The amount of credits granted
CreditType CreditType `json:"credit_type"` // The credit type for the amount granted
}

// Balance represents the effective balance of the grant as of the end of the customer's
// current billing period.
type Balance struct {
ExcludingPending int64 `json:"excluding_pending"` // The grant's current balance excluding all pending deductions.
IncludingPending int64 `json:"including_pending"` // The grant's current balance including all posted and pending deductions.
EffectiveAt string `json:"effective_at"` // The end date of the customer's current billing period in RFC 3339 format.
}

type CreditGrant struct {
ID uuid.UUID `json:"id"`
Name string
CustomerID uuid.UUID
GrantAmount GrantAmount
Balance Balance
}
26 changes: 26 additions & 0 deletions dashboard/src/lib/hooks/useStripe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@ export const usePublishableKey = (): TGetPublishableKey => {
};
};

export const usePorterCredits = (): TGetPublishableKey => {
const { currentProject } = useContext(Context);

// Fetch list of payment methods
const keyReq = useQuery(
["getPublishableKey", currentProject?.id],
async () => {
if (!currentProject?.id || currentProject.id === -1) {
return;
}
const res = await api.getPorterCredits(
"<token>",
{},
{
project_id: currentProject?.id,
}
);
return res.data;
}
);

return {
publishableKey: keyReq.data,
};
};

export const useSetDefaultPaymentMethod = (): TSetDefaultPaymentMethod => {
const { currentProject } = useContext(Context);

Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/shared/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3458,6 +3458,13 @@ const getPublishableKey = baseApi<
({ project_id }) => `/api/projects/${project_id}/billing/publishable_key`
);

const getPorterCredits = baseApi<
{},
{
project_id?: number;
}
>("GET", ({ project_id }) => `/api/projects/${project_id}/billing/credits`);

const getHasBilling = baseApi<{}, { project_id: number }>(
"GET",
({ project_id }) => `/api/projects/${project_id}/billing`
Expand Down Expand Up @@ -3856,6 +3863,7 @@ export default {
// BILLING
checkBillingCustomerExists,
getPublishableKey,
getPorterCredits,
listPaymentMethod,
addPaymentMethod,
setDefaultPaymentMethod,
Expand Down
40 changes: 22 additions & 18 deletions internal/billing/metronome.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,6 @@ func (m *MetronomeClient) CreateCustomer(orgName string, projectName string, pro
return result.Data.ID, nil
}

func (m *MetronomeClient) DeleteCustomer(customerID uuid.UUID) (err error) {
if customerID == uuid.Nil {
return fmt.Errorf("customer id cannot be empty")
}
path := "/customers/archive"

req := types.CustomerArchiveRequest{
CustomerID: customerID,
}

err = post(path, http.MethodPost, m.ApiKey, req, nil)
if err != nil {
return err
}

return nil
}

func (m *MetronomeClient) AddCustomerPlan(customerID uuid.UUID, planID uuid.UUID) (customerPlanID uuid.UUID, err error) {
if customerID == uuid.Nil || planID == uuid.Nil {
return customerPlanID, fmt.Errorf("customer or plan id empty")
Expand Down Expand Up @@ -127,6 +109,28 @@ func (m *MetronomeClient) EndCustomerPlan(customerID uuid.UUID, customerPlanID u
return nil
}

func (m *MetronomeClient) GetCustomerCredits(customerID uuid.UUID) (credits int64, err error) {
if customerID == uuid.Nil {
return credits, fmt.Errorf("customer id empty")
}

path := fmt.Sprintf("credits/listGrants")

req := types.ListCreditGrantsRequest{
CustomerIDs: []uuid.UUID{
customerID,
},
}

var result types.CreditGrant
err = post(path, http.MethodPost, m.ApiKey, req, result)
if err != nil {
return credits, err
}

return result.Balance.ExcludingPending, nil
}

func post(path string, method string, apiKey string, body interface{}, data interface{}) (err error) {
client := http.Client{}
endpoint, err := url.JoinPath(metronomeBaseUrl, path)
Expand Down

0 comments on commit 361f821

Please sign in to comment.