metronome.go 12 KB

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