metronome.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. package billing
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "net/url"
  9. "strconv"
  10. "time"
  11. "github.com/google/uuid"
  12. "github.com/porter-dev/porter/api/types"
  13. "github.com/porter-dev/porter/internal/telemetry"
  14. )
  15. const (
  16. metronomeBaseUrl = "https://api.metronome.com/v1/"
  17. defaultCollectionMethod = "charge_automatically"
  18. defaultGrantCredits = 5000
  19. defaultGrantName = "Starter Credits"
  20. defaultGrantExpiryMonths = 1
  21. )
  22. // MetronomeClient is the client used to call the Metronome API
  23. type MetronomeClient struct {
  24. ApiKey string
  25. }
  26. // NewMetronomeClient returns a new Metronome client
  27. func NewMetronomeClient(metronomeApiKey string) MetronomeClient {
  28. return MetronomeClient{
  29. ApiKey: metronomeApiKey,
  30. }
  31. }
  32. // createCustomer will create the customer in Metronome
  33. func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string) (customerID uuid.UUID, err error) {
  34. ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
  35. defer span.End()
  36. path := "customers"
  37. projIDStr := strconv.FormatUint(uint64(projectID), 10)
  38. customer := types.Customer{
  39. Name: projectName,
  40. Aliases: []string{
  41. projIDStr,
  42. },
  43. BillingConfig: types.BillingConfig{
  44. BillingProviderType: "stripe",
  45. BillingProviderCustomerID: billingID,
  46. StripeCollectionMethod: defaultCollectionMethod,
  47. },
  48. CustomFields: map[string]string{
  49. "project_id": projIDStr,
  50. "user_email": userEmail,
  51. },
  52. }
  53. var result struct {
  54. Data types.Customer `json:"data"`
  55. }
  56. err = post(path, m.ApiKey, customer, &result)
  57. if err != nil {
  58. return customerID, telemetry.Error(ctx, span, err, "error creating customer")
  59. }
  60. return result.Data.ID, nil
  61. }
  62. // addCustomerPlan will start the customer on the given plan
  63. func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID) (customerPlanID uuid.UUID, err error) {
  64. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  65. defer span.End()
  66. if customerID == uuid.Nil || planID == uuid.Nil {
  67. return customerPlanID, telemetry.Error(ctx, span, err, "customer or plan id empty")
  68. }
  69. path := fmt.Sprintf("/customers/%s/plans/add", customerID)
  70. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  71. now := time.Now()
  72. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  73. startOn := midnightUTC.Format(time.RFC3339)
  74. req := types.AddCustomerPlanRequest{
  75. PlanID: planID,
  76. StartingOnUTC: startOn,
  77. }
  78. var result struct {
  79. Data struct {
  80. CustomerPlanID uuid.UUID `json:"id"`
  81. } `json:"data"`
  82. }
  83. err = post(path, m.ApiKey, req, &result)
  84. if err != nil {
  85. return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
  86. }
  87. return result.Data.CustomerPlanID, nil
  88. }
  89. // CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
  90. func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, planID string) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) {
  91. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  92. defer span.End()
  93. porterCloudPlanID, err := uuid.Parse(planID)
  94. if err != nil {
  95. return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error parsing starter plan id")
  96. }
  97. customerID, err = m.createCustomer(ctx, userEmail, projectName, projectID, billingID)
  98. if err != nil {
  99. return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error while creatinc customer with plan")
  100. }
  101. customerPlanID, err = m.addCustomerPlan(ctx, customerID, porterCloudPlanID)
  102. return customerID, customerPlanID, err
  103. }
  104. // EndCustomerPlan will immediately end the plan for the given customer
  105. func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UUID, customerPlanID uuid.UUID) (err error) {
  106. ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
  107. defer span.End()
  108. if customerID == uuid.Nil || customerPlanID == uuid.Nil {
  109. return telemetry.Error(ctx, span, err, "customer or customer plan id empty")
  110. }
  111. path := fmt.Sprintf("/customers/%s/plans/%s/end", customerID, customerPlanID)
  112. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  113. now := time.Now()
  114. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  115. endBefore := midnightUTC.Format(time.RFC3339)
  116. req := types.EndCustomerPlanRequest{
  117. EndingBeforeUTC: endBefore,
  118. }
  119. err = post(path, m.ApiKey, req, nil)
  120. if err != nil {
  121. return telemetry.Error(ctx, span, err, "failed to end customer plan")
  122. }
  123. return nil
  124. }
  125. // GetCustomerCredits will return the first credit grant for the customer
  126. func (m MetronomeClient) GetCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits int64, err error) {
  127. ctx, span := telemetry.NewSpan(ctx, "get-customer-credits")
  128. defer span.End()
  129. if customerID == uuid.Nil {
  130. return credits, telemetry.Error(ctx, span, err, "customer id empty")
  131. }
  132. path := "credits/listGrants"
  133. req := types.ListCreditGrantsRequest{
  134. CustomerIDs: []uuid.UUID{
  135. customerID,
  136. },
  137. }
  138. var result struct {
  139. Data []types.CreditGrant `json:"data"`
  140. }
  141. err = post(path, m.ApiKey, req, &result)
  142. if err != nil {
  143. return credits, telemetry.Error(ctx, span, err, "failed to get customer credits")
  144. }
  145. return result.Data[0].Balance.IncludingPending, nil
  146. }
  147. func post(path string, apiKey string, body interface{}, data interface{}) (err error) {
  148. client := http.Client{}
  149. endpoint, err := url.JoinPath(metronomeBaseUrl, path)
  150. if err != nil {
  151. return err
  152. }
  153. var bodyJson []byte
  154. if body != nil {
  155. bodyJson, err = json.Marshal(body)
  156. if err != nil {
  157. return err
  158. }
  159. }
  160. req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(bodyJson))
  161. if err != nil {
  162. return err
  163. }
  164. bearer := "Bearer " + apiKey
  165. req.Header.Set("Authorization", bearer)
  166. req.Header.Set("Content-Type", "application/json")
  167. resp, err := client.Do(req)
  168. if err != nil {
  169. return err
  170. }
  171. if resp.StatusCode != http.StatusOK {
  172. return fmt.Errorf("non 200 status code returned: %d", resp.StatusCode)
  173. }
  174. if data != nil {
  175. err = json.NewDecoder(resp.Body).Decode(data)
  176. if err != nil {
  177. return err
  178. }
  179. }
  180. _ = resp.Body.Close()
  181. return nil
  182. }