import React from 'react'
import { Table, TableBody, TableCell, TableFooter, TableRow } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { billingMetricUnit, formatUsage } from '../helpers'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
import {
UpcomingInvoiceResponse,
useOrgUpcomingInvoiceQuery,
} from '@/data/invoices/org-invoice-upcoming-query'
import { useOrganizationQuery } from '@/data/organizations/organization-query'
import { DOCS_URL } from '@/lib/constants'
import { formatCurrency } from '@/lib/helpers'
export interface UpcomingInvoiceProps {
slug?: string
}
const usageBillingDocsLink: { [K in PricingMetric]?: string } = {
[PricingMetric.MONTHLY_ACTIVE_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users`,
[PricingMetric.MONTHLY_ACTIVE_SSO_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users-sso`,
[PricingMetric.MONTHLY_ACTIVE_THIRD_PARTY_USERS]: `${DOCS_URL}/guides/platform/manage-your-usage/monthly-active-users-third-party`,
[PricingMetric.AUTH_MFA_PHONE]: `${DOCS_URL}/guides/platform/manage-your-usage/advanced-mfa-phone`,
[PricingMetric.EGRESS]: `${DOCS_URL}/guides/platform/manage-your-usage/egress`,
[PricingMetric.CACHED_EGRESS]: `${DOCS_URL}/guides/platform/manage-your-usage/egress`,
[PricingMetric.FUNCTION_INVOCATIONS]: `${DOCS_URL}/guides/platform/manage-your-usage/edge-function-invocations`,
[PricingMetric.STORAGE_SIZE]: `${DOCS_URL}/guides/platform/manage-your-usage/storage-size`,
[PricingMetric.STORAGE_IMAGES_TRANSFORMED]: `${DOCS_URL}/guides/platform/manage-your-usage/storage-image-transformations`,
[PricingMetric.REALTIME_MESSAGE_COUNT]: `${DOCS_URL}/guides/platform/manage-your-usage/realtime-messages`,
[PricingMetric.REALTIME_PEAK_CONNECTIONS]: `${DOCS_URL}/guides/platform/manage-your-usage/realtime-peak-connections`,
[PricingMetric.CUSTOM_DOMAIN]: `${DOCS_URL}/guides/platform/manage-your-usage/custom-domains`,
[PricingMetric.IPV4]: `${DOCS_URL}/guides/platform/manage-your-usage/ipv4`,
[PricingMetric.PITR_7]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
[PricingMetric.PITR_14]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
[PricingMetric.PITR_28]: `${DOCS_URL}/guides/platform/manage-your-usage/point-in-time-recovery`,
[PricingMetric.DISK_SIZE_GB_HOURS_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-size`,
[PricingMetric.DISK_SIZE_GB_HOURS_IO2]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-size`,
[PricingMetric.DISK_IOPS_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-iops`,
[PricingMetric.DISK_IOPS_IO2]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-iops`,
[PricingMetric.DISK_THROUGHPUT_GP3]: `${DOCS_URL}/guides/platform/manage-your-usage/disk-throughput`,
[PricingMetric.LOG_DRAIN]: `${DOCS_URL}/guides/platform/manage-your-usage/log-drains`,
}
export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
const {
data: upcomingInvoice,
error: error,
isPending: isLoading,
isError,
isSuccess,
} = useOrgUpcomingInvoiceQuery({ orgSlug: slug })
const { data: organization } = useOrganizationQuery({ slug })
// For non-platform customers, compute is broken down per project and contains a breakdown array
const computeItems =
upcomingInvoice?.lines?.filter(
(item) =>
item.description?.toLowerCase().includes('compute') &&
!item.description.startsWith('Compute Credits')
) || []
const computeCreditsItem =
upcomingInvoice?.lines?.find((item) => item.description.startsWith('Compute Credits')) ?? null
const planItem = upcomingInvoice?.lines?.find((item) =>
item.description?.toLowerCase().includes('plan')
)
const regularComputeItems = computeItems.filter(
(it) => !it.metadata?.is_branch && !it.metadata?.is_read_replica
)
const branchingComputeItems = computeItems.filter((it) => it.metadata?.is_branch)
const replicaComputeItems = computeItems.filter((it) => it.metadata?.is_read_replica)
const branchingComputeItemsDisplay = branchingComputeItems
.flatMap((it) => it.breakdown)
.sort((a, b) => (a?.project_name ?? '').localeCompare(b?.project_name ?? ''))
const otherItems =
upcomingInvoice?.lines
?.filter(
(item) =>
!item.description?.toLowerCase().includes('compute') &&
!item.description?.toLowerCase().includes('plan') &&
item.amount_before_discount > 0
)
.sort((a, b) => b.amount_before_discount - a.amount_before_discount) || []
const prepaidCreditsItem =
upcomingInvoice?.lines?.find((item) => item.item_name === 'Prepaid Credits') ?? null
const hasTax =
upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0
const taxFailed = upcomingInvoice?.tax_status === 'failed'
const planFeePaidInAdvance =
!planItem && upcomingInvoice?.fixed_fees_billing_mode === 'in_arrears'
return (
<>
{isLoading && (
)}
{isError && }
{isSuccess && (
{planItem && !planFeePaidInAdvance && (
{planItem?.description}
{!planItem ? (
'-'
) : (
)}
)}
{/* Compute section */}
The first project is covered by Compute Credits. Additional projects incur
compute costs starting at $10/month, independent
of activity. See{' '}
docs
.
}
/>
{/* Read Replica compute */}
Each Read Replica is a dedicated database. You are charged for its resources:
Compute, Disk Size, provisioned Disk IOPS, provisioned Disk Throughput, and
IPv4. See{' '}
docs
.
}
/>
{/* Branching compute */}
{branchingComputeItems.length > 0 && (
Branching
{branchingComputeItemsDisplay.map((breakdown) => (
-
{breakdown!.project_name} ({breakdown!.usage} Hours)
))}
See{' '}
docs
{' '}
on how billing for Branching works.
prev + cur.amount, 0)}
amountBeforeDiscount={branchingComputeItems.reduce(
(prev, cur) => prev + (cur.amount_before_discount ?? 0),
0
)}
/>
)}
{/* Non-compute items */}
{otherItems.map((item) => {
const usageMetric = item.usage_metric as PricingMetric
const sortedBreakdown = (item.breakdown ?? []).sort((a, b) =>
a.project_name.localeCompare(b.project_name)
)
return (
{item.description ?? 'Unknown'}
{(sortedBreakdown.length > 0 || item.usage_metric !== null) && (
{item.item_name === 'minimum_amount' && (
Minimum Fee - If your cost is below the minimum fee, you will be
charged the difference as a floor fee
)}
{item.unit_price_desc && (
Pricing: {item.unit_price_desc}
)}
{sortedBreakdown.length > 0 && (
<>
Projects using {item.description}:
{sortedBreakdown.map((breakdown) => {
const unit = billingMetricUnit(usageMetric)
return (
-
{breakdown.project_name}
{' '}
{usageMetric && (
({formatUsage(usageMetric, breakdown)}
{!!unit ? ` ${unit}` : ''})
)}
)
})}
>
)}
{usageMetric && usageBillingDocsLink[usageMetric] != null && (
See{' '}
docs
{' '}
on how billing for {item.description} works and{' '}
usage page
{' '}
for a detailed breakdown.
)}
)}
)
})}
{prepaidCreditsItem && (
{prepaidCreditsItem.item_name}
Prepaid credits purchased upfront, applied automatically against your
invoice. Any remaining balance rolls over to the next billing cycle.
{formatCurrency(prepaidCreditsItem.amount) ?? '-'}
)}
Current Costs
Costs accumulated from the beginning of the billing cycle up to now.
{formatCurrency(upcomingInvoice?.amount_total) ?? '-'}
{(!!upcomingInvoice.amount_projected || hasTax || taxFailed) && (
Projected Costs
Projected costs at the end of the billing cycle. Includes predictable
costs for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery,
but no costs for metrics like MAU, storage or function invocations. Final
amounts may vary depending on your usage.
{hasTax && (
Subtotal
{formatCurrency(upcomingInvoice.tax!.total_amount_excluding_tax)}
Tax
{upcomingInvoice.tax?.tax_rate_percentage != null &&
` (${upcomingInvoice.tax.tax_rate_percentage}%)`}
{formatCurrency(upcomingInvoice.tax!.tax_amount)}
Estimated based on your organization's billing address. The final
amount may be adjusted at the end of the billing cycle.
)}
{taxFailed && (
We were unable to estimate tax for your organization. Please verify your
billing address in your organization settings.
)}
{formatCurrency(
hasTax
? upcomingInvoice.tax!.total_amount_including_tax
: upcomingInvoice.amount_projected
) ?? '-'}
)}
{planFeePaidInAdvance && (
Your {organization?.plan?.name && `${organization.plan.name} `}Plan fee for
this period has already been paid. This invoice will only reflect usage
charges.
)}
)}
>
)
}
function InvoiceLineItemAmount({
amountBeforeDiscount,
amount,
}: {
amountBeforeDiscount?: number
amount: number
}) {
if (amountBeforeDiscount && amount < amountBeforeDiscount) {
return (
{formatCurrency(amountBeforeDiscount)}
{formatCurrency(amount)}
)
} else {
return {formatCurrency(amount)}
}
}
function ComputeLineItem({
computeItems,
tooltip,
title,
computeCredits,
}: {
title: string
tooltip: React.ReactElement
computeItems: UpcomingInvoiceResponse['lines']
computeCredits?: UpcomingInvoiceResponse['lines'][number] | null
}) {
const computeProjects = computeItems
.flatMap((item) =>
item.breakdown!.map((project) => ({
...project,
computeType: item.description,
computeCosts: project.amount!,
}))
)
// descending by cost
.sort((a, b) => b.computeCosts - a.computeCosts)
const computeItemsSortedByCost = computeItems
// descending by cost
.sort((a, b) => b.amount - a.amount)
const computeCosts = Math.max(
0,
computeItems.reduce((prev, cur) => prev + cur.amount_before_discount, 0)
)
const discountedComputeCosts = Math.max(
0,
computeItems.reduce((prev, cur) => prev + (cur.amount ?? 0), 0)
)
if (!computeItems.length) return null
return (
<>
{title}
{tooltip}
{computeProjects.map((project) => (
{project.project_name} ({project.computeType} - {project.usage} Hours)
{formatCurrency(project.computeCosts)}
))}
{/* Fallback to breakdown by instance size if project breakdown not available */}
{!computeProjects.length &&
computeItemsSortedByCost.map((computeItem) => (
{computeItem.description} - {computeItem.usage_original} Hours
{formatCurrency(computeItem.amount)}
))}
{computeCredits && (
Compute Credits
{formatCurrency(computeCredits.amount)}
)}
>
)
}