usage.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package billing
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/getlago/lago-go-client"
  11. "github.com/porter-dev/porter/api/types"
  12. "github.com/porter-dev/porter/internal/telemetry"
  13. )
  14. const (
  15. lagoBaseURL = "https://api.getlago.com"
  16. defaultStarterCreditsCents = 500
  17. defaultRewardAmountCents = 1000
  18. maxReferralRewards = 10
  19. defaultMaxRetries = 10
  20. maxIngestEventLimit = 100
  21. // porterStandardTrialDays is the number of days for the trial
  22. porterStandardTrialDays = 15
  23. // These prefixes are used to build the customer and subscription IDs
  24. // in Lago. This way we can reuse the project IDs instead of storing
  25. // the Lago IDs in the database.
  26. // TrialIDPrefix is the prefix for the trial ID
  27. TrialIDPrefix = "trial"
  28. // SubscriptionIDPrefix is the prefix for the subscription ID
  29. SubscriptionIDPrefix = "sub"
  30. // CustomerIDPrefix is the prefix for the customer ID
  31. CustomerIDPrefix = "cus"
  32. )
  33. // LagoClient is the client used to call the Lago API
  34. type LagoClient struct {
  35. client lago.Client
  36. lagoApiKey string
  37. PorterCloudPlanCode string
  38. PorterStandardPlanCode string
  39. PorterTrialCode string
  40. // DefaultRewardAmountCents is the default amount in USD cents rewarded to users
  41. // who successfully refer a new user
  42. DefaultRewardAmountCents int64
  43. // MaxReferralRewards is the maximum number of referral rewards a user can receive
  44. MaxReferralRewards int64
  45. }
  46. // NewLagoClient returns a new Lago client
  47. func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) {
  48. lagoClient := lago.New().SetApiKey(lagoApiKey)
  49. if lagoClient == nil {
  50. return client, fmt.Errorf("failed to create lago client")
  51. }
  52. return LagoClient{
  53. lagoApiKey: lagoApiKey,
  54. client: *lagoClient,
  55. PorterCloudPlanCode: porterCloudPlanCode,
  56. PorterStandardPlanCode: porterStandardPlanCode,
  57. PorterTrialCode: porterTrialCode,
  58. DefaultRewardAmountCents: defaultRewardAmountCents,
  59. MaxReferralRewards: maxReferralRewards,
  60. }, nil
  61. }
  62. // CreateCustomerWithPlan will create the customer in Lago and immediately add it to the plan
  63. func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) {
  64. ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
  65. defer span.End()
  66. if projectID == 0 {
  67. return telemetry.Error(ctx, span, err, "project id empty")
  68. }
  69. customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
  70. if err != nil {
  71. return telemetry.Error(ctx, span, err, "error while creating customer")
  72. }
  73. trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled)
  74. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  75. // The dates need to be at midnight UTC
  76. now := time.Now().UTC()
  77. now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
  78. trialEndTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(time.Hour * 24 * porterStandardTrialDays).UTC()
  79. if sandboxEnabled {
  80. err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil)
  81. if err != nil {
  82. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode))
  83. }
  84. walletName := "Porter Credits"
  85. expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour)
  86. err = m.CreateCreditsGrant(ctx, projectID, walletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled)
  87. if err != nil {
  88. return telemetry.Error(ctx, span, err, "error while creating starter credits grant")
  89. }
  90. return nil
  91. }
  92. // First, start the new customer on the trial
  93. err = m.addCustomerPlan(ctx, customerID, m.PorterTrialCode, trialID, &now, &trialEndTime)
  94. if err != nil {
  95. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while starting customer trial %s", m.PorterTrialCode))
  96. }
  97. // Then, add the customer to the actual plan. The date of the subscription will be the end of the trial
  98. err = m.addCustomerPlan(ctx, customerID, m.PorterStandardPlanCode, subscriptionID, &trialEndTime, nil)
  99. if err != nil {
  100. return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterStandardPlanCode))
  101. }
  102. return err
  103. }
  104. // CheckIfCustomerExists will check if the customer exists in Lago
  105. func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) {
  106. ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists")
  107. defer span.End()
  108. if projectID == 0 {
  109. return exists, telemetry.Error(ctx, span, err, "project id empty")
  110. }
  111. customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
  112. _, lagoErr := m.client.Customer().Get(ctx, customerID)
  113. if lagoErr != nil {
  114. return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer")
  115. }
  116. return true, nil
  117. }
  118. // GetCustomeActivePlan will return the active plan for the customer
  119. func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) {
  120. ctx, span := telemetry.NewSpan(ctx, "get-active-subscription")
  121. defer span.End()
  122. if projectID == 0 {
  123. return plan, telemetry.Error(ctx, span, err, "project id empty")
  124. }
  125. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  126. subscriptionListInput := lago.SubscriptionListInput{
  127. ExternalCustomerID: customerID,
  128. }
  129. activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
  130. if lagoErr != nil {
  131. return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
  132. }
  133. if activeSubscriptions == nil {
  134. return plan, telemetry.Error(ctx, span, err, "no active subscriptions found")
  135. }
  136. for _, subscription := range activeSubscriptions.Subscriptions {
  137. if subscription.Status != lago.SubscriptionStatusActive {
  138. continue
  139. }
  140. plan.ID = subscription.ExternalID
  141. plan.CustomerID = subscription.ExternalCustomerID
  142. plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
  143. if subscription.EndingAt != nil {
  144. plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  145. }
  146. if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
  147. plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  148. }
  149. break
  150. }
  151. return plan, nil
  152. }
  153. // DeleteCustomer will delete the customer and terminate all subscriptions
  154. func (m LagoClient) DeleteCustomer(ctx context.Context, projectID uint, sandboxEnabled bool) (err error) {
  155. ctx, span := telemetry.NewSpan(ctx, "delete-lago-customer")
  156. defer span.End()
  157. if projectID == 0 {
  158. return telemetry.Error(ctx, span, err, "subscription id empty")
  159. }
  160. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  161. _, lagoErr := m.client.Customer().Delete(ctx, customerID)
  162. if lagoErr != nil {
  163. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
  164. }
  165. return nil
  166. }
  167. // ListCustomerCredits will return the total number of credits for the customer
  168. func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, sandboxEnabled bool) (credits types.ListCreditGrantsResponse, err error) {
  169. ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  170. defer span.End()
  171. if projectID == 0 {
  172. return credits, telemetry.Error(ctx, span, err, "project id empty")
  173. }
  174. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  175. walletList, err := m.listCustomerWallets(ctx, customerID)
  176. if err != nil {
  177. return credits, telemetry.Error(ctx, span, err, "failed to list customer wallets")
  178. }
  179. var response types.ListCreditGrantsResponse
  180. for _, wallet := range walletList {
  181. if wallet.Status != string(lago.Active) {
  182. continue
  183. }
  184. response.GrantedBalanceCents += wallet.BalanceCents
  185. response.RemainingBalanceCents += wallet.OngoingBalanceCents
  186. }
  187. return response, nil
  188. }
  189. // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
  190. func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) {
  191. ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
  192. defer span.End()
  193. if projectID == 0 {
  194. return telemetry.Error(ctx, span, err, "project id empty")
  195. }
  196. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  197. walletList, err := m.listCustomerWallets(ctx, customerID)
  198. if err != nil {
  199. return telemetry.Error(ctx, span, err, "failed to list customer wallets")
  200. }
  201. if len(walletList) == 0 {
  202. walletInput := &lago.WalletInput{
  203. ExternalCustomerID: customerID,
  204. Name: name,
  205. Currency: lago.USD,
  206. GrantedCredits: strconv.FormatInt(grantAmount, 10),
  207. // Rate is 1 credit = 1 cent
  208. RateAmount: "0.01",
  209. ExpirationAt: expiresAt,
  210. }
  211. _, lagoErr := m.client.Wallet().Create(ctx, walletInput)
  212. if lagoErr != nil {
  213. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant")
  214. }
  215. return nil
  216. }
  217. // Currently only one wallet per customer is supported in Lago
  218. wallet := walletList[0]
  219. walletTransactionInput := &lago.WalletTransactionInput{
  220. WalletID: wallet.LagoID.String(),
  221. GrantedCredits: strconv.FormatInt(grantAmount, 10),
  222. }
  223. // If the wallet already exists, we need to update the balance
  224. _, lagoErr := m.client.WalletTransaction().Create(ctx, walletTransactionInput)
  225. if lagoErr != nil {
  226. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to update credits grant")
  227. }
  228. return nil
  229. }
  230. // ListCustomerUsage will return the aggregated usage for a customer
  231. func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool, previousPeriods int) (usageList []types.Usage, err error) {
  232. ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
  233. defer span.End()
  234. if subscriptionID == "" {
  235. return usageList, telemetry.Error(ctx, span, err, "subscription id empty")
  236. }
  237. if currentPeriod {
  238. customerUsageInput := &lago.CustomerUsageInput{
  239. ExternalSubscriptionID: subscriptionID,
  240. }
  241. currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
  242. if lagoErr != nil {
  243. return usageList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
  244. }
  245. if currentUsage == nil {
  246. return usageList, nil
  247. }
  248. usage := createUsageFromLagoUsage(*currentUsage)
  249. usageList = append(usageList, usage)
  250. } else {
  251. url := fmt.Sprintf("%s/api/v1/customers/%s/past_usage?external_subscription_id=%s&periods_count=%d", lagoBaseURL, customerID, subscriptionID, previousPeriods)
  252. req, err := http.NewRequest("GET", url, nil)
  253. if err != nil {
  254. return usageList, telemetry.Error(ctx, span, err, "failed to create wallets request")
  255. }
  256. req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
  257. client := &http.Client{}
  258. resp, err := client.Do(req)
  259. if err != nil {
  260. return usageList, telemetry.Error(ctx, span, err, "failed to get customer credits")
  261. }
  262. var previousUsage lago.CustomerPastUsageResult
  263. err = json.NewDecoder(resp.Body).Decode(&previousUsage)
  264. if err != nil {
  265. return usageList, telemetry.Error(ctx, span, err, "failed to decode usage list response")
  266. }
  267. for _, pastUsage := range previousUsage.UsagePeriods {
  268. usage := createUsageFromLagoUsage(pastUsage)
  269. usageList = append(usageList, usage)
  270. }
  271. }
  272. return usageList, nil
  273. }
  274. // IngestEvents sends a list of billing events to Lago's ingest endpoint
  275. func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) {
  276. ctx, span := telemetry.NewSpan(ctx, "ingest-billing-events")
  277. defer span.End()
  278. if len(events) == 0 {
  279. return nil
  280. }
  281. for i := 0; i < len(events); i += maxIngestEventLimit {
  282. end := i + maxIngestEventLimit
  283. if end > len(events) {
  284. end = len(events)
  285. }
  286. batch := events[i:end]
  287. var batchInput []lago.EventInput
  288. for i := range batch {
  289. event := lago.EventInput{
  290. TransactionID: batch[i].TransactionID,
  291. ExternalSubscriptionID: subscriptionID,
  292. Code: batch[i].EventType,
  293. Properties: batch[i].Properties,
  294. }
  295. batchInput = append(batchInput, event)
  296. }
  297. // Retry each batch to make sure all events are ingested
  298. var currentAttempts int
  299. for currentAttempts := 0; currentAttempts < defaultMaxRetries; currentAttempts++ {
  300. _, lagoErr := m.client.Event().Batch(ctx, &batchInput)
  301. if lagoErr == nil {
  302. return nil
  303. }
  304. }
  305. if currentAttempts == defaultMaxRetries {
  306. return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
  307. }
  308. }
  309. return nil
  310. }
  311. // ListCustomerFinalizedInvoices will return all finalized invoices for the customer
  312. func (m LagoClient) ListCustomerFinalizedInvoices(ctx context.Context, projectID uint, enableSandbox bool) (invoiceList []types.Invoice, err error) {
  313. ctx, span := telemetry.NewSpan(ctx, "list-customer-invoices")
  314. defer span.End()
  315. if projectID == 0 {
  316. return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty")
  317. }
  318. customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
  319. invoiceListInput := &lago.InvoiceListInput{
  320. ExternalCustomerID: customerID,
  321. Status: lago.InvoiceStatusFinalized,
  322. }
  323. invoices, lagoErr := m.client.Invoice().GetList(ctx, invoiceListInput)
  324. if lagoErr != nil {
  325. return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to list invoices")
  326. }
  327. for _, invoice := range invoices.Invoices {
  328. invoiceReq, lagoErr := m.client.Invoice().Download(ctx, invoice.LagoID.String())
  329. if lagoErr != nil {
  330. return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to download invoice")
  331. }
  332. var fileURL string
  333. if invoiceReq == nil {
  334. fileURL = invoice.FileURL
  335. } else {
  336. fileURL = invoiceReq.FileURL
  337. }
  338. invoiceList = append(invoiceList, types.Invoice{
  339. HostedInvoiceURL: fileURL,
  340. Status: string(invoice.Status),
  341. Created: invoice.IssuingDate,
  342. })
  343. }
  344. return invoiceList, nil
  345. }
  346. // createCustomer will create the customer in Lago
  347. func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
  348. ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
  349. defer span.End()
  350. customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  351. customerInput := &lago.CustomerInput{
  352. ExternalID: customerID,
  353. Name: projectName,
  354. Email: userEmail,
  355. BillingConfiguration: lago.CustomerBillingConfigurationInput{
  356. PaymentProvider: lago.PaymentProviderStripe,
  357. ProviderCustomerID: billingID,
  358. Sync: false,
  359. SyncWithProvider: false,
  360. },
  361. }
  362. _, lagoErr := m.client.Customer().Create(ctx, customerInput)
  363. if lagoErr != nil {
  364. return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
  365. }
  366. return customerID, nil
  367. }
  368. // addCustomerPlan will create a plan subscription for the customer
  369. func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
  370. ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
  371. defer span.End()
  372. if customerID == "" || planID == "" {
  373. return telemetry.Error(ctx, span, err, "project and plan id are required")
  374. }
  375. subscriptionInput := &lago.SubscriptionInput{
  376. ExternalCustomerID: customerID,
  377. ExternalID: subscriptionID,
  378. PlanCode: planID,
  379. SubscriptionAt: startingAt,
  380. EndingAt: endingAt,
  381. BillingTime: lago.Calendar,
  382. }
  383. _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
  384. if lagoErr != nil {
  385. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
  386. }
  387. return nil
  388. }
  389. func (m LagoClient) listCustomerWallets(ctx context.Context, customerID string) (walletList []types.Wallet, err error) {
  390. ctx, span := telemetry.NewSpan(ctx, "list-lago-customer-wallets")
  391. defer span.End()
  392. // We manually do the request in this function because the Lago client has an issue
  393. // with types for this specific request
  394. url := fmt.Sprintf("%s/api/v1/wallets?external_customer_id=%s", lagoBaseURL, customerID)
  395. req, err := http.NewRequest("GET", url, nil)
  396. if err != nil {
  397. return walletList, telemetry.Error(ctx, span, err, "failed to create wallets list request")
  398. }
  399. req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
  400. client := &http.Client{}
  401. resp, err := client.Do(req)
  402. if err != nil {
  403. return walletList, telemetry.Error(ctx, span, err, "failed to get customer credits")
  404. }
  405. response := struct {
  406. Wallets []types.Wallet `json:"wallets"`
  407. }{}
  408. err = json.NewDecoder(resp.Body).Decode(&response)
  409. if err != nil {
  410. return walletList, telemetry.Error(ctx, span, err, "failed to decode wallet list response")
  411. }
  412. err = resp.Body.Close()
  413. if err != nil {
  414. return walletList, telemetry.Error(ctx, span, err, "failed to close response body")
  415. }
  416. return response.Wallets, nil
  417. }
  418. func createUsageFromLagoUsage(lagoUsage lago.CustomerUsage) types.Usage {
  419. usage := types.Usage{}
  420. usage.FromDatetime = lagoUsage.FromDatetime.Format(time.RFC3339)
  421. usage.ToDatetime = lagoUsage.ToDatetime.Format(time.RFC3339)
  422. usage.TotalAmountCents = int64(lagoUsage.TotalAmountCents)
  423. usage.ChargesUsage = make([]types.ChargeUsage, len(lagoUsage.ChargesUsage))
  424. for i, charge := range lagoUsage.ChargesUsage {
  425. usage.ChargesUsage[i] = types.ChargeUsage{
  426. Units: charge.Units,
  427. AmountCents: int64(charge.AmountCents),
  428. AmountCurrency: string(charge.AmountCurrency),
  429. BillableMetric: types.BillableMetric{
  430. Name: charge.BillableMetric.Name,
  431. },
  432. }
  433. }
  434. return usage
  435. }
  436. func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
  437. if sandboxEnabled {
  438. return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
  439. }
  440. return fmt.Sprintf("%s_%d", prefix, projectID)
  441. }