usage.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package billing
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "time"
  7. "github.com/getlago/lago-go-client"
  8. "github.com/porter-dev/porter/api/types"
  9. "github.com/porter-dev/porter/internal/telemetry"
  10. )
  11. const (
  12. defaultRewardAmountCents = 1000
  13. maxReferralRewards = 10
  14. defaultMaxRetries = 10
  15. maxIngestEventLimit = 100
  16. // These prefixes are used to build the customer and subscription IDs
  17. // in Lago. This way we can reuse the project IDs instead of storing
  18. // the Lago IDs in the database.
  19. // SubscriptionIDPrefix is the prefix for the subscription ID
  20. SubscriptionIDPrefix = "sub"
  21. // CustomerIDPrefix is the prefix for the customer ID
  22. CustomerIDPrefix = "cus"
  23. )
  24. // LagoClient is the client used to call the Lago API
  25. type LagoClient struct {
  26. client lago.Client
  27. PorterCloudPlanID string
  28. PorterStandardPlanID string
  29. // DefaultRewardAmountCents is the default amount in USD cents rewarded to users
  30. // who successfully refer a new user
  31. DefaultRewardAmountCents int64
  32. // MaxReferralRewards is the maximum number of referral rewards a user can receive
  33. MaxReferralRewards int64
  34. }
  35. // NewLagoClient returns a new Metronome client
  36. func NewLagoClient(lagoApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client LagoClient, err error) {
  37. lagoClient := lago.New().
  38. SetApiKey("__YOU_API_KEY__")
  39. return LagoClient{
  40. client: *lagoClient,
  41. PorterCloudPlanID: porterCloudPlanID,
  42. PorterStandardPlanID: porterStandardPlanID,
  43. DefaultRewardAmountCents: defaultRewardAmountCents,
  44. MaxReferralRewards: maxReferralRewards,
  45. }, nil
  46. }
  47. // CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
  48. func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) {
  49. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  50. defer span.End()
  51. planID := m.PorterStandardPlanID
  52. if sandboxEnabled {
  53. planID = m.PorterCloudPlanID
  54. }
  55. customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
  56. if err != nil {
  57. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID))
  58. }
  59. subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  60. err = m.addCustomerPlan(ctx, customerID, planID, subscriptionID)
  61. return err
  62. }
  63. // createCustomer will create the customer in Metronome
  64. func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
  65. ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
  66. defer span.End()
  67. customerID = m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  68. customerInput := &lago.CustomerInput{
  69. ExternalID: customerID,
  70. Name: projectName,
  71. Email: userEmail,
  72. BillingConfiguration: lago.CustomerBillingConfigurationInput{
  73. PaymentProvider: "stripe",
  74. ProviderCustomerID: billingID,
  75. },
  76. }
  77. _, lagoErr := m.client.Customer().Create(ctx, customerInput)
  78. if err != nil {
  79. return customerID, telemetry.Error(ctx, span, lagoErr.Err, "failed to create lago customer")
  80. }
  81. return customerID, nil
  82. }
  83. // addCustomerPlan will create a plan subscription for the customer
  84. func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string) (err error) {
  85. ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
  86. defer span.End()
  87. if projectID == "" || planID == "" {
  88. return telemetry.Error(ctx, span, err, "project and plan id are required")
  89. }
  90. now := time.Now()
  91. subscriptionInput := &lago.SubscriptionInput{
  92. ExternalCustomerID: projectID,
  93. ExternalID: subscriptionID,
  94. PlanCode: planID,
  95. SubscriptionAt: &now,
  96. BillingTime: lago.Calendar,
  97. }
  98. _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
  99. if err != nil {
  100. return telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription")
  101. }
  102. return nil
  103. }
  104. // ListCustomerPlan will return the current active plan to which the user is subscribed
  105. func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) {
  106. ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
  107. defer span.End()
  108. if projectID == 0 {
  109. return plan, telemetry.Error(ctx, span, err, "project id empty")
  110. }
  111. subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  112. subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID)
  113. if err != nil {
  114. return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription")
  115. }
  116. plan.StartingOn = subscription.StartedAt.Format(time.RFC3339)
  117. plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  118. plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339)
  119. return plan, nil
  120. }
  121. // EndCustomerPlan will immediately end the plan for the given customer
  122. func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) {
  123. ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
  124. defer span.End()
  125. if projectID == 0 {
  126. return telemetry.Error(ctx, span, err, "subscription id empty")
  127. }
  128. subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, false)
  129. subscriptionTerminateInput := lago.SubscriptionTerminateInput{
  130. ExternalID: subscriptionID,
  131. }
  132. _, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput)
  133. if lagoErr.Err != nil {
  134. return telemetry.Error(ctx, span, lagoErr.Err, "failed to terminate subscription")
  135. }
  136. return nil
  137. }
  138. // ListCustomerCredits will return the total number of credits for the customer
  139. // func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) {
  140. // ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  141. // defer span.End()
  142. // if customerID == "" {
  143. // return credits, telemetry.Error(ctx, span, err, "customer id empty")
  144. // }
  145. // walletListInput := &lago.WalletListInput{
  146. // ExternalCustomerID: customerID,
  147. // }
  148. // walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput)
  149. // if lagoErr.Err != nil {
  150. // return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet")
  151. // }
  152. // var response types.ListCreditGrantsResponse
  153. // for _, wallet := range walletList.Wallets {
  154. // response.GrantedBalanceCents += wallet.BalanceCents
  155. // response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents
  156. // }
  157. // return response, nil
  158. // }
  159. // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
  160. func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt string, sandboxEnabled bool) (err error) {
  161. ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
  162. defer span.End()
  163. if projectID == 0 {
  164. return telemetry.Error(ctx, span, err, "project id empty")
  165. }
  166. customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  167. expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
  168. if err != nil {
  169. return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp")
  170. }
  171. walletInput := &lago.WalletInput{
  172. ExternalCustomerID: customerID,
  173. Name: name,
  174. Currency: lago.USD,
  175. GrantedCredits: strconv.FormatInt(grantAmount, 10),
  176. RateAmount: "1",
  177. ExpirationAt: &expiresAtTime,
  178. }
  179. _, lagoErr := m.client.Wallet().Create(ctx, walletInput)
  180. if lagoErr.Err != nil {
  181. return telemetry.Error(ctx, span, lagoErr.Err, "failed to create credits grant")
  182. }
  183. return nil
  184. }
  185. // ListCustomerUsage will return the aggregated usage for a customer
  186. func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, currentPeriod bool, sandboxEnabled bool) (usage []types.Usage, err error) {
  187. ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
  188. defer span.End()
  189. if projectID == 0 {
  190. return usage, telemetry.Error(ctx, span, err, "project id empty")
  191. }
  192. subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  193. customerUsageInput := &lago.CustomerUsageInput{
  194. ExternalSubscriptionID: subscriptionID,
  195. }
  196. customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  197. _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
  198. if lagoErr.Err != nil {
  199. return usage, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage")
  200. }
  201. return usage, nil
  202. }
  203. // IngestEvents sends a list of billing events to Metronome's ingest endpoint
  204. func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent, enableSandbox bool) (err error) {
  205. ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
  206. defer span.End()
  207. if len(events) == 0 {
  208. return nil
  209. }
  210. for i := 0; i < len(events); i += maxIngestEventLimit {
  211. end := i + maxIngestEventLimit
  212. if end > len(events) {
  213. end = len(events)
  214. }
  215. batch := events[i:end]
  216. batchInput := make([]lago.EventInput, len(batch))
  217. for i := range batch {
  218. customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64)
  219. if err != nil {
  220. return telemetry.Error(ctx, span, err, "failed to parse customer ID")
  221. }
  222. externalSubscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox)
  223. event := lago.EventInput{
  224. TransactionID: batch[i].TransactionID,
  225. ExternalSubscriptionID: externalSubscriptionID,
  226. Code: batch[i].EventType,
  227. Timestamp: batch[i].Timestamp,
  228. Properties: batch[i].Properties,
  229. }
  230. batchInput = append(batchInput, event)
  231. }
  232. // Retry each batch to make sure all events are ingested
  233. var currentAttempts int
  234. for currentAttempts < defaultMaxRetries {
  235. m.client.Event().Batch(ctx, &batchInput)
  236. currentAttempts++
  237. }
  238. if currentAttempts == defaultMaxRetries {
  239. return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
  240. }
  241. }
  242. return nil
  243. }
  244. func (m LagoClient) GenerateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
  245. if sandboxEnabled {
  246. return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
  247. }
  248. return fmt.Sprintf("%s_%d", prefix, projectID)
  249. }