metronome.go 9.8 KB

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