metronome.go 5.2 KB

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