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