usage.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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. defaultStarterCreditsCents = 500
  16. defaultRewardAmountCents = 1000
  17. maxReferralRewards = 10
  18. defaultMaxRetries = 10
  19. maxIngestEventLimit = 100
  20. // porterStandardTrialDays is the number of days for the trial
  21. porterStandardTrialDays = 15
  22. // These prefixes are used to build the customer and subscription IDs
  23. // in Lago. This way we can reuse the project IDs instead of storing
  24. // the Lago IDs in the database.
  25. // TrialIDPrefix is the prefix for the trial ID
  26. TrialIDPrefix = "trial"
  27. // SubscriptionIDPrefix is the prefix for the subscription ID
  28. SubscriptionIDPrefix = "sub"
  29. // CustomerIDPrefix is the prefix for the customer ID
  30. CustomerIDPrefix = "cus"
  31. )
  32. // LagoClient is the client used to call the Lago API
  33. type LagoClient struct {
  34. client lago.Client
  35. lagoApiKey string
  36. PorterCloudPlanCode string
  37. PorterStandardPlanCode string
  38. PorterTrialCode string
  39. // DefaultRewardAmountCents is the default amount in USD cents rewarded to users
  40. // who successfully refer a new user
  41. DefaultRewardAmountCents int64
  42. // MaxReferralRewards is the maximum number of referral rewards a user can receive
  43. MaxReferralRewards int64
  44. }
  45. // NewLagoClient returns a new Lago client
  46. func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) {
  47. lagoClient := lago.New().SetApiKey(lagoApiKey)
  48. if lagoClient == nil {
  49. return client, fmt.Errorf("failed to create lago client")
  50. }
  51. // lagoClient.Debug = true
  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. starterWalletName := "Free Starter Credits"
  85. expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour)
  86. err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, 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. if sandboxEnabled {
  126. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
  127. return types.Plan{ID: subscriptionID}, nil
  128. }
  129. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  130. subscriptionListInput := lago.SubscriptionListInput{
  131. ExternalCustomerID: customerID,
  132. }
  133. activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
  134. if lagoErr != nil {
  135. return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
  136. }
  137. if activeSubscriptions == nil {
  138. return plan, telemetry.Error(ctx, span, err, "no active subscriptions found")
  139. }
  140. for _, subscription := range activeSubscriptions.Subscriptions {
  141. if subscription.Status != lago.SubscriptionStatusActive {
  142. continue
  143. }
  144. plan.ID = subscription.ExternalID
  145. plan.CustomerID = subscription.ExternalCustomerID
  146. plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
  147. plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  148. if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
  149. plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
  150. }
  151. break
  152. }
  153. return plan, nil
  154. }
  155. // EndCustomerPlan will immediately end the plan for the given customer
  156. func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) {
  157. ctx, span := telemetry.NewSpan(ctx, "end-lago-customer-plan")
  158. defer span.End()
  159. if projectID == 0 {
  160. return telemetry.Error(ctx, span, err, "subscription id empty")
  161. }
  162. subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, false)
  163. subscriptionTerminateInput := lago.SubscriptionTerminateInput{
  164. ExternalID: subscriptionID,
  165. }
  166. _, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput)
  167. if lagoErr != nil {
  168. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
  169. }
  170. return nil
  171. }
  172. // ListCustomerCredits will return the total number of credits for the customer
  173. func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, sandboxEnabled bool) (credits types.ListCreditGrantsResponse, err error) {
  174. ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
  175. defer span.End()
  176. if projectID == 0 {
  177. return credits, telemetry.Error(ctx, span, err, "project id empty")
  178. }
  179. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  180. // We manually do the request in this function because the Lago client has an issue
  181. // with types for this specific request
  182. lagoBaseURL := "https://api.getlago.com"
  183. url := fmt.Sprintf("%s/api/v1/wallets?external_customer_id=%s", lagoBaseURL, customerID)
  184. req, err := http.NewRequest("GET", url, nil)
  185. if err != nil {
  186. return credits, telemetry.Error(ctx, span, err, "failed to create wallets request")
  187. }
  188. req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
  189. client := &http.Client{}
  190. resp, err := client.Do(req)
  191. if err != nil {
  192. return credits, telemetry.Error(ctx, span, err, "failed to get customer credits")
  193. }
  194. type ListWalletsResponse struct {
  195. Wallets []types.Wallet `json:"wallets"`
  196. }
  197. var walletList ListWalletsResponse
  198. err = json.NewDecoder(resp.Body).Decode(&walletList)
  199. if err != nil {
  200. return credits, telemetry.Error(ctx, span, err, "failed to decode wallet list response")
  201. }
  202. var response types.ListCreditGrantsResponse
  203. for _, wallet := range walletList.Wallets {
  204. if wallet.Status != string(lago.Active) {
  205. continue
  206. }
  207. response.GrantedBalanceCents += wallet.BalanceCents
  208. response.RemainingBalanceCents += wallet.OngoingBalanceCents
  209. }
  210. err = resp.Body.Close()
  211. if err != nil {
  212. return credits, telemetry.Error(ctx, span, err, "failed to close response body")
  213. }
  214. return response, nil
  215. }
  216. // CreateCreditsGrant will create a new credit grant for the customer with the specified amount
  217. func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) {
  218. ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
  219. defer span.End()
  220. if projectID == 0 {
  221. return telemetry.Error(ctx, span, err, "project id empty")
  222. }
  223. customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  224. walletInput := &lago.WalletInput{
  225. ExternalCustomerID: customerID,
  226. Name: name,
  227. Currency: lago.USD,
  228. GrantedCredits: strconv.FormatInt(grantAmount, 10),
  229. // Rate is 1 credit = 1 cent
  230. RateAmount: "0.01",
  231. ExpirationAt: expiresAt,
  232. }
  233. _, lagoErr := m.client.Wallet().Create(ctx, walletInput)
  234. if lagoErr != nil {
  235. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant")
  236. }
  237. return nil
  238. }
  239. // ListCustomerUsage will return the aggregated usage for a customer
  240. func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool) (usage types.Usage, err error) {
  241. ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
  242. defer span.End()
  243. if subscriptionID == "" {
  244. return usage, telemetry.Error(ctx, span, err, "subscription id empty")
  245. }
  246. if currentPeriod {
  247. customerUsageInput := &lago.CustomerUsageInput{
  248. ExternalSubscriptionID: subscriptionID,
  249. }
  250. currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
  251. if lagoErr != nil {
  252. return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
  253. }
  254. usage.FromDatetime = currentUsage.FromDatetime.Format(time.RFC3339)
  255. usage.ToDatetime = currentUsage.ToDatetime.Format(time.RFC3339)
  256. usage.TotalAmountCents = int64(currentUsage.TotalAmountCents)
  257. usage.ChargesUsage = make([]types.ChargeUsage, len(currentUsage.ChargesUsage))
  258. for i, charge := range currentUsage.ChargesUsage {
  259. usage.ChargesUsage[i] = types.ChargeUsage{
  260. Units: charge.Units,
  261. AmountCents: int64(charge.AmountCents),
  262. AmountCurrency: string(charge.AmountCurrency),
  263. BillableMetric: types.BillableMetric{
  264. Name: charge.BillableMetric.Name,
  265. },
  266. }
  267. }
  268. }
  269. return usage, nil
  270. }
  271. // IngestEvents sends a list of billing events to Lago's ingest endpoint
  272. func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) {
  273. ctx, span := telemetry.NewSpan(ctx, "ingest-billing-events")
  274. defer span.End()
  275. if len(events) == 0 {
  276. return nil
  277. }
  278. for i := 0; i < len(events); i += maxIngestEventLimit {
  279. end := i + maxIngestEventLimit
  280. if end > len(events) {
  281. end = len(events)
  282. }
  283. batch := events[i:end]
  284. batchInput := make([]lago.EventInput, len(batch))
  285. for i := range batch {
  286. externalSubscriptionID := subscriptionID
  287. if enableSandbox {
  288. // This hack has to be done because we can't infer the project id from the
  289. // context in Porter Cloud
  290. customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64)
  291. if err != nil {
  292. return telemetry.Error(ctx, span, err, "failed to parse customer ID")
  293. }
  294. externalSubscriptionID = m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox)
  295. }
  296. event := lago.EventInput{
  297. TransactionID: batch[i].TransactionID,
  298. ExternalSubscriptionID: externalSubscriptionID,
  299. Code: batch[i].EventType,
  300. Timestamp: batch[i].Timestamp,
  301. Properties: batch[i].Properties,
  302. }
  303. batchInput = append(batchInput, event)
  304. }
  305. // Retry each batch to make sure all events are ingested
  306. var currentAttempts int
  307. for currentAttempts < defaultMaxRetries {
  308. _, lagoErr := m.client.Event().Batch(ctx, &batchInput)
  309. if lagoErr == nil {
  310. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "error sending ingest events to Lago")
  311. }
  312. currentAttempts++
  313. }
  314. if currentAttempts == defaultMaxRetries {
  315. return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
  316. }
  317. }
  318. return nil
  319. }
  320. // ListCustomerFinalizedInvoices will return all finalized invoices for the customer
  321. func (m LagoClient) ListCustomerFinalizedInvoices(ctx context.Context, projectID uint, enableSandbox bool) (invoiceList []types.Invoice, err error) {
  322. ctx, span := telemetry.NewSpan(ctx, "list-customer-invoices")
  323. defer span.End()
  324. if projectID == 0 {
  325. return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty")
  326. }
  327. customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
  328. invoiceListInput := &lago.InvoiceListInput{
  329. ExternalCustomerID: customerID,
  330. Status: lago.InvoiceStatusFinalized,
  331. }
  332. invoices, lagoErr := m.client.Invoice().GetList(ctx, invoiceListInput)
  333. if lagoErr != nil {
  334. return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to list invoices")
  335. }
  336. for _, invoice := range invoices.Invoices {
  337. invoiceReq, lagoErr := m.client.Invoice().Download(ctx, invoice.LagoID.String())
  338. if lagoErr != nil {
  339. return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to download invoice")
  340. }
  341. var fileURL string
  342. if invoiceReq == nil {
  343. fileURL = invoice.FileURL
  344. } else {
  345. fileURL = invoiceReq.FileURL
  346. }
  347. invoiceList = append(invoiceList, types.Invoice{
  348. HostedInvoiceURL: fileURL,
  349. Status: string(invoice.Status),
  350. Created: invoice.IssuingDate,
  351. })
  352. }
  353. return invoiceList, nil
  354. }
  355. // createCustomer will create the customer in Lago
  356. func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
  357. ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
  358. defer span.End()
  359. customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
  360. customerInput := &lago.CustomerInput{
  361. ExternalID: customerID,
  362. Name: projectName,
  363. Email: userEmail,
  364. BillingConfiguration: lago.CustomerBillingConfigurationInput{
  365. PaymentProvider: lago.PaymentProviderStripe,
  366. ProviderCustomerID: billingID,
  367. Sync: false,
  368. SyncWithProvider: false,
  369. },
  370. }
  371. _, lagoErr := m.client.Customer().Create(ctx, customerInput)
  372. if lagoErr != nil {
  373. return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
  374. }
  375. return customerID, nil
  376. }
  377. // addCustomerPlan will create a plan subscription for the customer
  378. func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
  379. ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
  380. defer span.End()
  381. if customerID == "" || planID == "" {
  382. return telemetry.Error(ctx, span, err, "project and plan id are required")
  383. }
  384. subscriptionInput := &lago.SubscriptionInput{
  385. ExternalCustomerID: customerID,
  386. ExternalID: subscriptionID,
  387. PlanCode: planID,
  388. SubscriptionAt: startingAt,
  389. EndingAt: endingAt,
  390. BillingTime: lago.Calendar,
  391. }
  392. _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
  393. if lagoErr != nil {
  394. return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
  395. }
  396. return nil
  397. }
  398. func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
  399. if sandboxEnabled {
  400. return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
  401. }
  402. return fmt.Sprintf("%s_%d", prefix, projectID)
  403. }