UpcomingInvoice.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import React from 'react'
  2. import { Table, TableBody, TableCell, TableFooter, TableRow } from 'ui'
  3. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  4. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  5. import { billingMetricUnit, formatUsage } from '../helpers'
  6. import AlertError from '@/components/ui/AlertError'
  7. import { InlineLink } from '@/components/ui/InlineLink'
  8. import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
  9. import {
  10. UpcomingInvoiceResponse,
  11. useOrgUpcomingInvoiceQuery,
  12. } from '@/data/invoices/org-invoice-upcoming-query'
  13. import { useOrganizationQuery } from '@/data/organizations/organization-query'
  14. import { DOCS_URL } from '@/lib/constants'
  15. import { formatCurrency } from '@/lib/helpers'
  16. export interface UpcomingInvoiceProps {
  17. slug?: string
  18. }
  19. const usageBillingDocsLink: { [K in PricingMetric]?: string } = {
  20. [PricingMetric.MONTHLY_ACTIVE_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users`,
  21. [PricingMetric.MONTHLY_ACTIVE_SSO_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users-sso`,
  22. [PricingMetric.MONTHLY_ACTIVE_THIRD_PARTY_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users-third-party`,
  23. [PricingMetric.AUTH_MFA_PHONE]: `${DOCS_URL}/guides/platform/manage-your-usage/advanced-mfa-phone`,
  24. [PricingMetric.EGRESS]: `${DOCS_URL}/guides/platform/manage-your-usage/egress`,
  25. [PricingMetric.CACHED_EGRESS]: `${DOCS_URL}/guides/platform/manage-your-usage/egress`,
  26. [PricingMetric.FUNCTION_INVOCATIONS]: `${DOCS_URL}/guides/platform/manage-your-usage/edge-function-invocations`,
  27. [PricingMetric.STORAGE_SIZE]: `${DOCS_URL}/guides/platform/manage-your-usage/storage-size`,
  28. [PricingMetric.STORAGE_IMAGES_TRANSFORMED]: `${DOCS_URL}/guides/platform/manage-your-usage/storage-image-transformations`,
  29. [PricingMetric.REALTIME_MESSAGE_COUNT]: `${DOCS_URL}/guides/platform/manage-your-usage/realtime-messages`,
  30. [PricingMetric.REALTIME_PEAK_CONNECTIONS]: `${DOCS_URL}/guides/platform/manage-your-usage/realtime-peak-connections`,
  31. [PricingMetric.CUSTOM_DOMAIN]: `${DOCS_URL}/guides/platform/manage-your-usage/custom-domains`,
  32. [PricingMetric.IPV4]: `${DOCS_URL}/guides/platform/manage-your-usage/ipv4`,
  33. [PricingMetric.PITR_7]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
  34. [PricingMetric.PITR_14]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
  35. [PricingMetric.PITR_28]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
  36. [PricingMetric.DISK_SIZE_GB_HOURS_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-size`,
  37. [PricingMetric.DISK_SIZE_GB_HOURS_IO2]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-size`,
  38. [PricingMetric.DISK_IOPS_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-iops`,
  39. [PricingMetric.DISK_IOPS_IO2]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-iops`,
  40. [PricingMetric.DISK_THROUGHPUT_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-throughput`,
  41. [PricingMetric.LOG_DRAIN]: `${DOCS_URL}/guides/platform/manage-your-usage/log-drains`,
  42. }
  43. export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
  44. const {
  45. data: upcomingInvoice,
  46. error: error,
  47. isPending: isLoading,
  48. isError,
  49. isSuccess,
  50. } = useOrgUpcomingInvoiceQuery({ orgSlug: slug })
  51. const { data: organization } = useOrganizationQuery({ slug })
  52. // For non-platform customers, compute is broken down per project and contains a breakdown array
  53. const computeItems =
  54. upcomingInvoice?.lines?.filter(
  55. (item) =>
  56. item.description?.toLowerCase().includes('compute') &&
  57. !item.description.startsWith('Compute Credits')
  58. ) || []
  59. const computeCreditsItem =
  60. upcomingInvoice?.lines?.find((item) => item.description.startsWith('Compute Credits')) ?? null
  61. const planItem = upcomingInvoice?.lines?.find((item) =>
  62. item.description?.toLowerCase().includes('plan')
  63. )
  64. const regularComputeItems = computeItems.filter(
  65. (it) => !it.metadata?.is_branch && !it.metadata?.is_read_replica
  66. )
  67. const branchingComputeItems = computeItems.filter((it) => it.metadata?.is_branch)
  68. const replicaComputeItems = computeItems.filter((it) => it.metadata?.is_read_replica)
  69. const branchingComputeItemsDisplay = branchingComputeItems
  70. .flatMap((it) => it.breakdown)
  71. .sort((a, b) => (a?.project_name ?? '').localeCompare(b?.project_name ?? ''))
  72. const otherItems =
  73. upcomingInvoice?.lines
  74. ?.filter(
  75. (item) =>
  76. !item.description?.toLowerCase().includes('compute') &&
  77. !item.description?.toLowerCase().includes('plan') &&
  78. item.amount_before_discount > 0
  79. )
  80. .sort((a, b) => b.amount_before_discount - a.amount_before_discount) || []
  81. const prepaidCreditsItem =
  82. upcomingInvoice?.lines?.find((item) => item.item_name === 'Prepaid Credits') ?? null
  83. const hasTax =
  84. upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0
  85. const taxFailed = upcomingInvoice?.tax_status === 'failed'
  86. const planFeePaidInAdvance =
  87. !planItem && upcomingInvoice?.fixed_fees_billing_mode === 'in_arrears'
  88. return (
  89. <>
  90. {isLoading && (
  91. <div className="space-y-2">
  92. <ShimmeringLoader />
  93. <ShimmeringLoader className="w-3/4" />
  94. <ShimmeringLoader className="w-1/2" />
  95. </div>
  96. )}
  97. {isError && <AlertError subject="Failed to retrieve upcoming invoice" error={error} />}
  98. {isSuccess && (
  99. <div>
  100. <div>
  101. <Table className="w-full text-sm">
  102. <TableBody>
  103. {planItem && !planFeePaidInAdvance && (
  104. <TableRow>
  105. <TableCell className="py-2! px-0">{planItem?.description}</TableCell>
  106. <TableCell className="text-right py-2 px-0">
  107. {!planItem ? (
  108. '-'
  109. ) : (
  110. <InvoiceLineItemAmount
  111. amount={planItem.amount}
  112. amountBeforeDiscount={planItem.amount_before_discount}
  113. />
  114. )}
  115. </TableCell>
  116. </TableRow>
  117. )}
  118. {/* Compute section */}
  119. <ComputeLineItem
  120. computeItems={regularComputeItems}
  121. title="Compute"
  122. computeCredits={computeCreditsItem}
  123. tooltip={
  124. <p className="prose text-xs">
  125. The first project is covered by Compute Credits. Additional projects incur
  126. compute costs starting at <span translate="no">$10</span>/month, independent
  127. of activity. See{' '}
  128. <InlineLink href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}>
  129. docs
  130. </InlineLink>
  131. .
  132. </p>
  133. }
  134. />
  135. {/* Read Replica compute */}
  136. <ComputeLineItem
  137. title="Replica Compute"
  138. computeItems={replicaComputeItems}
  139. tooltip={
  140. <p className="prose text-xs">
  141. Each Read Replica is a dedicated database. You are charged for its resources:
  142. Compute, Disk Size, provisioned Disk IOPS, provisioned Disk Throughput, and
  143. IPv4. See{' '}
  144. <InlineLink
  145. href={`${DOCS_URL}/guides/platform/manage-your-usage/read-replicas`}
  146. >
  147. docs
  148. </InlineLink>
  149. .
  150. </p>
  151. }
  152. />
  153. {/* Branching compute */}
  154. {branchingComputeItems.length > 0 && (
  155. <TableRow>
  156. <TableCell className="py-2 px-0">
  157. <div className="flex items-center gap-1">
  158. <span>Branching</span>
  159. <InfoTooltip className="max-w-sm">
  160. <ul className="ml-6 list-disc">
  161. {branchingComputeItemsDisplay.map((breakdown) => (
  162. <li key={`branching-breakdown-${breakdown!.project_ref}`}>
  163. {breakdown!.project_name} ({breakdown!.usage} Hours)
  164. </li>
  165. ))}
  166. </ul>
  167. <p className="mt-2">
  168. See{' '}
  169. <InlineLink
  170. href={`${DOCS_URL}/guides/platform/manage-your-usage/branching`}
  171. >
  172. docs
  173. </InlineLink>{' '}
  174. on how billing for Branching works.
  175. </p>
  176. </InfoTooltip>
  177. </div>
  178. </TableCell>
  179. <TableCell className="text-right py-2 px-0">
  180. <InvoiceLineItemAmount
  181. amount={branchingComputeItems.reduce((prev, cur) => prev + cur.amount, 0)}
  182. amountBeforeDiscount={branchingComputeItems.reduce(
  183. (prev, cur) => prev + (cur.amount_before_discount ?? 0),
  184. 0
  185. )}
  186. />
  187. </TableCell>
  188. </TableRow>
  189. )}
  190. {/* Non-compute items */}
  191. {otherItems.map((item) => {
  192. const usageMetric = item.usage_metric as PricingMetric
  193. const sortedBreakdown = (item.breakdown ?? []).sort((a, b) =>
  194. a.project_name.localeCompare(b.project_name)
  195. )
  196. return (
  197. <TableRow key={item.description}>
  198. <TableCell className="py-2 px-0">
  199. <div className="gap-1 flex items-center">
  200. <span>{item.description ?? 'Unknown'}</span>
  201. {(sortedBreakdown.length > 0 || item.usage_metric !== null) && (
  202. <InfoTooltip className="max-w-sm">
  203. {item.item_name === 'minimum_amount' && (
  204. <p className="mb-2" translate="no">
  205. Minimum Fee - If your cost is below the minimum fee, you will be
  206. charged the difference as a floor fee
  207. </p>
  208. )}
  209. {item.unit_price_desc && (
  210. <p className="mb-2" translate="no">
  211. Pricing: {item.unit_price_desc}
  212. </p>
  213. )}
  214. {sortedBreakdown.length > 0 && (
  215. <>
  216. <p>Projects using {item.description}:</p>
  217. <ul className="ml-6 list-disc">
  218. {sortedBreakdown.map((breakdown) => {
  219. const unit = billingMetricUnit(usageMetric)
  220. return (
  221. <li
  222. key={`${item.description}-breakdown-${breakdown.project_ref}`}
  223. >
  224. <InlineLink
  225. target="_blank"
  226. href={`/project/${breakdown.project_ref}`}
  227. >
  228. {breakdown.project_name}
  229. </InlineLink>{' '}
  230. {usageMetric && (
  231. <span>
  232. ({formatUsage(usageMetric, breakdown)}
  233. {!!unit ? ` ${unit}` : ''})
  234. </span>
  235. )}
  236. </li>
  237. )
  238. })}
  239. </ul>
  240. </>
  241. )}
  242. {usageMetric && usageBillingDocsLink[usageMetric] != null && (
  243. <p className="mt-2">
  244. See{' '}
  245. <InlineLink href={usageBillingDocsLink[usageMetric]!}>
  246. docs
  247. </InlineLink>{' '}
  248. on how billing for {item.description} works and{' '}
  249. <InlineLink href={`/organization/${slug}/usage`}>
  250. usage page
  251. </InlineLink>{' '}
  252. for a detailed breakdown.
  253. </p>
  254. )}
  255. </InfoTooltip>
  256. )}
  257. </div>
  258. </TableCell>
  259. <TableCell className="text-right py-2 px-0">
  260. <InvoiceLineItemAmount
  261. amount={item.amount}
  262. amountBeforeDiscount={item.amount_before_discount}
  263. />
  264. </TableCell>
  265. </TableRow>
  266. )
  267. })}
  268. </TableBody>
  269. <TableFooter>
  270. {prepaidCreditsItem && (
  271. <TableRow>
  272. <TableCell className="py-2 px-0 flex items-center">
  273. <span className="mr-2">{prepaidCreditsItem.item_name}</span>
  274. <InfoTooltip className="max-w-xs">
  275. Prepaid credits purchased upfront, applied automatically against your
  276. invoice. Any remaining balance rolls over to the next billing cycle.
  277. </InfoTooltip>
  278. </TableCell>
  279. <TableCell className="text-right py-2 px-0" translate="no">
  280. {formatCurrency(prepaidCreditsItem.amount) ?? '-'}
  281. </TableCell>
  282. </TableRow>
  283. )}
  284. <TableRow>
  285. <TableCell className="font-medium py-2 px-0 flex items-center">
  286. <span className="mr-2">Current Costs</span>
  287. <InfoTooltip>
  288. Costs accumulated from the beginning of the billing cycle up to now.
  289. </InfoTooltip>
  290. </TableCell>
  291. <TableCell className="text-right font-medium py-2 px-0" translate="no">
  292. {formatCurrency(upcomingInvoice?.amount_total) ?? '-'}
  293. </TableCell>
  294. </TableRow>
  295. {(!!upcomingInvoice.amount_projected || hasTax || taxFailed) && (
  296. <TableRow>
  297. <TableCell className="font-medium py-2 px-0 flex items-center">
  298. <span className="mr-2">Projected Costs</span>
  299. <InfoTooltip className="max-w-sm">
  300. <p className="mb-2">
  301. Projected costs at the end of the billing cycle. Includes predictable
  302. costs for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery,
  303. but no costs for metrics like MAU, storage or function invocations. Final
  304. amounts may vary depending on your usage.
  305. </p>
  306. {hasTax && (
  307. <div className="mt-3 border-t border-muted pt-2 space-y-1">
  308. <div className="flex items-center justify-between gap-4 text-xs">
  309. <span>Subtotal</span>
  310. <span translate="no">
  311. {formatCurrency(upcomingInvoice.tax!.total_amount_excluding_tax)}
  312. </span>
  313. </div>
  314. <div className="flex items-center justify-between gap-4 text-xs">
  315. <span>
  316. Tax
  317. {upcomingInvoice.tax?.tax_rate_percentage != null &&
  318. ` (${upcomingInvoice.tax.tax_rate_percentage}%)`}
  319. </span>
  320. <span translate="no">
  321. {formatCurrency(upcomingInvoice.tax!.tax_amount)}
  322. </span>
  323. </div>
  324. <p className="text-foreground-lighter pt-1 text-xs">
  325. Estimated based on your organization's billing address. The final
  326. amount may be adjusted at the end of the billing cycle.
  327. </p>
  328. </div>
  329. )}
  330. {taxFailed && (
  331. <p className="mt-3 border-t border-muted pt-2 text-warning">
  332. We were unable to estimate tax for your organization. Please verify your
  333. billing address in your organization settings.
  334. </p>
  335. )}
  336. </InfoTooltip>
  337. </TableCell>
  338. <TableCell className="text-right font-medium py-2 px-0" translate="no">
  339. {formatCurrency(
  340. hasTax
  341. ? upcomingInvoice.tax!.total_amount_including_tax
  342. : upcomingInvoice.amount_projected
  343. ) ?? '-'}
  344. </TableCell>
  345. </TableRow>
  346. )}
  347. {planFeePaidInAdvance && (
  348. <TableRow className="border-0 hover:bg-transparent">
  349. <TableCell
  350. className="pt-2! pb-0! px-0 text-foreground-light text-xs text-right"
  351. colSpan={2}
  352. >
  353. Your {organization?.plan?.name && `${organization.plan.name} `}Plan fee for
  354. this period has already been paid. This invoice will only reflect usage
  355. charges.
  356. </TableCell>
  357. </TableRow>
  358. )}
  359. </TableFooter>
  360. </Table>
  361. </div>
  362. </div>
  363. )}
  364. </>
  365. )
  366. }
  367. function InvoiceLineItemAmount({
  368. amountBeforeDiscount,
  369. amount,
  370. }: {
  371. amountBeforeDiscount?: number
  372. amount: number
  373. }) {
  374. if (amountBeforeDiscount && amount < amountBeforeDiscount) {
  375. return (
  376. <div>
  377. <span className="text-foreground-light line-through mr-2" translate="no">
  378. {formatCurrency(amountBeforeDiscount)}
  379. </span>
  380. <span translate="no">{formatCurrency(amount)}</span>
  381. </div>
  382. )
  383. } else {
  384. return <span translate="no">{formatCurrency(amount)}</span>
  385. }
  386. }
  387. function ComputeLineItem({
  388. computeItems,
  389. tooltip,
  390. title,
  391. computeCredits,
  392. }: {
  393. title: string
  394. tooltip: React.ReactElement
  395. computeItems: UpcomingInvoiceResponse['lines']
  396. computeCredits?: UpcomingInvoiceResponse['lines'][number] | null
  397. }) {
  398. const computeProjects = computeItems
  399. .flatMap((item) =>
  400. item.breakdown!.map((project) => ({
  401. ...project,
  402. computeType: item.description,
  403. computeCosts: project.amount!,
  404. }))
  405. )
  406. // descending by cost
  407. .sort((a, b) => b.computeCosts - a.computeCosts)
  408. const computeItemsSortedByCost = computeItems
  409. // descending by cost
  410. .sort((a, b) => b.amount - a.amount)
  411. const computeCosts = Math.max(
  412. 0,
  413. computeItems.reduce((prev, cur) => prev + cur.amount_before_discount, 0)
  414. )
  415. const discountedComputeCosts = Math.max(
  416. 0,
  417. computeItems.reduce((prev, cur) => prev + (cur.amount ?? 0), 0)
  418. )
  419. if (!computeItems.length) return null
  420. return (
  421. <>
  422. <TableRow>
  423. <TableCell className="py-2! px-0 flex items-center gap-1">
  424. <span>{title}</span>
  425. <InfoTooltip className="max-w-sm">{tooltip}</InfoTooltip>
  426. </TableCell>
  427. <TableCell className="text-right py-2 px-0">
  428. <InvoiceLineItemAmount
  429. amount={discountedComputeCosts}
  430. amountBeforeDiscount={computeCosts}
  431. />
  432. </TableCell>
  433. </TableRow>
  434. {computeProjects.map((project) => (
  435. <TableRow key={project.project_ref} className="text-foreground-light text-xs">
  436. <TableCell className="py-2! px-0 pl-6">
  437. {project.project_name} ({project.computeType} - {project.usage} Hours)
  438. </TableCell>
  439. <TableCell className="py-2! px-0 text-right" translate="no">
  440. {formatCurrency(project.computeCosts)}
  441. </TableCell>
  442. </TableRow>
  443. ))}
  444. {/* Fallback to breakdown by instance size if project breakdown not available */}
  445. {!computeProjects.length &&
  446. computeItemsSortedByCost.map((computeItem) => (
  447. <TableRow
  448. key={title + computeItem.usage_metric}
  449. className="text-foreground-light text-xs"
  450. >
  451. <TableCell className="py-2! px-0 pl-6">
  452. {computeItem.description} - {computeItem.usage_original} Hours
  453. </TableCell>
  454. <TableCell className="py-2! px-0 text-right" translate="no">
  455. {formatCurrency(computeItem.amount)}
  456. </TableCell>
  457. </TableRow>
  458. ))}
  459. {computeCredits && (
  460. <TableRow className="text-foreground-light text-xs">
  461. <TableCell className="py-2! px-0 pl-6">Compute Credits</TableCell>
  462. <TableCell className="py-2! px-0 text-right" translate="no">
  463. {formatCurrency(computeCredits.amount)}
  464. </TableCell>
  465. </TableRow>
  466. )}
  467. </>
  468. )
  469. }