index.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {DateTime} from 'sentry/components/dateTime';
  4. import LoadingError from 'sentry/components/loadingError';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import Panel from 'sentry/components/panels/panel';
  7. import PanelBody from 'sentry/components/panels/panelBody';
  8. import {IconSentry} from 'sentry/icons';
  9. import {t, tct} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  12. import type {Organization} from 'sentry/types/organization';
  13. import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
  14. import withOrganization from 'sentry/utils/withOrganization';
  15. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  16. import type {BillingDetails, Invoice} from 'getsentry/types';
  17. import {InvoiceItemType, InvoiceStatus} from 'getsentry/types';
  18. import {getTaxFieldInfo} from 'getsentry/utils/salesTax';
  19. import {displayPriceWithCents} from '../amCheckout/utils';
  20. import InvoiceDetailsActions from './actions';
  21. type Props = RouteComponentProps<{invoiceGuid: string}, unknown> & {
  22. organization: Organization;
  23. };
  24. function InvoiceDetails({organization, params}: Props) {
  25. const {
  26. data: billingDetails,
  27. isPending: isBillingDetailsLoading,
  28. isError: isBillingDetailsError,
  29. refetch: billingDetailsRefetch,
  30. } = useApiQuery<BillingDetails>([`/customers/${organization.slug}/billing-details/`], {
  31. staleTime: 0,
  32. placeholderData: keepPreviousData,
  33. });
  34. const {
  35. data: invoice,
  36. isPending: isInvoiceLoading,
  37. isError: isInvoiceError,
  38. refetch: invoiceRefetch,
  39. } = useApiQuery<Invoice>(
  40. [`/customers/${organization.slug}/invoices/${params.invoiceGuid}/`],
  41. {staleTime: Infinity}
  42. );
  43. if (isBillingDetailsError || isInvoiceError) {
  44. return (
  45. <LoadingError
  46. onRetry={() => {
  47. billingDetailsRefetch();
  48. invoiceRefetch();
  49. }}
  50. />
  51. );
  52. }
  53. return (
  54. <Fragment>
  55. <SettingsPageHeader title={t('Invoice Details')}>
  56. {t('Invoice Details')}
  57. </SettingsPageHeader>
  58. <Panel>
  59. {isInvoiceLoading || isBillingDetailsLoading ? (
  60. <PanelBody withPadding>
  61. <LoadingIndicator />
  62. </PanelBody>
  63. ) : (
  64. <PanelBody withPadding>
  65. <SenderContainer>
  66. <div>
  67. <SenderName>
  68. <IconSentry size="lg" /> {invoice.sender.name}
  69. </SenderName>
  70. <StyledAddress>
  71. {invoice.sender.address.map((line, idx) => (
  72. <div key={idx}>{line}</div>
  73. ))}
  74. </StyledAddress>
  75. {invoice.sentryTaxIds && (
  76. <div>
  77. {invoice.sentryTaxIds.taxIdName}: {invoice.sentryTaxIds.taxId}
  78. </div>
  79. )}
  80. {invoice.sentryTaxIds?.region && (
  81. <div>
  82. {invoice.sentryTaxIds.region.taxIdName}:{' '}
  83. {invoice.sentryTaxIds.region.taxId}
  84. </div>
  85. )}
  86. </div>
  87. {invoice && (
  88. <InvoiceDetailsActions
  89. organization={organization}
  90. invoice={invoice}
  91. reloadInvoice={invoiceRefetch}
  92. />
  93. )}
  94. </SenderContainer>
  95. <hr />
  96. <InvoiceDetailsContents invoice={invoice} billingDetails={billingDetails} />
  97. </PanelBody>
  98. )}
  99. </Panel>
  100. </Fragment>
  101. );
  102. }
  103. type AttributeProps = {
  104. invoice: Invoice;
  105. billingDetails?: BillingDetails;
  106. };
  107. function InvoiceAttributes({invoice, billingDetails}: AttributeProps) {
  108. let paymentStatus: InvoiceStatus = InvoiceStatus.CLOSED;
  109. if (invoice.isPaid) {
  110. paymentStatus = InvoiceStatus.PAID;
  111. } else if (!invoice.isClosed) {
  112. paymentStatus = InvoiceStatus.AWAITING_PAYMENT;
  113. }
  114. const contactInfo = invoice?.displayAddress || billingDetails?.displayAddress;
  115. const companyName = billingDetails?.companyName;
  116. const billingEmail = billingDetails?.billingEmail;
  117. const taxNumber = invoice?.taxNumber || billingDetails?.taxNumber;
  118. const countryCode = invoice?.countryCode || billingDetails?.countryCode;
  119. const taxNumberName = `${getTaxFieldInfo(countryCode).label}:`;
  120. return (
  121. <AttributeGroup>
  122. <Attributes>
  123. <dt>{t('Account:')}</dt>
  124. <dd>
  125. {invoice.customer?.name && <div>{invoice.customer.name}</div>}
  126. {billingEmail}
  127. </dd>
  128. {companyName || contactInfo ? (
  129. <Fragment>
  130. <dt>{t('Details:')}</dt>
  131. <dd>
  132. {!!companyName && <div>{companyName}</div>}
  133. {!!contactInfo && <div>{contactInfo}</div>}
  134. </dd>
  135. </Fragment>
  136. ) : null}
  137. {!!taxNumber && (
  138. <Fragment>
  139. <dt>{taxNumberName}</dt>
  140. <dd>{taxNumber}</dd>
  141. </Fragment>
  142. )}
  143. </Attributes>
  144. <Attributes>
  145. <dt>{t('Invoice ID:')}</dt>
  146. <dd>{invoice.id}</dd>
  147. <dt>{t('Status:')}</dt>
  148. <dd>{paymentStatus.toUpperCase()}</dd>
  149. <dt>{t('Date:')}</dt>
  150. <dd>
  151. <DateTime date={invoice.dateCreated} dateOnly year />
  152. </dd>
  153. </Attributes>
  154. </AttributeGroup>
  155. );
  156. }
  157. type ContentsProps = {
  158. invoice: Invoice;
  159. billingDetails?: BillingDetails;
  160. };
  161. function InvoiceDetailsContents({billingDetails, invoice}: ContentsProps) {
  162. // If an Invoice has 'isReverseCharge: true', it should be noted in
  163. // the last row of the table with "VAT" in the left column and "Reverse Charge"
  164. // on the right underneath the totals and (if included) refunds
  165. return (
  166. <Fragment>
  167. <InvoiceAttributes invoice={invoice} billingDetails={billingDetails} />
  168. <InvoiceItems data-test-id="invoice-items">
  169. <colgroup>
  170. <col />
  171. <col style={{width: '150px'}} />
  172. </colgroup>
  173. <thead>
  174. <tr>
  175. <th>{t('Item')}</th>
  176. <th>{t('Price')}</th>
  177. </tr>
  178. </thead>
  179. <tfoot>
  180. <tr>
  181. <th>{t('Total')}</th>
  182. <td>{displayPriceWithCents({cents: invoice.amountBilled ?? 0})} USD</td>
  183. </tr>
  184. {invoice.isRefunded && (
  185. <RefundRow>
  186. <th>{t('Refunds')}</th>
  187. <td>{displayPriceWithCents({cents: invoice.amountRefunded})} USD</td>
  188. </RefundRow>
  189. )}
  190. {invoice.isReverseCharge && (
  191. <tr>
  192. <th>{invoice.defaultTaxName}</th>
  193. <td>{t('Reverse Charge')}</td>
  194. </tr>
  195. )}
  196. </tfoot>
  197. <tbody>
  198. {invoice.items.map((item, i) => {
  199. if (item.type === InvoiceItemType.SUBSCRIPTION) {
  200. return (
  201. <tr key={i}>
  202. <td>
  203. {tct('[description] Plan', {description: item.description})}
  204. <small>
  205. {tct('[start] to [end]', {
  206. start: <DateTime date={item.periodStart} dateOnly year />,
  207. end: <DateTime date={item.periodEnd} dateOnly year />,
  208. })}
  209. </small>
  210. </td>
  211. <td>{displayPriceWithCents({cents: item.amount})} USD</td>
  212. </tr>
  213. );
  214. }
  215. return (
  216. <tr key={i}>
  217. <td>{item.description}</td>
  218. <td>{displayPriceWithCents({cents: item.amount})} USD</td>
  219. </tr>
  220. );
  221. })}
  222. </tbody>
  223. </InvoiceItems>
  224. </Fragment>
  225. );
  226. }
  227. export default withOrganization(InvoiceDetails);
  228. const SenderName = styled('h3')`
  229. display: flex;
  230. align-items: center;
  231. gap: ${space(0.5)};
  232. `;
  233. const SenderContainer = styled('div')`
  234. display: grid;
  235. grid-template-columns: auto auto;
  236. gap: ${space(2)};
  237. padding-left: ${space(1)};
  238. /* Use a vertical layout on smaller viewports */
  239. @media (max-width: ${p => p.theme.breakpoints.small}) {
  240. grid-template-columns: auto;
  241. grid-template-rows: auto auto;
  242. }
  243. `;
  244. const AttributeGroup = styled('div')`
  245. display: grid;
  246. grid-template-columns: 1fr 1fr;
  247. gap: ${space(2)};
  248. /* Use a vertical layout on smaller viewports */
  249. @media (max-width: ${p => p.theme.breakpoints.small}) {
  250. grid-template-columns: auto;
  251. grid-template-rows: auto auto;
  252. }
  253. `;
  254. const Attributes = styled('dl')`
  255. overflow: hidden;
  256. dt {
  257. font-weight: bold;
  258. margin: 0 0 ${space(0.25)} ${space(1)};
  259. }
  260. dd {
  261. background: ${p => p.theme.backgroundSecondary};
  262. padding: ${space(1)};
  263. margin-bottom: ${space(2)};
  264. }
  265. `;
  266. const StyledAddress = styled('address')`
  267. margin-bottom: 0px;
  268. line-height: 1.5;
  269. font-style: normal;
  270. `;
  271. const InvoiceItems = styled('table')`
  272. width: 100%;
  273. tr th,
  274. tr td {
  275. border-top: 1px solid ${p => p.theme.innerBorder};
  276. padding: ${space(2)} ${space(1)};
  277. }
  278. thead tr:first-child th,
  279. thead tr:first-child td {
  280. border-top: none;
  281. }
  282. th:last-child,
  283. td:last-child {
  284. font-variant-numeric: tabular-nums;
  285. text-align: right;
  286. }
  287. td small {
  288. display: block;
  289. margin-top: ${space(0.5)};
  290. }
  291. `;
  292. const RefundRow = styled('tr')`
  293. td,
  294. th {
  295. background: ${p => p.theme.alert.warning.backgroundLight};
  296. }
  297. `;