metronome.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  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. porterStandardTrialDays = 15
  20. defaultRewardAmountCents = 1000
  21. defaultPaidAmountCents = 0
  22. maxReferralRewards = 10
  23. maxIngestEventLimit = 100
  24. )
  25. // MetronomeClient is the client used to call the Metronome API
  26. type MetronomeClient struct {
  27. ApiKey string
  28. billableMetrics []types.BillableMetric
  29. PorterCloudPlanID uuid.UUID
  30. PorterStandardPlanID uuid.UUID
  31. // DefaultRewardAmountCents is the default amount in USD cents rewarded to users
  32. // who successfully refer a new user
  33. DefaultRewardAmountCents float64
  34. // DefaultPaidAmountCents is the amount paid by the user to get the credits
  35. // grant, if set to 0 it means they are free
  36. DefaultPaidAmountCents float64
  37. // MaxReferralRewards is the maximum number of referral rewards a user can receive
  38. MaxReferralRewards int64
  39. }
  40. // NewMetronomeClient returns a new Metronome client
  41. func NewMetronomeClient(metronomeApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client MetronomeClient, err error) {
  42. porterCloudPlanUUID, err := uuid.Parse(porterCloudPlanID)
  43. if err != nil {
  44. return client, err
  45. }
  46. porterStandardPlanUUID, err := uuid.Parse(porterStandardPlanID)
  47. if err != nil {
  48. return client, err
  49. }
  50. return MetronomeClient{
  51. ApiKey: metronomeApiKey,
  52. PorterCloudPlanID: porterCloudPlanUUID,
  53. PorterStandardPlanID: porterStandardPlanUUID,
  54. DefaultRewardAmountCents: defaultRewardAmountCents,
  55. DefaultPaidAmountCents: defaultPaidAmountCents,
  56. MaxReferralRewards: maxReferralRewards,
  57. }, nil
  58. }
  59. // CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
  60. 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) {
  61. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  62. defer span.End()
  63. var trialDays uint
  64. planID := m.PorterStandardPlanID
  65. projID := strconv.FormatUint(uint64(projectID), 10)
  66. if sandboxEnabled {
  67. planID = m.PorterCloudPlanID
  68. // This is necessary to avoid conflicts with Porter standard projects
  69. projID = fmt.Sprintf("porter-cloud-%s", projID)
  70. } else {
  71. trialDays = porterStandardTrialDays
  72. }
  73. customerID, err = m.createCustomer(ctx, userEmail, projectName, projID, billingID)
  74. if err != nil {
  75. return customerID, customerPlanID, telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID))
  76. }
  77. customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID, trialDays)
  78. return customerID, customerPlanID, err
  79. }
  80. // createCustomer will create the customer in Metronome
  81. func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID string, billingID string) (customerID uuid.UUID, err error) {
  82. ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
  83. defer span.End()
  84. path := "customers"
  85. customer := types.Customer{
  86. Name: projectName,
  87. Aliases: []string{
  88. projectID,
  89. },
  90. BillingConfig: types.BillingConfig{
  91. BillingProviderType: "stripe",
  92. BillingProviderCustomerID: billingID,
  93. StripeCollectionMethod: defaultCollectionMethod,
  94. },
  95. CustomFields: map[string]string{
  96. "project_id": projectID,
  97. "user_email": userEmail,
  98. },
  99. }
  100. var result struct {
  101. Data types.Customer `json:"data"`
  102. }
  103. _, err = m.do(http.MethodPost, path, "", customer, &result)
  104. if err != nil {
  105. return customerID, telemetry.Error(ctx, span, err, "error creating customer")
  106. }
  107. return result.Data.ID, nil
  108. }
  109. // addCustomerPlan will start the customer on the given plan
  110. func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID, trialDays uint) (customerPlanID uuid.UUID, err error) {
  111. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  112. defer span.End()
  113. if customerID == uuid.Nil || planID == uuid.Nil {
  114. return customerPlanID, telemetry.Error(ctx, span, err, "customer or plan id empty")
  115. }
  116. path := fmt.Sprintf("/customers/%s/plans/add", customerID)
  117. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  118. now := time.Now()
  119. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  120. startOn := midnightUTC.Format(time.RFC3339)
  121. req := types.AddCustomerPlanRequest{
  122. PlanID: planID,
  123. StartingOnUTC: startOn,
  124. }
  125. if trialDays != 0 {
  126. req.Trial = &types.TrialSpec{
  127. LengthInDays: int64(trialDays),
  128. }
  129. }
  130. var result struct {
  131. Data struct {
  132. CustomerPlanID uuid.UUID `json:"id"`
  133. } `json:"data"`
  134. }
  135. _, err = m.do(http.MethodPost, path, "", req, &result)
  136. if err != nil {
  137. return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
  138. }
  139. return result.Data.CustomerPlanID, nil
  140. }
  141. // ListCustomerPlan will return the current active plan to which the user is subscribed
  142. func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.UUID) (plan types.Plan, err error) {
  143. ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
  144. defer span.End()
  145. if customerID == uuid.Nil {
  146. return plan, telemetry.Error(ctx, span, err, "customer id empty")
  147. }
  148. path := fmt.Sprintf("/customers/%s/plans", customerID)
  149. var result struct {
  150. Data []types.Plan `json:"data"`
  151. }
  152. _, err = m.do(http.MethodGet, path, "", nil, &result)
  153. if err != nil {
  154. return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
  155. }
  156. if len(result.Data) > 0 {
  157. plan = result.Data[0]
  158. }
  159. return plan, nil
  160. }
  161. // EndCustomerPlan will immediately end the plan for the given customer
  162. func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UUID, customerPlanID uuid.UUID) (err error) {
  163. ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
  164. defer span.End()
  165. if customerID == uuid.Nil || customerPlanID == uuid.Nil {
  166. return telemetry.Error(ctx, span, err, "customer or customer plan id empty")
  167. }
  168. path := fmt.Sprintf("/customers/%s/plans/%s/end", customerID, customerPlanID)
  169. // Plan start time must be midnight UTC, formatted as RFC3339 timestamp
  170. now := time.Now()
  171. midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  172. endBefore := midnightUTC.Format(time.RFC3339)
  173. req := types.EndCustomerPlanRequest{
  174. EndingBeforeUTC: endBefore,
  175. }
  176. _, err = m.do(http.MethodPost, path, "", req, nil)
  177. if err != nil {
  178. return telemetry.Error(ctx, span, err, "failed to end customer plan")
  179. }
  180. return nil
  181. }
  182. // ListCustomerCredits will return the total number of credits for the customer
  183. func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits types.ListCreditGrantsResponse, err error) {
  184. ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  185. defer span.End()
  186. if customerID == uuid.Nil {
  187. return credits, telemetry.Error(ctx, span, err, "customer id empty")
  188. }
  189. path := "credits/listGrants"
  190. req := types.ListCreditGrantsRequest{
  191. CustomerIDs: []uuid.UUID{
  192. customerID,
  193. },
  194. }
  195. var result struct {
  196. Data []types.CreditGrant `json:"data"`
  197. }
  198. _, err = m.do(http.MethodPost, path, "", req, &result)
  199. if err != nil {
  200. return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
  201. }
  202. var response types.ListCreditGrantsResponse
  203. for _, grant := range result.Data {
  204. response.GrantedCredits += grant.GrantAmount.Amount
  205. response.RemainingCredits += grant.Balance.IncludingPending
  206. }
  207. return response, nil
  208. }
  209. // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
  210. func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, reason string, grantAmount float64, paidAmount float64, expiresAt string) (err error) {
  211. ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
  212. defer span.End()
  213. if customerID == uuid.Nil {
  214. return telemetry.Error(ctx, span, err, "customer id empty")
  215. }
  216. path := "credits/createGrant"
  217. creditTypeID, err := m.getCreditTypeID(ctx, "USD (cents)")
  218. if err != nil {
  219. return telemetry.Error(ctx, span, err, "failed to get credit type id")
  220. }
  221. req := types.CreateCreditsGrantRequest{
  222. CustomerID: customerID,
  223. UniquenessKey: uuid.NewString(),
  224. GrantAmount: types.GrantAmountID{
  225. Amount: grantAmount,
  226. CreditTypeID: creditTypeID,
  227. },
  228. PaidAmount: types.PaidAmount{
  229. Amount: paidAmount,
  230. CreditTypeID: creditTypeID,
  231. },
  232. Name: "Porter Credits",
  233. Reason: reason,
  234. ExpiresAt: expiresAt,
  235. Priority: 1,
  236. }
  237. statusCode, err := m.do(http.MethodPost, path, "", req, nil)
  238. if err != nil && statusCode != http.StatusConflict {
  239. // a conflict response indicates the grant already exists
  240. return telemetry.Error(ctx, span, err, "failed to create credits grant")
  241. }
  242. return nil
  243. }
  244. // ListCustomerUsage will return the aggregated usage for a customer
  245. func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) {
  246. ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
  247. defer span.End()
  248. if customerID == uuid.Nil {
  249. return usage, telemetry.Error(ctx, span, err, "customer id empty")
  250. }
  251. if len(m.billableMetrics) == 0 {
  252. billableMetrics, err := m.listBillableMetricIDs(ctx, customerID)
  253. if err != nil {
  254. return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics")
  255. }
  256. telemetry.WithAttributes(span,
  257. telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)},
  258. )
  259. // Cache billable metric ids for future calls
  260. m.billableMetrics = append(m.billableMetrics, billableMetrics...)
  261. }
  262. path := "usage/groups"
  263. startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore)
  264. if err != nil {
  265. return nil, telemetry.Error(ctx, span, err, err.Error())
  266. }
  267. baseReq := types.ListCustomerUsageRequest{
  268. CustomerID: customerID,
  269. WindowSize: windowsSize,
  270. StartingOn: startingOnTimestamp,
  271. EndingBefore: endingBeforeTimestamp,
  272. CurrentPeriod: currentPeriod,
  273. }
  274. for _, billableMetric := range m.billableMetrics {
  275. telemetry.WithAttributes(span,
  276. telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID},
  277. )
  278. var result struct {
  279. Data []types.CustomerUsageMetric `json:"data"`
  280. }
  281. baseReq.BillableMetricID = billableMetric.ID
  282. _, err = m.do(http.MethodPost, path, "", baseReq, &result)
  283. if err != nil {
  284. return usage, telemetry.Error(ctx, span, err, "failed to get customer usage")
  285. }
  286. usage = append(usage, types.Usage{
  287. MetricName: billableMetric.Name,
  288. UsageMetrics: result.Data,
  289. })
  290. }
  291. return usage, nil
  292. }
  293. // ListCustomerCosts will return the costs for a customer over a time period
  294. func (m MetronomeClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) {
  295. ctx, span := telemetry.NewSpan(ctx, "list-customer-costs")
  296. defer span.End()
  297. if customerID == uuid.Nil {
  298. return costs, telemetry.Error(ctx, span, err, "customer id empty")
  299. }
  300. path := fmt.Sprintf("customers/%s/costs", customerID)
  301. var result struct {
  302. Data []types.Cost `json:"data"`
  303. }
  304. startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore)
  305. if err != nil {
  306. return nil, telemetry.Error(ctx, span, err, err.Error())
  307. }
  308. queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOnTimestamp, endingBeforeTimestamp, limit)
  309. _, err = m.do(http.MethodGet, path, queryParams, nil, &result)
  310. if err != nil {
  311. return costs, telemetry.Error(ctx, span, err, "failed to create credits grant")
  312. }
  313. for _, customerCost := range result.Data {
  314. formattedCost := types.FormattedCost{
  315. StartTimestamp: customerCost.StartTimestamp,
  316. EndTimestamp: customerCost.EndTimestamp,
  317. }
  318. for _, creditType := range customerCost.CreditTypes {
  319. formattedCost.Cost += creditType.Cost
  320. }
  321. costs = append(costs, formattedCost)
  322. }
  323. return costs, nil
  324. }
  325. // IngestEvents sends a list of billing events to Metronome's ingest endpoint
  326. func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
  327. ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
  328. defer span.End()
  329. if len(events) == 0 {
  330. return nil
  331. }
  332. path := "ingest"
  333. for i := 0; i < len(events); i += maxIngestEventLimit {
  334. end := i + maxIngestEventLimit
  335. if end > len(events) {
  336. end = len(events)
  337. }
  338. batch := events[i:end]
  339. // Retry each batch to make sure all events are ingested
  340. var currentAttempts int
  341. for currentAttempts < defaultMaxRetries {
  342. statusCode, err := m.do(http.MethodPost, path, "", batch, nil)
  343. // Check errors that are not from error http codes
  344. if statusCode == 0 && err != nil {
  345. return telemetry.Error(ctx, span, err, "failed to ingest billing events")
  346. }
  347. if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
  348. return telemetry.Error(ctx, span, err, "unauthorized")
  349. }
  350. // 400 responses should not be retried
  351. if statusCode == http.StatusBadRequest {
  352. return telemetry.Error(ctx, span, err, "malformed billing events")
  353. }
  354. // Any other status code can be safely retried
  355. if statusCode == http.StatusOK {
  356. break
  357. }
  358. currentAttempts++
  359. }
  360. if currentAttempts == defaultMaxRetries {
  361. return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
  362. }
  363. }
  364. return nil
  365. }
  366. func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) {
  367. ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics")
  368. defer span.End()
  369. if customerID == uuid.Nil {
  370. return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty")
  371. }
  372. path := fmt.Sprintf("/customers/%s/billable-metrics", customerID)
  373. var result struct {
  374. Data []types.BillableMetric `json:"data"`
  375. }
  376. _, err = m.do(http.MethodGet, path, "", nil, &result)
  377. if err != nil {
  378. return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
  379. }
  380. return result.Data, nil
  381. }
  382. func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode string) (creditTypeID uuid.UUID, err error) {
  383. ctx, span := telemetry.NewSpan(ctx, "get-credit-type-id")
  384. defer span.End()
  385. path := "/credit-types/list"
  386. var result struct {
  387. Data []types.PricingUnit `json:"data"`
  388. }
  389. _, err = m.do(http.MethodGet, path, "", nil, &result)
  390. if err != nil {
  391. return creditTypeID, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
  392. }
  393. for _, pricingUnit := range result.Data {
  394. if pricingUnit.Name == currencyCode {
  395. return pricingUnit.ID, nil
  396. }
  397. }
  398. return creditTypeID, telemetry.Error(ctx, span, fmt.Errorf("credit type not found for currency code %s", currencyCode), "failed to find credit type")
  399. }
  400. // Utility function to parse and adjust times
  401. func parseAndCheckTimestamps(startingOn string, endingBefore string) (startingOnTimestamp string, endingBeforeTimestamp string, err error) {
  402. startingOnTime, err := time.Parse(time.RFC3339, startingOn)
  403. if err != nil {
  404. return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse starting on time: %w", err)
  405. }
  406. endingBeforeTime, err := time.Parse(time.RFC3339, endingBefore)
  407. if err != nil {
  408. return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse ending before time: %w", err)
  409. }
  410. if startingOnTime.Equal(endingBeforeTime) {
  411. // If starting and ending timestamps are the same, change the ending timestamp to be one day in the future
  412. endingBeforeTime = endingBeforeTime.Add(24 * time.Hour)
  413. }
  414. return startingOnTime.Format(time.RFC3339), endingBeforeTime.Format(time.RFC3339), nil
  415. }
  416. func (m MetronomeClient) do(method string, path string, queryParams string, body interface{}, data interface{}) (statusCode int, err error) {
  417. client := http.Client{}
  418. endpoint, err := url.JoinPath(metronomeBaseUrl, path)
  419. if err != nil {
  420. return statusCode, err
  421. }
  422. var bodyJson []byte
  423. if body != nil {
  424. bodyJson, err = json.Marshal(body)
  425. if err != nil {
  426. return statusCode, err
  427. }
  428. }
  429. // Add raw query parameters to the endpoint
  430. if queryParams != "" {
  431. endpoint += "?" + queryParams
  432. }
  433. req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
  434. if err != nil {
  435. return statusCode, err
  436. }
  437. bearer := "Bearer " + m.ApiKey
  438. req.Header.Set("Authorization", bearer)
  439. req.Header.Set("Content-Type", "application/json")
  440. resp, err := client.Do(req)
  441. if err != nil {
  442. return statusCode, err
  443. }
  444. statusCode = resp.StatusCode
  445. if resp.StatusCode != http.StatusOK {
  446. // If there is an error, try to decode the message
  447. var message map[string]string
  448. err = json.NewDecoder(resp.Body).Decode(&message)
  449. if err != nil {
  450. return statusCode, fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
  451. }
  452. _ = resp.Body.Close()
  453. return statusCode, fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
  454. }
  455. if data != nil {
  456. err = json.NewDecoder(resp.Body).Decode(data)
  457. if err != nil {
  458. return statusCode, err
  459. }
  460. }
  461. _ = resp.Body.Close()
  462. return statusCode, nil
  463. }