metronome.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  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. // CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
  33. 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) {
  34. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  35. defer span.End()
  36. porterCloudPlanID, err := uuid.Parse(planID)
  37. if err != nil {
  38. return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error parsing starter plan id")
  39. }
  40. customerID, err = m.createCustomer(ctx, userEmail, projectName, projectID, billingID)
  41. if err != nil {
  42. return customerID, customerPlanID, telemetry.Error(ctx, span, err, "error while creatinc customer with plan")
  43. }
  44. customerPlanID, err = m.addCustomerPlan(ctx, customerID, porterCloudPlanID)
  45. return customerID, customerPlanID, err
  46. }
  47. // createCustomer will create the customer in Metronome
  48. func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string) (customerID uuid.UUID, err error) {
  49. ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
  50. defer span.End()
  51. path := "customers"
  52. projIDStr := strconv.FormatUint(uint64(projectID), 10)
  53. customer := types.Customer{
  54. Name: projectName,
  55. Aliases: []string{
  56. projIDStr,
  57. },
  58. BillingConfig: types.BillingConfig{
  59. BillingProviderType: "stripe",
  60. BillingProviderCustomerID: billingID,
  61. StripeCollectionMethod: defaultCollectionMethod,
  62. },
  63. CustomFields: map[string]string{
  64. "project_id": projIDStr,
  65. "user_email": userEmail,
  66. },
  67. }
  68. var result struct {
  69. Data types.Customer `json:"data"`
  70. }
  71. err = do(http.MethodPost, path, m.ApiKey, customer, &result)
  72. if err != nil {
  73. return customerID, telemetry.Error(ctx, span, err, "error creating customer")
  74. }
  75. return result.Data.ID, nil
  76. }
  77. // addCustomerPlan will start the customer on the given plan
  78. func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID) (customerPlanID uuid.UUID, err error) {
  79. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  80. defer span.End()
  81. if customerID == uuid.Nil || planID == uuid.Nil {
  82. return customerPlanID, telemetry.Error(ctx, span, err, "customer or plan id empty")
  83. }
  84. path := fmt.Sprintf("/customers/%s/plans/add", customerID)
  85. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  86. now := time.Now()
  87. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  88. startOn := midnightUTC.Format(time.RFC3339)
  89. req := types.AddCustomerPlanRequest{
  90. PlanID: planID,
  91. StartingOnUTC: startOn,
  92. }
  93. var result struct {
  94. Data struct {
  95. CustomerPlanID uuid.UUID `json:"id"`
  96. } `json:"data"`
  97. }
  98. err = do(http.MethodPost, path, m.ApiKey, req, &result)
  99. if err != nil {
  100. return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
  101. }
  102. return result.Data.CustomerPlanID, nil
  103. }
  104. // ListCustomerPlan will return the current active plan to which the user is subscribed
  105. func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.UUID) (plan types.Plan, err error) {
  106. ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
  107. defer span.End()
  108. if customerID == uuid.Nil {
  109. return plan, telemetry.Error(ctx, span, err, "customer id empty")
  110. }
  111. path := fmt.Sprintf("/customers/%s/plans", customerID)
  112. var result struct {
  113. Data []types.Plan `json:"data"`
  114. }
  115. err = do(http.MethodGet, path, m.ApiKey, nil, &result)
  116. if err != nil {
  117. return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
  118. }
  119. return result.Data[0], nil
  120. }
  121. // EndCustomerPlan will immediately end the plan for the given customer
  122. func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UUID, customerPlanID uuid.UUID) (err error) {
  123. ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
  124. defer span.End()
  125. if customerID == uuid.Nil || customerPlanID == uuid.Nil {
  126. return telemetry.Error(ctx, span, err, "customer or customer plan id empty")
  127. }
  128. path := fmt.Sprintf("/customers/%s/plans/%s/end", customerID, customerPlanID)
  129. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  130. now := time.Now()
  131. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  132. endBefore := midnightUTC.Format(time.RFC3339)
  133. req := types.EndCustomerPlanRequest{
  134. EndingBeforeUTC: endBefore,
  135. }
  136. err = do(http.MethodPost, path, m.ApiKey, req, nil)
  137. if err != nil {
  138. return telemetry.Error(ctx, span, err, "failed to end customer plan")
  139. }
  140. return nil
  141. }
  142. // ListCustomerCredits will return the list of credit grants for the customer
  143. func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits []types.CreditGrant, err error) {
  144. ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  145. defer span.End()
  146. if customerID == uuid.Nil {
  147. return credits, telemetry.Error(ctx, span, err, "customer id empty")
  148. }
  149. path := "credits/listGrants"
  150. req := types.ListCreditGrantsRequest{
  151. CustomerIDs: []uuid.UUID{
  152. customerID,
  153. },
  154. }
  155. var result struct {
  156. Data []types.CreditGrant `json:"data"`
  157. }
  158. err = do(http.MethodPost, path, m.ApiKey, req, &result)
  159. if err != nil {
  160. return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
  161. }
  162. return result.Data, nil
  163. }
  164. func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uuid.UUID, dashboardType string, options []types.DashboardOptions, colorOverrides []types.ColorOverrides) (url string, err error) {
  165. ctx, span := telemetry.NewSpan(ctx, "get-customer-usage-dashboard")
  166. defer span.End()
  167. if customerID == uuid.Nil {
  168. return url, telemetry.Error(ctx, span, err, "customer id empty")
  169. }
  170. path := "dashboards/getEmbeddableUrl"
  171. req := types.EmbeddableDashboardRequest{
  172. CustomerID: customerID,
  173. Options: options,
  174. DashboardType: dashboardType,
  175. ColorOverrides: colorOverrides,
  176. }
  177. var result struct {
  178. Data map[string]string `json:"data"`
  179. }
  180. err = do(http.MethodPost, path, m.ApiKey, req, &result)
  181. if err != nil {
  182. return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
  183. }
  184. return result.Data["url"], nil
  185. }
  186. func do(method string, path string, apiKey string, body interface{}, data interface{}) (err error) {
  187. client := http.Client{}
  188. endpoint, err := url.JoinPath(metronomeBaseUrl, path)
  189. if err != nil {
  190. return err
  191. }
  192. var bodyJson []byte
  193. if body != nil {
  194. bodyJson, err = json.Marshal(body)
  195. if err != nil {
  196. return err
  197. }
  198. }
  199. req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
  200. if err != nil {
  201. return err
  202. }
  203. bearer := "Bearer " + apiKey
  204. req.Header.Set("Authorization", bearer)
  205. req.Header.Set("Content-Type", "application/json")
  206. resp, err := client.Do(req)
  207. if err != nil {
  208. return err
  209. }
  210. if resp.StatusCode != http.StatusOK {
  211. // If there is an error, try to decode the message
  212. var message map[string]string
  213. err = json.NewDecoder(resp.Body).Decode(&message)
  214. if err != nil {
  215. return fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
  216. }
  217. _ = resp.Body.Close()
  218. return fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
  219. }
  220. if data != nil {
  221. err = json.NewDecoder(resp.Body).Decode(data)
  222. if err != nil {
  223. return err
  224. }
  225. }
  226. _ = resp.Body.Close()
  227. return nil
  228. }