123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- import * as Sentry from '@sentry/react';
- import moment from 'moment-timezone';
- import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
- } from 'sentry/actionCreators/indicator';
- import {fetchOrganizationDetails} from 'sentry/actionCreators/organization';
- import {Client} from 'sentry/api';
- import {t} from 'sentry/locale';
- import {DataCategory} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import {browserHistory} from 'sentry/utils/browserHistory';
- import normalizeUrl from 'sentry/utils/url/normalizeUrl';
- import {DEFAULT_TIER, MONTHLY, SUPPORTED_TIERS} from 'getsentry/constants';
- import SubscriptionStore from 'getsentry/stores/subscriptionStore';
- import type {
- DataCategories,
- EventBucket,
- OnDemandBudgets,
- Plan,
- PlanTier,
- PreviewData,
- Subscription,
- } from 'getsentry/types';
- import {getSlot} from 'getsentry/utils/billing';
- import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
- import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent';
- import type {CheckoutAPIData, CheckoutFormData} from 'getsentry/views/amCheckout/types';
- import {
- normalizeOnDemandBudget,
- parseOnDemandBudgetsFromSubscription,
- trackOnDemandBudgetAnalytics,
- } from 'getsentry/views/onDemandBudgets/utils';
- import {bigNumFormatter} from 'getsentry/views/spendAllocations/utils';
- const CURRENCY_LOCALE = 'en-US';
- /**
- * Includes $, and cents in the price when needed.
- *
- * 100.01 => $100.01
- * 100.00 => $100
- * 100.30 => $100.30
- * -100 => -$100
- */
- type DisplayPriceTypes = {
- cents: number;
- formatBigNum?: boolean;
- };
- // Intent details returned by CustomerSubscriptionEndpoint
- // when there is an error and customer card actions are
- // required.
- export type IntentDetails = {
- paymentIntent: string;
- paymentSecret: string;
- };
- type APIDataProps = {
- formData: CheckoutFormData;
- isPreview?: boolean;
- onDemandBudget?: OnDemandBudgets;
- paymentIntent?: string;
- previewToken?: PreviewData['previewToken'];
- referrer?: string;
- shouldUpdateOnDemand?: boolean;
- };
- export function displayPrice({cents, formatBigNum = false}: DisplayPriceTypes): string {
- const dollars = cents / 100;
- const prefix = dollars >= 0 ? '$' : '-$';
- if (formatBigNum) {
- return prefix + bigNumFormatter(Math.abs(dollars));
- }
- const hasCents = dollars % 1 !== 0;
- if (hasCents) {
- return displayPriceWithCents({cents});
- }
- return prefix + Math.abs(dollars).toLocaleString(CURRENCY_LOCALE);
- }
- /**
- * Always include $ and cents in the price.
- *
- * $100.01 => $100.01
- * $100.00 => $100.00
- * $100.30 => $100.30
- * -100 => -$100.00
- */
- export function displayPriceWithCents({
- cents,
- minimumFractionDigits,
- maximumFractionDigits,
- }: {
- cents: number;
- maximumFractionDigits?: number;
- minimumFractionDigits?: number;
- }): string {
- const dollars = cents / 100;
- const prefix = dollars >= 0 ? '$' : '-$';
- return (
- prefix +
- Math.abs(dollars).toLocaleString(CURRENCY_LOCALE, {
- minimumFractionDigits: minimumFractionDigits ?? 2,
- maximumFractionDigits: maximumFractionDigits ?? 2,
- })
- );
- }
- type UnitPriceProps = {
- cents: number;
- maxDigits?: number;
- minDigits?: number;
- };
- /**
- * Includes cents in the price when needed and excludes $ for separate formatting.
- * Note: Use `displayPrice` if prices can be negative (ex: credits)
- *
- * 100.01 => 100.01
- * 100.00 => 100
- * 100.30 => 100.30
- * -100 => -100
- */
- export function formatPrice({cents}: {cents: number}): string {
- return displayPrice({cents}).replace('$', '');
- }
- /**
- * Format per unit price for events,
- * where errors and transactions default to 5 digits
- * and attachments should use 2 digits.
- */
- export function displayUnitPrice({
- cents,
- minDigits = 5,
- maxDigits = 7,
- }: UnitPriceProps): string {
- const dollars = cents / 100;
- return (
- '$' +
- dollars.toLocaleString(CURRENCY_LOCALE, {
- minimumFractionDigits: minDigits,
- maximumFractionDigits: maxDigits,
- })
- );
- }
- export function getBucket({
- buckets,
- events,
- price,
- shouldMinimize = false,
- }: {
- buckets?: EventBucket[];
- events?: number;
- price?: number;
- shouldMinimize?: boolean; // the slot strategy when `events` does not exist in `buckets`
- }): EventBucket {
- if (buckets) {
- const slot = getSlot(events, price, buckets, shouldMinimize);
- if (slot in buckets) {
- return buckets[slot]!;
- }
- }
- throw new Error('Invalid data category for plan');
- }
- type ReservedTotalProps = {
- plan: Plan;
- reserved: {
- [categoryKey in DataCategory]?: number;
- };
- amount?: number;
- creditCategory?: string;
- discountType?: string;
- maxDiscount?: number;
- };
- /**
- * Returns the total plan price (including prices for reserved categories) in cents.
- */
- export function getReservedPriceCents({
- plan,
- reserved,
- amount,
- discountType,
- maxDiscount,
- creditCategory,
- }: ReservedTotalProps): number {
- let reservedCents = plan.basePrice;
- if (amount && discountType && creditCategory) {
- reservedCents = getDiscountedPrice({
- basePrice: reservedCents,
- amount,
- discountType,
- creditCategory,
- });
- }
- Object.entries(reserved).forEach(
- ([category, quantity]) =>
- (reservedCents += getBucket({
- events: quantity,
- buckets: plan.planCategories[category as DataCategories],
- }).price)
- );
- if (amount && maxDiscount) {
- const discount = Math.min(maxDiscount, (reservedCents * amount) / 10000);
- reservedCents -= discount;
- }
- return reservedCents;
- }
- /**
- * Gets the price in cents per reserved category, and returns the
- * reserved total in dollars.
- */
- export function getReservedTotal({
- plan,
- reserved,
- amount,
- discountType,
- maxDiscount,
- creditCategory,
- }: ReservedTotalProps): string {
- return formatPrice({
- cents: getReservedPriceCents({
- plan,
- reserved,
- amount,
- discountType,
- maxDiscount,
- creditCategory,
- }),
- });
- }
- type DiscountedPriceProps = {
- amount: number;
- basePrice: number;
- creditCategory: string;
- discountType: string;
- };
- /**
- * Gets the price in cents after the discount is applied.
- */
- export function getDiscountedPrice({
- basePrice,
- discountType,
- amount,
- creditCategory,
- }: DiscountedPriceProps): number {
- let price = basePrice;
- if (discountType === 'percentPoints' && creditCategory === 'subscription') {
- const discount = (basePrice * amount) / 10000;
- price = basePrice - discount;
- } else if (discountType === 'amountCents') {
- price = basePrice - amount;
- }
- return price;
- }
- /**
- * Returns the short billing interval name.
- */
- export function getShortInterval(billingInterval: string): string {
- return billingInterval === MONTHLY ? 'mo' : 'yr';
- }
- function getAttachmentsWithUnit(gigabytes: number): string {
- return `${gigabytes.toLocaleString()} GB`;
- }
- /**
- * Used by RangeSlider. As such, a value of zero is not equivalent to unlimited.
- */
- export function getEventsWithUnit(
- events: number,
- dataType: string
- ): string | number | null {
- if (!events) {
- return null;
- }
- if (dataType === DataCategory.ATTACHMENTS) {
- return getAttachmentsWithUnit(events).replace(' ', '');
- }
- if (events >= 1_000_000_000) {
- return `${events / 1_000_000_000}B`;
- }
- if (events >= 1_000_000) {
- return `${events / 1_000_000}M`;
- }
- if (events >= 1_000) {
- return `${events / 1_000}K`;
- }
- return events;
- }
- function recordAnalytics(
- organization: Organization,
- subscription: Subscription,
- data: CheckoutAPIData,
- isMigratingPartnerAccount: boolean
- ) {
- trackMarketingEvent('Upgrade', {plan: data.plan});
- const currentData = {
- plan: data.plan,
- errors: data.reservedErrors,
- transactions: data.reservedTransactions,
- attachments: data.reservedAttachments,
- replays: data.reservedReplays,
- monitorSeats: data.reservedMonitorSeats,
- spans: data.reservedSpans,
- profileDuration: data.reservedProfileDuration,
- uptime: data.reservedUptime,
- };
- const previousData = {
- plan: subscription.plan,
- errors: subscription.categories.errors?.reserved || undefined,
- transactions: subscription.categories.transactions?.reserved || undefined,
- attachments: subscription.categories.attachments?.reserved || undefined,
- replays: subscription.categories.replays?.reserved || undefined,
- monitorSeats: subscription.categories.monitorSeats?.reserved || undefined,
- profileDuration: subscription.categories.profileDuration?.reserved || undefined,
- spans: subscription.categories.spans?.reserved || undefined,
- uptime: subscription.categories.uptime?.reserved || undefined,
- };
- trackGetsentryAnalytics('checkout.upgrade', {
- organization,
- subscription,
- previous_plan: previousData.plan,
- previous_errors: previousData.errors,
- previous_transactions: previousData.transactions,
- previous_attachments: previousData.attachments,
- previous_replays: previousData.replays,
- previous_monitorSeats: previousData.monitorSeats,
- previous_profileDuration: previousData.profileDuration,
- previous_spans: previousData.spans,
- previous_uptime: previousData.uptime,
- ...currentData,
- });
- let {onDemandBudget} = data;
- if (onDemandBudget) {
- onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
- const previousOnDemandBudget = parseOnDemandBudgetsFromSubscription(subscription);
- trackOnDemandBudgetAnalytics(
- organization,
- previousOnDemandBudget,
- onDemandBudget,
- 'checkout'
- );
- }
- if (
- currentData.transactions &&
- previousData.transactions &&
- currentData.transactions > previousData.transactions
- ) {
- trackGetsentryAnalytics('checkout.transactions_upgrade', {
- organization,
- subscription,
- plan: data.plan,
- previous_transactions: previousData.transactions,
- transactions: currentData.transactions,
- });
- }
- if (isMigratingPartnerAccount) {
- trackGetsentryAnalytics('partner_billing_migration.checkout.completed', {
- subscription,
- organization,
- applyNow: data.applyNow ?? false,
- daysLeft: moment(subscription.contractPeriodEnd).diff(moment(), 'days'),
- partner: subscription.partner?.partnership.id,
- });
- }
- }
- export function stripeHandleCardAction(
- intentDetails: IntentDetails,
- stripeInstance?: stripe.Stripe,
- onSuccess?: () => void,
- onError?: (errorMessage?: string) => void
- ) {
- if (!stripeInstance) {
- return;
- }
- // Use stripe client library to handle additional actions.
- // This allows us to complete 3DS and MFA during checkout.
- stripeInstance
- .handleCardAction(intentDetails.paymentSecret)
- .then((result: stripe.PaymentIntentResponse) => {
- if (result.error) {
- let message =
- 'Your payment could not be authorized. Please try a different card, or try again later.';
- if (
- ['card_error', 'validation_error'].includes(result.error.type) &&
- result.error.message
- ) {
- message = result.error.message;
- }
- onError?.(message);
- return;
- }
- // With our intent confirmed we can complete checkout.
- onSuccess?.();
- });
- }
- /** @internal exported for tests only */
- export function getCheckoutAPIData({
- formData,
- onDemandBudget,
- previewToken,
- paymentIntent,
- referrer,
- shouldUpdateOnDemand = true,
- }: APIDataProps) {
- const formatReservedData = (value: number | null | undefined) => value ?? undefined;
- const reservedData = {
- reservedErrors: formatReservedData(formData.reserved.errors),
- reservedTransactions: formatReservedData(formData.reserved.transactions),
- reservedAttachments: formatReservedData(formData.reserved.attachments),
- reservedReplays: formatReservedData(formData.reserved.replays),
- reservedMonitorSeats: formatReservedData(formData.reserved.monitorSeats),
- reservedProfileDuration: formatReservedData(formData.reserved.profileDuration),
- reservedSpans: formatReservedData(formData.reserved.spans),
- reservedUptime: formatReservedData(formData.reserved.uptime),
- } satisfies Partial<
- // Enforce plural spelling against the enums in DataCategory
- Record<`reserved${Capitalize<DataCategory>}`, number | undefined>
- >;
- const onDemandMaxSpend = shouldUpdateOnDemand
- ? (formData.onDemandMaxSpend ?? 0)
- : undefined;
- let data: CheckoutAPIData = {
- ...reservedData,
- onDemandBudget,
- plan: formData.plan,
- onDemandMaxSpend,
- referrer: referrer || 'billing',
- ...(previewToken && {previewToken}),
- ...(paymentIntent && {paymentIntent}),
- };
- if (formData.applyNow) {
- data = {
- ...data,
- applyNow: true,
- };
- }
- return data;
- }
- export async function fetchPreviewData(
- organization: Organization,
- api: Client,
- formData: CheckoutFormData,
- onLoading: () => void,
- onSuccess: (previewData: PreviewData) => void,
- onError: (error: any) => void
- ) {
- onLoading?.();
- const data = getCheckoutAPIData({formData, isPreview: true});
- try {
- const previewData: PreviewData = await api.requestPromise(
- `/customers/${organization.slug}/subscription/preview/`,
- {
- method: 'GET',
- data,
- }
- );
- onSuccess?.(previewData);
- } catch (error) {
- onError?.(error);
- Sentry.withScope(scope => {
- scope.setExtras({data});
- Sentry.captureException(error);
- });
- }
- }
- export async function submitCheckout(
- organization: Organization,
- subscription: Subscription,
- previewData: PreviewData,
- formData: CheckoutFormData,
- api: Client,
- onFetchPreviewData: () => void,
- onHandleCardAction: (intentDetails: IntentDetails) => void,
- onSubmitting?: (b: boolean) => void,
- intentId?: string,
- referrer = 'billing',
- codecovReferrer = 'checkout',
- shouldUpdateOnDemand = true
- ) {
- const endpoint = `/customers/${organization.slug}/subscription/`;
- let {onDemandBudget} = formData;
- if (onDemandBudget) {
- onDemandBudget = normalizeOnDemandBudget(onDemandBudget);
- }
- // this is necessary for recording partner billing migration-specific analytics after
- // the migration is successful (during which the flag is flipped off)
- const isMigratingPartnerAccount = organization.features.includes(
- 'partner-billing-migration'
- );
- const data = getCheckoutAPIData({
- formData,
- onDemandBudget,
- previewToken: previewData?.previewToken,
- paymentIntent: intentId,
- referrer,
- shouldUpdateOnDemand,
- });
- addLoadingMessage(t('Saving changes\u{2026}'));
- try {
- onSubmitting?.(true);
- await api.requestPromise(endpoint, {
- method: 'PUT',
- data,
- });
- addSuccessMessage(t('Success'));
- recordAnalytics(organization, subscription, data, isMigratingPartnerAccount);
- // refresh org and subscription state
- // useApi cancels open requests on unmount by default, so we create a new Client to ensure this
- // request doesn't get cancelled
- fetchOrganizationDetails(new Client(), organization.slug);
- SubscriptionStore.loadData(organization.slug);
- browserHistory.push(
- normalizeUrl(
- `/settings/${organization.slug}/billing/overview/?open_codecov_modal=1&referrer=${codecovReferrer}`
- )
- );
- } catch (error) {
- const body = error.responseJSON;
- if (body?.previewToken) {
- onSubmitting?.(false);
- addErrorMessage(t('Your preview expired, please review changes and submit again'));
- onFetchPreviewData?.();
- } else if (body?.paymentIntent && body?.paymentSecret && body?.detail) {
- // When an error response contains payment intent information
- // we can retry the payment using the client-side confirmation flow
- // in stripe.
- // We don't re-enable the button here as we don't want users clicking it
- // while there are UI transitions happening.
- addErrorMessage(body.detail);
- const intent: IntentDetails = {
- paymentIntent: body.paymentIntent,
- paymentSecret: body.paymentSecret,
- };
- onHandleCardAction?.(intent);
- } else {
- const msg =
- body?.detail || t('An unknown error occurred while saving your subscription');
- addErrorMessage(msg);
- onSubmitting?.(false);
- // Don't capture 402 errors as that status code is used for
- // customer credit card failures.
- if (error.status !== 402) {
- Sentry.withScope(scope => {
- scope.setExtras({data});
- Sentry.captureException(error);
- });
- }
- }
- }
- }
- export function getToggleTier(checkoutTier: PlanTier | undefined) {
- // cannot toggle from or to AM3
- if (checkoutTier === DEFAULT_TIER || !checkoutTier || SUPPORTED_TIERS.length === 0) {
- return null;
- }
- if (SUPPORTED_TIERS.length === 1) {
- return SUPPORTED_TIERS[0];
- }
- const tierIndex = SUPPORTED_TIERS.indexOf(checkoutTier);
- // can toggle between AM1 and AM2 for AM1 customers
- if (tierIndex === SUPPORTED_TIERS.length - 1) {
- return SUPPORTED_TIERS[tierIndex - 1];
- }
- return SUPPORTED_TIERS[tierIndex + 1];
- }
|