metronome.go 8.8 KB

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