billing.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import moment from 'moment-timezone';
  2. import type {PromptData} from 'sentry/actionCreators/prompts';
  3. import {DataCategory} from 'sentry/types/core';
  4. import type {Organization} from 'sentry/types/organization';
  5. import {defined} from 'sentry/utils';
  6. import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
  7. import {
  8. BILLION,
  9. DEFAULT_TRIAL_DAYS,
  10. GIGABYTE,
  11. MILLION,
  12. RESERVED_BUDGET_QUOTA,
  13. TRIAL_PLANS,
  14. UNLIMITED,
  15. UNLIMITED_RESERVED,
  16. } from 'getsentry/constants';
  17. import type {
  18. BillingConfig,
  19. BillingMetricHistory,
  20. EventBucket,
  21. Plan,
  22. ProductTrial,
  23. Subscription,
  24. } from 'getsentry/types';
  25. import {PlanName, PlanTier} from 'getsentry/types';
  26. import titleCase from 'getsentry/utils/titleCase';
  27. import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils';
  28. export const MILLISECONDS_IN_HOUR = 3600_000;
  29. function isNum(val: unknown): val is number {
  30. return typeof val === 'number';
  31. }
  32. // TODO(brendan): remove condition for 0 once -1 is the value we use to represent unlimited reserved quota
  33. export function isUnlimitedReserved(value: number | null | undefined): boolean {
  34. return value === UNLIMITED_RESERVED;
  35. }
  36. export const getSlot = (
  37. events?: number,
  38. price?: number,
  39. slots?: EventBucket[],
  40. shouldMinimize = false
  41. ) => {
  42. let s = 0;
  43. if (!slots?.length || (typeof events !== 'number' && typeof price !== 'number')) {
  44. return 0;
  45. }
  46. const byEvents = typeof events === 'number';
  47. const value = isNum(events) ? events : isNum(price) ? price : null;
  48. if (value === null) {
  49. return 0;
  50. }
  51. const slotKey = byEvents ? 'events' : 'price';
  52. while (value > slots[s]![slotKey]) {
  53. s++;
  54. if (s >= slots.length - 1) {
  55. if (shouldMinimize) {
  56. return Math.max(s - 1, 0);
  57. }
  58. return Math.min(s, slots.length - 1);
  59. }
  60. }
  61. // If the specified number of events does not match any of the slots we have,
  62. // we return the slot down if shouldMinimize is true, otherwise we always return
  63. // the next slot up (ie. 500 events when the slots are [50, 5000] would return 50
  64. // when shouldMinimize is true, and 5000 when it is false or unspecified)
  65. if (
  66. shouldMinimize &&
  67. ((byEvents && slots[s]![slotKey] !== events) ||
  68. (!byEvents && slots[s]![slotKey] !== price))
  69. ) {
  70. return Math.max(s - 1, 0);
  71. }
  72. return Math.min(s, slots.length - 1);
  73. };
  74. type ReservedSku =
  75. | Subscription['reservedErrors']
  76. | Subscription['reservedTransactions']
  77. | Subscription['reservedAttachments']
  78. | number
  79. | null;
  80. /**
  81. * isAbbreviated: Shortens the number using K for thousand, M for million, etc
  82. * Useful for Errors/Transactions but not recommended to be used
  83. * with Attachments because "1K GB" is hard to read.
  84. * isGifted: For gifted data volumes, 0 is displayed as 0 instead of unlimited.
  85. * useUnitScaling: For Attachments only. Scale from KB -> MB -> GB -> TB -> etc
  86. */
  87. type FormatOptions = {
  88. fractionDigits?: number;
  89. isAbbreviated?: boolean;
  90. isGifted?: boolean;
  91. useUnitScaling?: boolean;
  92. };
  93. /**
  94. * This expects values from CustomerSerializer, which contains quota/reserved
  95. * quantities for the data categories that we sell.
  96. *
  97. * Note: reservedQuantity for Attachments should be in GIGABYTES
  98. * If isReservedBudget is true, the reservedQuantity is in cents
  99. */
  100. export function formatReservedWithUnits(
  101. reservedQuantity: ReservedSku,
  102. dataCategory: string,
  103. options: FormatOptions = {
  104. isAbbreviated: false,
  105. useUnitScaling: false,
  106. isGifted: false,
  107. },
  108. isReservedBudget = false
  109. ): string {
  110. if (isReservedBudget) {
  111. return displayPriceWithCents({cents: reservedQuantity ?? 0});
  112. }
  113. if (dataCategory !== DataCategory.ATTACHMENTS) {
  114. return formatReservedNumberToString(reservedQuantity, options);
  115. }
  116. // convert reservedQuantity to BYTES to check for unlimited
  117. const usageGb = reservedQuantity ? reservedQuantity * GIGABYTE : reservedQuantity;
  118. if (isUnlimitedReserved(usageGb)) {
  119. return !options.isGifted ? UNLIMITED : '0 GB';
  120. }
  121. if (!options.useUnitScaling) {
  122. const formatted = formatReservedNumberToString(reservedQuantity, options);
  123. return `${formatted} GB`;
  124. }
  125. return formatAttachmentUnits(reservedQuantity || 0, 3);
  126. }
  127. /**
  128. * This expects values from CustomerUsageEndpoint, which contains usage
  129. * quantities for the data categories that we sell.
  130. *
  131. * Note: usageQuantity for Attachments should be in BYTES
  132. */
  133. export function formatUsageWithUnits(
  134. usageQuantity = 0,
  135. dataCategory: string,
  136. options: FormatOptions = {isAbbreviated: false, useUnitScaling: false}
  137. ) {
  138. if (dataCategory === DataCategory.ATTACHMENTS) {
  139. if (options.useUnitScaling) {
  140. return formatAttachmentUnits(usageQuantity);
  141. }
  142. const usageGb = usageQuantity / GIGABYTE;
  143. return options.isAbbreviated
  144. ? `${displayNumber(usageGb)} GB`
  145. : `${usageGb.toLocaleString(undefined, {maximumFractionDigits: 2})} GB`;
  146. }
  147. if (dataCategory === DataCategory.PROFILE_DURATION) {
  148. const usageProfileHours = usageQuantity / MILLISECONDS_IN_HOUR;
  149. if (usageProfileHours === 0) {
  150. return '0';
  151. }
  152. return options.isAbbreviated
  153. ? displayNumber(usageProfileHours, 1)
  154. : usageProfileHours.toLocaleString(undefined, {maximumFractionDigits: 1});
  155. }
  156. return options.isAbbreviated
  157. ? displayNumber(usageQuantity, 0)
  158. : usageQuantity.toLocaleString();
  159. }
  160. /**
  161. * Do not export.
  162. * Helper method for formatReservedWithUnits
  163. */
  164. function formatReservedNumberToString(
  165. reservedQuantity: ReservedSku,
  166. options: FormatOptions = {
  167. isAbbreviated: false,
  168. isGifted: false,
  169. useUnitScaling: false,
  170. fractionDigits: 0,
  171. }
  172. ): string {
  173. // "null" indicates that there's no quota for it.
  174. if (!defined(reservedQuantity)) {
  175. return '0';
  176. }
  177. if (reservedQuantity === RESERVED_BUDGET_QUOTA) {
  178. return 'N/A';
  179. }
  180. if (isUnlimitedReserved(reservedQuantity) && !options.isGifted) {
  181. return UNLIMITED;
  182. }
  183. return options.isAbbreviated
  184. ? displayNumber(reservedQuantity, options.fractionDigits)
  185. : reservedQuantity.toLocaleString(undefined, {maximumFractionDigits: 1});
  186. }
  187. /**
  188. * Do not export.
  189. * Use formatReservedWithUnits or formatUsageWithUnits instead.
  190. *
  191. * This function is different from sentry/utils/formatBytes. Note the
  192. * difference between *a-bytes (base 10) vs *i-bytes (base 2), which means that:
  193. * - 1000 megabytes is equal to 1 gigabyte
  194. * - 1024 mebibytes is equal to 1024 gibibytes
  195. *
  196. * We will use base 10 throughout billing for attachments. This function formats
  197. * quota/usage values for display.
  198. *
  199. * For storage/memory/file sizes, please take a look at the function in
  200. * sentry/utils/formatBytes.
  201. */
  202. function formatAttachmentUnits(bytes: number, u = 0) {
  203. const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
  204. const threshold = 1000;
  205. while (bytes >= threshold) {
  206. bytes /= threshold;
  207. u += 1;
  208. }
  209. return bytes.toLocaleString(undefined, {maximumFractionDigits: 2}) + ' ' + units[u];
  210. }
  211. /**
  212. * Do not export.
  213. * Use formatReservedWithUnits or formatUsageWithUnits with options.isAbbreviated to true
  214. */
  215. function displayNumber(n: number, fractionDigits = 0) {
  216. if (n >= BILLION) {
  217. return (n / BILLION).toLocaleString(undefined, {maximumFractionDigits: 2}) + 'B';
  218. }
  219. if (n >= MILLION) {
  220. return (n / MILLION).toLocaleString(undefined, {maximumFractionDigits: 1}) + 'M';
  221. }
  222. if (n >= 1000) {
  223. return (n / 1000).toFixed().toLocaleString() + 'K';
  224. }
  225. // Do not show decimals
  226. return n.toFixed(fractionDigits).toLocaleString();
  227. }
  228. /**
  229. * Utility functions for Pricing Plans
  230. */
  231. export const isEnterprise = (subscription: Subscription) =>
  232. ['e1', 'enterprise'].some(p => subscription.plan.startsWith(p));
  233. export const isTrialPlan = (plan: string) => TRIAL_PLANS.includes(plan);
  234. export const hasPerformance = (plan?: Plan) => {
  235. return (
  236. // Older plans will have Transactions
  237. plan?.categories?.includes(DataCategory.TRANSACTIONS) ||
  238. // AM3 Onwards will have Spans
  239. plan?.categories?.includes(DataCategory.SPANS)
  240. );
  241. };
  242. export const hasPartnerMigrationFeature = (organization: Organization) =>
  243. organization.features.includes('partner-billing-migration');
  244. export const hasActiveVCFeature = (organization: Organization) =>
  245. organization.features.includes('vc-marketplace-active-customer');
  246. export const isDeveloperPlan = (plan?: Plan) => plan?.name === PlanName.DEVELOPER;
  247. export const isBizPlanFamily = (plan?: Plan) =>
  248. plan?.name === PlanName.BUSINESS ||
  249. plan?.name === PlanName.BUSINESS_BUNDLE ||
  250. plan?.name === PlanName.BUSINESS_SPONSORED;
  251. export const isTeamPlanFamily = (plan?: Plan) =>
  252. plan?.name === PlanName.TEAM ||
  253. plan?.name === PlanName.TEAM_BUNDLE ||
  254. plan?.name === PlanName.TEAM_SPONSORED;
  255. export const isBusinessTrial = (subscription: Subscription) => {
  256. return (
  257. subscription.isTrial &&
  258. !subscription.isPerformancePlanTrial &&
  259. !subscription.isEnterpriseTrial
  260. );
  261. };
  262. export function isAmPlan(planId?: string) {
  263. return typeof planId === 'string' && planId.startsWith('am');
  264. }
  265. function isAm2Plan(planId?: string) {
  266. return typeof planId === 'string' && planId.startsWith('am2');
  267. }
  268. export function isAm3Plan(planId?: string) {
  269. return typeof planId === 'string' && planId.startsWith('am3');
  270. }
  271. export function isAm3DsPlan(planId?: string) {
  272. return typeof planId === 'string' && planId.startsWith('am3') && planId.includes('_ds');
  273. }
  274. export function isAmEnterprisePlan(planId?: string) {
  275. return (
  276. typeof planId === 'string' &&
  277. planId.startsWith('am') &&
  278. (planId.endsWith('_ent') ||
  279. planId.endsWith('_ent_auf') ||
  280. planId.endsWith('_ent_ds') ||
  281. planId.endsWith('_ent_ds_auf'))
  282. );
  283. }
  284. export function hasJustStartedPlanTrial(subscription: Subscription) {
  285. return subscription.isTrial && subscription.isTrialStarted;
  286. }
  287. export const displayPlanName = (plan?: Plan | null) => {
  288. return isAmEnterprisePlan(plan?.id) ? 'Enterprise' : (plan?.name ?? '[unavailable]');
  289. };
  290. export const getAmPlanTier = (plan: string) => {
  291. if (isAm3Plan(plan)) {
  292. return PlanTier.AM3;
  293. }
  294. if (isAm2Plan(plan)) {
  295. return PlanTier.AM2;
  296. }
  297. if (isAmPlan(plan)) {
  298. return PlanTier.AM1;
  299. }
  300. return null;
  301. };
  302. /**
  303. * Promotion utility functions that are based off of formData which has the plan as a string
  304. * instead of a Plan
  305. */
  306. export const getBusinessPlanOfTier = (plan: string) =>
  307. plan.startsWith('am2_') ? 'am2_business' : 'am1_business';
  308. export const isTeamPlan = (plan: string) => plan.includes('team');
  309. /**
  310. * Get the number of days left on trial
  311. */
  312. export function getTrialDaysLeft(subscription: Subscription): number {
  313. // trial end is in the future
  314. return -1 * getDaysSinceDate(subscription.trialEnd ?? '');
  315. }
  316. /**
  317. * Get the number of days left on contract
  318. */
  319. export function getContractDaysLeft(subscription: Subscription): number {
  320. // contract period end is in the future
  321. return -1 * getDaysSinceDate(subscription.contractPeriodEnd ?? '');
  322. }
  323. /**
  324. * Return a sorted list of plans the user can upgrade to.
  325. * Used to find the best plan for an org to upgrade to
  326. * based on a particular feature to unlock.
  327. */
  328. function sortPlansForUpgrade(billingConfig: BillingConfig, subscription: Subscription) {
  329. // Filter plans down to just user selectable plans types of the orgs current
  330. // contract interval. Sorted by price as features will become progressively
  331. // more available.
  332. let plans = billingConfig.planList
  333. .sort((a, b) => a.price - b.price)
  334. .filter(p => p.userSelectable && p.billingInterval === subscription.billingInterval);
  335. // If we're dealing with plans that are *not part of a tier* Then we can
  336. // assume special case that there is only one plan.
  337. if (billingConfig.id === null && plans.length === 0) {
  338. plans = billingConfig.planList;
  339. }
  340. return plans;
  341. }
  342. export function getBestPlanForUnlimitedMembers(
  343. billingConfig: BillingConfig,
  344. subscription: Subscription
  345. ) {
  346. const plans = sortPlansForUpgrade(billingConfig, subscription);
  347. // the best plan is the first one that has unlimited members
  348. return plans.find(p => p.maxMembers === null);
  349. }
  350. export function getTrialLength(_organization: Organization) {
  351. // currently only doing trials of 14 days
  352. return DEFAULT_TRIAL_DAYS;
  353. }
  354. export function formatBalance(value: number) {
  355. return value < 0
  356. ? `${displayPriceWithCents({cents: 0 - value})} credit`
  357. : `${displayPriceWithCents({cents: value})} owed`;
  358. }
  359. export enum UsageAction {
  360. START_TRIAL = 'start_trial',
  361. ADD_EVENTS = 'add_events',
  362. REQUEST_ADD_EVENTS = 'request_add_events',
  363. REQUEST_UPGRADE = 'request_upgrade',
  364. SEND_TO_CHECKOUT = 'send_to_checkout',
  365. }
  366. /**
  367. * Return the best action that user can take so that organization
  368. * can get more events.
  369. */
  370. export function getBestActionToIncreaseEventLimits(
  371. organization: Organization,
  372. subscription: Subscription
  373. ) {
  374. const isPaidPlan = subscription.planDetails?.price > 0;
  375. const hasBillingPerms = organization.access?.includes('org:billing');
  376. // free orgs can increase event limits by trialing
  377. if (!isPaidPlan && subscription.canTrial) {
  378. return UsageAction.START_TRIAL;
  379. }
  380. // paid plans should add events without changing plans
  381. if (isPaidPlan && hasPerformance(subscription.planDetails)) {
  382. return hasBillingPerms ? UsageAction.ADD_EVENTS : UsageAction.REQUEST_ADD_EVENTS;
  383. }
  384. // otherwise, we want them to upgrade to a different plan
  385. return hasBillingPerms ? UsageAction.SEND_TO_CHECKOUT : UsageAction.REQUEST_UPGRADE;
  386. }
  387. /**
  388. * Returns a name for the plan that we can display to users
  389. */
  390. export function getFriendlyPlanName(subscription: Subscription) {
  391. const {name} = subscription.planDetails;
  392. switch (name) {
  393. case 'Trial':
  394. return 'Business Trial';
  395. default:
  396. return name;
  397. }
  398. }
  399. export function hasAccessToSubscriptionOverview(
  400. subscription: Subscription,
  401. organization: Organization
  402. ) {
  403. return organization.access.includes('org:billing') || subscription.canSelfServe;
  404. }
  405. /**
  406. * Returns the soft cap type for the given metric history category that can be
  407. * displayed to users if applicable. Returns null for if no soft cap type.
  408. */
  409. export function getSoftCapType(metricHistory: BillingMetricHistory): string | null {
  410. if (metricHistory.softCapType) {
  411. return titleCase(metricHistory.softCapType.replace(/_/g, ' '));
  412. }
  413. if (metricHistory.trueForward) {
  414. return 'True Forward';
  415. }
  416. return null;
  417. }
  418. /**
  419. * Returns:
  420. * active trial with latest end date, if available, else
  421. * available trial with most trial days, else
  422. * most recently ended trial, else
  423. * null,
  424. * in that order.
  425. */
  426. export function getProductTrial(
  427. productTrials: ProductTrial[] | null,
  428. category: DataCategory
  429. ): ProductTrial | null {
  430. const trialsForCategory =
  431. productTrials
  432. ?.filter(pt => pt.category === category)
  433. .sort((a, b) => b.endDate?.localeCompare(a.endDate ?? '') || 0) ?? [];
  434. const activeProductTrial = getActiveProductTrial(trialsForCategory, category);
  435. if (activeProductTrial) {
  436. return activeProductTrial;
  437. }
  438. const longestAvailableTrial = getPotentialProductTrial(trialsForCategory, category);
  439. if (longestAvailableTrial) {
  440. return longestAvailableTrial;
  441. }
  442. return trialsForCategory[0] ?? null;
  443. }
  444. /**
  445. * Returns the currently active product trial for the specified category if there is one,
  446. * otherwise, returns null.
  447. */
  448. export function getActiveProductTrial(
  449. productTrials: ProductTrial[] | null,
  450. category: DataCategory
  451. ): ProductTrial | null {
  452. if (!productTrials) {
  453. return null;
  454. }
  455. const currentTrials = productTrials
  456. .filter(
  457. pt =>
  458. pt.category === category &&
  459. pt.isStarted &&
  460. getDaysSinceDate(pt.endDate ?? '') <= 0
  461. )
  462. .sort((a, b) => b.endDate?.localeCompare(a.endDate ?? '') || 0);
  463. return currentTrials[0] ?? null;
  464. }
  465. /**
  466. * Returns the longest available trial for the specified category if there is one,
  467. * otherwise, returns null.
  468. */
  469. export function getPotentialProductTrial(
  470. productTrials: ProductTrial[] | null,
  471. category: DataCategory
  472. ): ProductTrial | null {
  473. if (!productTrials) {
  474. return null;
  475. }
  476. const potentialTrials = productTrials
  477. .filter(
  478. pt =>
  479. pt.category === category &&
  480. !pt.isStarted &&
  481. getDaysSinceDate(pt.endDate ?? '') <= 0
  482. )
  483. .sort((a, b) => (b.lengthDays ?? 0) - (a.lengthDays ?? 0));
  484. return potentialTrials[0] ?? null;
  485. }
  486. export function trialPromptIsDismissed(prompt: PromptData, subscription: Subscription) {
  487. const {snoozedTime, dismissedTime} = prompt || {};
  488. const time = snoozedTime || dismissedTime;
  489. if (!time) {
  490. return false;
  491. }
  492. const onDemandPeriodStart = new Date(subscription.onDemandPeriodStart);
  493. return time >= onDemandPeriodStart.getTime() / 1000;
  494. }
  495. export function partnerPlanEndingModalIsDismissed(
  496. prompt: PromptData,
  497. subscription: Subscription,
  498. timeframe: string
  499. ) {
  500. const {snoozedTime, dismissedTime} = prompt || {};
  501. const time = snoozedTime || dismissedTime;
  502. if (!time) {
  503. return false;
  504. }
  505. const lastDaysLeft = moment(subscription.contractPeriodEnd).diff(
  506. moment.unix(time),
  507. 'days'
  508. );
  509. switch (timeframe) {
  510. case 'zero':
  511. return lastDaysLeft <= 0;
  512. case 'two':
  513. return lastDaysLeft <= 2 && lastDaysLeft > 0;
  514. case 'week':
  515. return lastDaysLeft <= 7 && lastDaysLeft > 2;
  516. case 'month':
  517. return lastDaysLeft <= 30 && lastDaysLeft > 7;
  518. default:
  519. return true;
  520. }
  521. }