usage.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. package billing
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/getlago/lago-go-client"
  9. "github.com/porter-dev/porter/api/types"
  10. "github.com/porter-dev/porter/internal/telemetry"
  11. )
  12. const (
  13. defaultStarterCreditsCents = 500
  14. defaultRewardAmountCents = 1000
  15. maxReferralRewards = 10
  16. defaultMaxRetries = 10
  17. maxIngestEventLimit = 100
  18. // porterStandardTrialDays is the number of days for the trial
  19. porterStandardTrialDays = 15
  20. // These prefixes are used to build the customer and subscription IDs
  21. // in Lago. This way we can reuse the project IDs instead of storing
  22. // the Lago IDs in the database.
  23. // TrialIDPrefix is the prefix for the trial ID
  24. TrialIDPrefix = "trial"
  25. // SubscriptionIDPrefix is the prefix for the subscription ID
  26. SubscriptionIDPrefix = "sub"
  27. // CustomerIDPrefix is the prefix for the customer ID
  28. CustomerIDPrefix = "cus"
  29. )
  30. // LagoClient is the client used to call the Lago API
  31. type LagoClient struct {
  32. client lago.Client
  33. PorterCloudPlanCode string
  34. PorterStandardPlanCode string
  35. PorterTrialCode string
  36. // DefaultRewardAmountCents is the default amount in USD cents rewarded to users
  37. // who successfully refer a new user
  38. DefaultRewardAmountCents int64
  39. // MaxReferralRewards is the maximum number of referral rewards a user can receive
  40. MaxReferralRewards int64
  41. }
  42. // NewLagoClient returns a new Lago client
  43. func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) {
  44. lagoClient := lago.New().SetApiKey(lagoApiKey)
  45. if lagoClient == nil {
  46. return client, fmt.Errorf("failed to create lago client")
  47. }
  48. // lagoClient.Debug = true
  49. return LagoClient{
  50. client: *lagoClient,
  51. PorterCloudPlanCode: porterCloudPlanCode,
  52. PorterStandardPlanCode: porterStandardPlanCode,
  53. PorterTrialCode: porterTrialCode,
  54. DefaultRewardAmountCents: defaultRewardAmountCents,
  55. MaxReferralRewards: maxReferralRewards,
  56. }, nil
  57. }
  58. // CreateCustomerWithPlan will create the customer in Lago and immediately add it to the plan
  59. func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) {
  60. ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
  61. defer span.End()
  62. if projectID == 0 {
  63. return telemetry.Error(ctx, span, err, "project id empty")
  64. }
  65. customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
  66. if err != nil {
  67. return telemetry.Error(ctx, span, err, "error while creating customer")
  68. }
  69. trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled)
  70. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  71. // The dates need to be at midnight UTC
  72. now := time.Now().UTC()
  73. now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  74. trialEndTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(time.Hour * 24 * porterStandardTrialDays).UTC()
  75. if sandboxEnabled {
  76. err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil)
  77. if err != nil {
  78. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode))
  79. }
  80. starterWalletName := "Free Starter Credits"
  81. expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour)
  82. err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled)
  83. return nil
  84. }
  85. // First, start the new customer on the trial
  86. err = m.addCustomerPlan(ctx, customerID, m.PorterTrialCode, trialID, &now, &trialEndTime)
  87. if err != nil {
  88. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while starting customer trial %s", m.PorterTrialCode))
  89. }
  90. // Then, add the customer to the actual plan. The date of the subscription will be the end of the trial
  91. err = m.addCustomerPlan(ctx, customerID, m.PorterStandardPlanCode, subscriptionID, &trialEndTime, nil)
  92. if err != nil {
  93. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterStandardPlanCode))
  94. }
  95. return err
  96. }
  97. func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) {
  98. ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists")
  99. defer span.End()
  100. if projectID == 0 {
  101. return exists, telemetry.Error(ctx, span, err, "project id empty")
  102. }
  103. customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
  104. _, lagoErr := m.client.Customer().Get(ctx, customerID)
  105. if lagoErr != nil {
  106. return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer")
  107. }
  108. return true, nil
  109. }
  110. func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) {
  111. ctx, span := telemetry.NewSpan(ctx, "get-active-subscription")
  112. defer span.End()
  113. if projectID == 0 {
  114. return plan, telemetry.Error(ctx, span, err, "project id empty")
  115. }
  116. if sandboxEnabled {
  117. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  118. return types.Plan{ID: subscriptionID}, nil
  119. }
  120. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  121. subscriptionListInput := lago.SubscriptionListInput{
  122. ExternalCustomerID: customerID,
  123. }
  124. activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
  125. if lagoErr != nil {
  126. return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
  127. }
  128. if activeSubscriptions == nil {
  129. return plan, telemetry.Error(ctx, span, err, "no active subscriptions found")
  130. }
  131. for _, subscription := range activeSubscriptions.Subscriptions {
  132. if subscription.Status != lago.SubscriptionStatusActive {
  133. continue
  134. }
  135. plan.ID = subscription.ExternalID
  136. plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
  137. plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  138. if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
  139. plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  140. }
  141. break
  142. }
  143. return plan, nil
  144. }
  145. // EndCustomerPlan will immediately end the plan for the given customer
  146. func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) {
  147. ctx, span := telemetry.NewSpan(ctx, "end-lago-customer-plan")
  148. defer span.End()
  149. if projectID == 0 {
  150. return telemetry.Error(ctx, span, err, "subscription id empty")
  151. }
  152. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, false)
  153. subscriptionTerminateInput := lago.SubscriptionTerminateInput{
  154. ExternalID: subscriptionID,
  155. }
  156. _, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput)
  157. if lagoErr != nil {
  158. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
  159. }
  160. return nil
  161. }
  162. // ListCustomerCredits will return the total number of credits for the customer
  163. // func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) {
  164. // ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  165. // defer span.End()
  166. // if customerID == "" {
  167. // return credits, telemetry.Error(ctx, span, err, "customer id empty")
  168. // }
  169. // walletListInput := &lago.WalletListInput{
  170. // ExternalCustomerID: customerID,
  171. // }
  172. // walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput)
  173. // if lagoErr.Err != nil {
  174. // return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet")
  175. // }
  176. // var response types.ListCreditGrantsResponse
  177. // for _, wallet := range walletList.Wallets {
  178. // response.GrantedBalanceCents += wallet.BalanceCents
  179. // response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents
  180. // }
  181. // return response, nil
  182. // }
  183. // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
  184. func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) {
  185. ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
  186. defer span.End()
  187. if projectID == 0 {
  188. return telemetry.Error(ctx, span, err, "project id empty")
  189. }
  190. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  191. walletInput := &lago.WalletInput{
  192. ExternalCustomerID: customerID,
  193. Name: name,
  194. Currency: lago.USD,
  195. GrantedCredits: strconv.FormatInt(grantAmount, 10),
  196. // Rate is 1 credit = 1 cent
  197. RateAmount: "0.01",
  198. ExpirationAt: expiresAt,
  199. }
  200. _, lagoErr := m.client.Wallet().Create(ctx, walletInput)
  201. if lagoErr != nil {
  202. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant")
  203. }
  204. return nil
  205. }
  206. // ListCustomerUsage will return the aggregated usage for a customer
  207. func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, currentPeriod bool, sandboxEnabled bool) (usage []types.Usage, err error) {
  208. ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
  209. defer span.End()
  210. if projectID == 0 {
  211. return usage, telemetry.Error(ctx, span, err, "project id empty")
  212. }
  213. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  214. customerUsageInput := &lago.CustomerUsageInput{
  215. ExternalSubscriptionID: subscriptionID,
  216. }
  217. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  218. _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
  219. if lagoErr != nil {
  220. return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
  221. }
  222. return usage, nil
  223. }
  224. // IngestEvents sends a list of billing events to Lago's ingest endpoint
  225. func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) {
  226. ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
  227. defer span.End()
  228. if len(events) == 0 {
  229. return nil
  230. }
  231. for i := 0; i < len(events); i += maxIngestEventLimit {
  232. end := i + maxIngestEventLimit
  233. if end > len(events) {
  234. end = len(events)
  235. }
  236. batch := events[i:end]
  237. batchInput := make([]lago.EventInput, len(batch))
  238. for i := range batch {
  239. externalSubscriptionID := subscriptionID
  240. if enableSandbox {
  241. // This hack has to be done because we can't infer the project id from the
  242. // context in Porter Cloud
  243. customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64)
  244. if err != nil {
  245. return telemetry.Error(ctx, span, err, "failed to parse customer ID")
  246. }
  247. externalSubscriptionID = m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox)
  248. }
  249. event := lago.EventInput{
  250. TransactionID: batch[i].TransactionID,
  251. ExternalSubscriptionID: externalSubscriptionID,
  252. Code: batch[i].EventType,
  253. Timestamp: batch[i].Timestamp,
  254. Properties: batch[i].Properties,
  255. }
  256. batchInput = append(batchInput, event)
  257. }
  258. // Retry each batch to make sure all events are ingested
  259. var currentAttempts int
  260. for currentAttempts < defaultMaxRetries {
  261. m.client.Event().Batch(ctx, &batchInput)
  262. currentAttempts++
  263. }
  264. if currentAttempts == defaultMaxRetries {
  265. return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
  266. }
  267. }
  268. return nil
  269. }
  270. // ListCustomerInvoices will return all invoices for the customer with the given status
  271. func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint) (invoiceList []types.Invoice, err error) {
  272. ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls")
  273. defer span.End()
  274. if projectID == 0 {
  275. return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty")
  276. }
  277. return invoiceList, nil
  278. }
  279. // createCustomer will create the customer in Lago
  280. func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
  281. ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
  282. defer span.End()
  283. customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  284. customerInput := &lago.CustomerInput{
  285. ExternalID: customerID,
  286. Name: projectName,
  287. Email: userEmail,
  288. BillingConfiguration: lago.CustomerBillingConfigurationInput{
  289. PaymentProvider: lago.PaymentProviderStripe,
  290. ProviderCustomerID: billingID,
  291. Sync: false,
  292. SyncWithProvider: false,
  293. },
  294. }
  295. _, lagoErr := m.client.Customer().Create(ctx, customerInput)
  296. if lagoErr != nil {
  297. return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
  298. }
  299. return customerID, nil
  300. }
  301. // addCustomerPlan will create a plan subscription for the customer
  302. func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
  303. ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
  304. defer span.End()
  305. if customerID == "" || planID == "" {
  306. return telemetry.Error(ctx, span, err, "project and plan id are required")
  307. }
  308. subscriptionInput := &lago.SubscriptionInput{
  309. ExternalCustomerID: customerID,
  310. ExternalID: subscriptionID,
  311. PlanCode: planID,
  312. SubscriptionAt: startingAt,
  313. EndingAt: endingAt,
  314. BillingTime: lago.Calendar,
  315. }
  316. _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
  317. if lagoErr != nil {
  318. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
  319. }
  320. return nil
  321. }
  322. func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
  323. if sandboxEnabled {
  324. return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
  325. }
  326. return fmt.Sprintf("%s_%d", prefix, projectID)
  327. }