billingDetails.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import {Component, Fragment, useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import type {Location} from 'history';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {Button} from 'sentry/components/button';
  7. import FieldGroup from 'sentry/components/forms/fieldGroup';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelBody from 'sentry/components/panels/panelBody';
  12. import PanelHeader from 'sentry/components/panels/panelHeader';
  13. import {t, tct} from 'sentry/locale';
  14. import type {Organization} from 'sentry/types/organization';
  15. import {decodeScalar} from 'sentry/utils/queryString';
  16. import useApi from 'sentry/utils/useApi';
  17. import withOrganization from 'sentry/utils/withOrganization';
  18. import {openEditCreditCard} from 'getsentry/actionCreators/modal';
  19. import BillingDetailsForm from 'getsentry/components/billingDetailsForm';
  20. import withSubscription from 'getsentry/components/withSubscription';
  21. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  22. import type {BillingDetails as BillingDetailsType, Subscription} from 'getsentry/types';
  23. import {AddressType} from 'getsentry/types';
  24. import formatCurrency from 'getsentry/utils/formatCurrency';
  25. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  26. import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
  27. import RecurringCredits from 'getsentry/views/subscriptionPage/recurringCredits';
  28. import SubscriptionHeader from './subscriptionHeader';
  29. import {trackSubscriptionView} from './utils';
  30. type Props = {
  31. location: Location;
  32. organization: Organization;
  33. subscription: Subscription;
  34. };
  35. type State = {
  36. cardLastFourDigits: string | null;
  37. cardZipCode: string | null;
  38. countryCode?: Subscription['countryCode'];
  39. };
  40. /**
  41. * Update billing details view.
  42. */
  43. class BillingDetails extends Component<Props, State> {
  44. state: State = {
  45. cardLastFourDigits: null,
  46. cardZipCode: null,
  47. };
  48. static getDerivedStateFromProps(props: Readonly<Props>, state: State) {
  49. const {subscription} = props;
  50. const {cardLastFourDigits, cardZipCode} = state;
  51. if (!subscription) {
  52. return {};
  53. }
  54. return {
  55. cardLastFourDigits:
  56. cardLastFourDigits ?? (subscription.paymentSource?.last4 || null),
  57. cardZipCode: cardZipCode ?? (subscription.paymentSource?.zipCode || null),
  58. };
  59. }
  60. componentDidMount() {
  61. const {organization, subscription, location} = this.props;
  62. trackSubscriptionView(organization, subscription, 'details');
  63. // Open update credit card modal and track clicks from payment failure emails and GS Banner
  64. const queryReferrer = decodeScalar(location?.query?.referrer);
  65. // There are multiple billing failure referrals and each should have analytics tracking
  66. if (queryReferrer?.includes('billing-failure')) {
  67. openEditCreditCard({
  68. organization,
  69. onSuccess: this.handleCardUpdated,
  70. location,
  71. });
  72. trackGetsentryAnalytics('billing_failure.button_clicked', {
  73. organization,
  74. referrer: queryReferrer,
  75. });
  76. }
  77. }
  78. handleCardUpdated = (data: Subscription) => {
  79. this.setState({
  80. countryCode: data.countryCode,
  81. cardLastFourDigits: data.paymentSource?.last4 || null,
  82. cardZipCode: data.paymentSource?.zipCode || null,
  83. });
  84. SubscriptionStore.set(data.slug, data);
  85. };
  86. handleSubmitSuccess = (data: Subscription) => {
  87. addSuccessMessage(t('Successfully updated billing details.'));
  88. SubscriptionStore.set(data.slug, data);
  89. };
  90. render() {
  91. const {organization, subscription} = this.props;
  92. const hasBillingPerms = organization.access?.includes('org:billing');
  93. if (!hasBillingPerms) {
  94. return <ContactBillingMembers />;
  95. }
  96. if (!subscription) {
  97. return <LoadingIndicator />;
  98. }
  99. const {cardLastFourDigits, cardZipCode} = this.state;
  100. const balance =
  101. subscription.accountBalance < 0
  102. ? tct('[credits] credit', {
  103. credits: formatCurrency(0 - subscription.accountBalance),
  104. })
  105. : `${formatCurrency(subscription.accountBalance)}`;
  106. return (
  107. <Fragment>
  108. <SubscriptionHeader organization={organization} subscription={subscription} />
  109. <RecurringCredits displayType="discount" planDetails={subscription.planDetails} />
  110. <Panel className="ref-credit-card-details">
  111. <PanelHeader hasButtons>
  112. {t('Credit Card On File')}
  113. <Button
  114. data-test-id="update-card"
  115. priority="primary"
  116. size="sm"
  117. onClick={() =>
  118. openEditCreditCard({
  119. organization,
  120. onSuccess: this.handleCardUpdated,
  121. })
  122. }
  123. >
  124. {t('Update card')}
  125. </Button>
  126. </PanelHeader>
  127. <PanelBody>
  128. <FieldGroup label={t('Credit Card Number')}>
  129. <TextForField>
  130. {cardLastFourDigits ? (
  131. `xxxx xxxx xxxx ${cardLastFourDigits}`
  132. ) : (
  133. <em>{t('No card on file')}</em>
  134. )}
  135. </TextForField>
  136. </FieldGroup>
  137. <FieldGroup
  138. label={t('Postal Code')}
  139. help={t('Postal code associated with the card on file')}
  140. >
  141. <TextForField>{cardZipCode}</TextForField>
  142. </FieldGroup>
  143. </PanelBody>
  144. </Panel>
  145. <Panel className="ref-billing-details">
  146. <PanelHeader>{t('Billing Details')}</PanelHeader>
  147. <PanelBody data-test-id="account-balance">
  148. {subscription.accountBalance ? (
  149. <FieldGroup label="Account Balance">{balance}</FieldGroup>
  150. ) : null}
  151. <BillingDetailsFormContainer organization={organization} />
  152. </PanelBody>
  153. </Panel>
  154. </Fragment>
  155. );
  156. }
  157. }
  158. type FormState = {
  159. isLoading: boolean;
  160. loadError: Error | null;
  161. billingDetails?: BillingDetailsType;
  162. };
  163. function BillingDetailsFormContainer({organization}: {organization: Organization}) {
  164. const [state, setState] = useState<FormState>({
  165. isLoading: false,
  166. loadError: null,
  167. });
  168. const api = useApi();
  169. const fetchBillingDetails = useCallback(async () => {
  170. setState(prevState => ({...prevState, isLoading: true, loadError: null}));
  171. try {
  172. const response: BillingDetailsType = await api.requestPromise(
  173. `/customers/${organization.slug}/billing-details/`
  174. );
  175. setState(prevState => ({
  176. ...prevState,
  177. isLoading: false,
  178. billingDetails: response,
  179. useExisting: response.addressType === AddressType.STRUCTURED,
  180. }));
  181. } catch (error) {
  182. setState(prevState => ({...prevState, loadError: error, isLoading: false}));
  183. if (error.status !== 401 && error.status !== 403) {
  184. Sentry.captureException(error);
  185. }
  186. }
  187. }, [api, organization.slug]);
  188. useEffect(() => {
  189. fetchBillingDetails();
  190. }, [fetchBillingDetails]);
  191. if (state.isLoading) {
  192. return <LoadingIndicator />;
  193. }
  194. if (state.loadError) {
  195. return <LoadingError onRetry={fetchBillingDetails} />;
  196. }
  197. return (
  198. <BillingDetailsForm
  199. requireChanges
  200. initialData={state.billingDetails}
  201. organization={organization}
  202. onSubmitError={() => addErrorMessage(t('Unable to update billing details.'))}
  203. onSubmitSuccess={() =>
  204. addSuccessMessage(t('Successfully updated billing details.'))
  205. }
  206. fieldProps={{
  207. disabled: !organization.access.includes('org:billing'),
  208. disabledReason: t(
  209. "You don't have access to manage these billing and subscription details."
  210. ),
  211. }}
  212. />
  213. );
  214. }
  215. // Sets the min-height so a field displaying text will be the same height as a
  216. // field that has an input
  217. const TextForField = styled('span')`
  218. min-height: 37px;
  219. display: flex;
  220. align-items: center;
  221. `;
  222. export default withSubscription(withOrganization(BillingDetails));
  223. /** @internal exported for tests only */
  224. export {BillingDetails, BillingDetailsFormContainer};