feat: expose tax in upcoming invoice (#44732)

Display tax information in the upcoming invoice breakdown.
- Show a "Tax" line item with amount and rate tooltip when tax is
successfully calculated
- Show a warning row when tax estimation fails, prompting users to
verify their billing address
- Update Current Costs and Projected Costs tooltips to indicate whether
tax is included or could not be estimated

## Test plan

- [ ]  Verify tax row appears with correct amount
when `tax_status` is `calculated`
- [ ]  Verify tax rate percentage shows in the tooltip (e.g., "Estimated
tax at 10%...")
- [ ]  Verify warning row appears when `tax_status` is `failed`
- [ ]  Verify no tax row appears when `tax_status` is `not_applicable`
- [ ]  Verify "Applicable tax included." appears in Current/Projected
Costs tooltips when tax is calculated
- [ ]  Verify "Tax could not be estimated and is not included." appears
in tooltips when tax fails

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Upcoming invoices now include tax details and a tax status.
* Billing breakdown shows projected tax and conditionally displays
projected totals excluding tax when applicable.
* If tax estimation fails, a “Tax — Could not be estimated” row appears
and totals reflect the failure.
* Added "Stripe Projects" as a billing partner option and clarified that
projected amounts may be explicitly null.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ignacio Dobronich
2026-04-15 12:34:28 -03:00
committed by GitHub
parent bacd524b22
commit 35478cf47b
4 changed files with 158 additions and 123 deletions
@@ -76,6 +76,10 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
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(
@@ -86,6 +90,10 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
)
.sort((a, b) => b.amount_before_discount - a.amount_before_discount) || []
const hasTax =
upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0
const taxFailed = upcomingInvoice?.tax_status === 'failed'
return (
<>
{isLoading && (
@@ -166,24 +174,20 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
<span>Branching</span>
<InfoTooltip className="max-w-sm">
<ul className="ml-6 list-disc">
{branchingComputeItems
.flatMap((it) => it.breakdown)
.map((breakdown) => (
<li key={`branching-breakdown-${breakdown!.project_ref}`}>
{breakdown!.project_name} ({breakdown!.usage} Hours)
</li>
))}
{branchingComputeItemsDisplay.map((breakdown) => (
<li key={`branching-breakdown-${breakdown!.project_ref}`}>
{breakdown!.project_name} ({breakdown!.usage} Hours)
</li>
))}
</ul>
<p className="mt-2">
See{' '}
<Link
className="underline"
<InlineLink
href={`${DOCS_URL}/guides/platform/manage-your-usage/branching`}
target="_blank"
>
docs
</Link>{' '}
</InlineLink>{' '}
on how billing for Branching works.
</p>
</InfoTooltip>
@@ -202,52 +206,58 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
)}
{/* Non-compute items */}
{otherItems.map((item) => (
<TableRow key={item.description}>
<TableCell className="py-2 px-0">
<div className="gap-1 flex items-center">
<span>{item.description ?? 'Unknown'}</span>
{((item.breakdown && item.breakdown.length > 0) ||
item.usage_metric != null) && (
<InfoTooltip className="max-w-sm">
{item.unit_price_desc && (
<p className="mb-2" translate="no">
Pricing: {item.unit_price_desc}
</p>
)}
{otherItems.map((item) => {
const usageMetric = item.usage_metric as PricingMetric
const sortedBreakdown = (item.breakdown ?? []).sort((a, b) =>
a.project_name.localeCompare(b.project_name)
)
{item.breakdown && item.breakdown.length > 0 && (
<>
<p>Projects using {item.description}:</p>
<ul className="ml-6 list-disc">
{item.breakdown.map((breakdown) => (
<li
key={`${item.description}-breakdown-${breakdown.project_ref}`}
>
<Link
className="underline"
href={`/project/${breakdown.project_ref}`}
target="_blank"
>
{breakdown.project_name}
</Link>{' '}
{item.usage_metric && (
<span>
({formatUsage(item.usage_metric, breakdown)}{' '}
{billingMetricUnit(item.usage_metric)})
</span>
)}
</li>
))}
</ul>
</>
)}
return (
<TableRow key={item.description}>
<TableCell className="py-2 px-0">
<div className="gap-1 flex items-center">
<span>{item.description ?? 'Unknown'}</span>
{(sortedBreakdown.length > 0 || item.usage_metric !== null) && (
<InfoTooltip className="max-w-sm">
{item.unit_price_desc && (
<p className="mb-2" translate="no">
Pricing: {item.unit_price_desc}
</p>
)}
{item.usage_metric &&
usageBillingDocsLink[item.usage_metric] != null && (
{sortedBreakdown.length > 0 && (
<>
<p>Projects using {item.description}:</p>
<ul className="ml-6 list-disc">
{sortedBreakdown.map((breakdown) => {
const unit = billingMetricUnit(usageMetric)
return (
<li
key={`${item.description}-breakdown-${breakdown.project_ref}`}
>
<InlineLink
target="_blank"
href={`/project/${breakdown.project_ref}`}
>
{breakdown.project_name}
</InlineLink>{' '}
{usageMetric && (
<span>
({formatUsage(usageMetric, breakdown)}
{!!unit ? ` ${unit}` : ''})
</span>
)}
</li>
)
})}
</ul>
</>
)}
{usageMetric && usageBillingDocsLink[usageMetric] != null && (
<p className="mt-2">
See{' '}
<InlineLink href={usageBillingDocsLink[item.usage_metric]!}>
<InlineLink href={usageBillingDocsLink[usageMetric]!}>
docs
</InlineLink>{' '}
on how billing for {item.description} works and{' '}
@@ -257,18 +267,19 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
for a detailed breakdown.
</p>
)}
</InfoTooltip>
)}
</div>
</TableCell>
<TableCell className="text-right py-2 px-0">
<InvoiceLineItemAmount
amount={item.amount}
amountBeforeDiscount={item.amount_before_discount}
/>
</TableCell>
</TableRow>
))}
</InfoTooltip>
)}
</div>
</TableCell>
<TableCell className="text-right py-2 px-0">
<InvoiceLineItemAmount
amount={item.amount}
amountBeforeDiscount={item.amount_before_discount}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
@@ -284,7 +295,7 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
</TableCell>
</TableRow>
{upcomingInvoice?.amount_projected && (
{(!!upcomingInvoice.amount_projected || hasTax || taxFailed) && (
<TableRow>
<TableCell className="font-medium py-2 px-0 flex items-center">
<span className="mr-2">Projected Costs</span>
@@ -296,7 +307,64 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
</InfoTooltip>
</TableCell>
<TableCell className="text-right font-medium py-2 px-0" translate="no">
{formatCurrency(upcomingInvoice.amount_projected) ?? '-'}
{formatCurrency(
hasTax
? upcomingInvoice.tax!.total_amount_excluding_tax
: upcomingInvoice.amount_projected
) ?? '-'}
</TableCell>
</TableRow>
)}
{hasTax && (
<>
<TableRow>
<TableCell className="py-2 px-0">
<div className="flex items-center gap-1">
<span>Projected Tax</span>
<InfoTooltip>
Estimated tax
{upcomingInvoice?.tax?.tax_rate_percentage != null &&
` at ${upcomingInvoice.tax.tax_rate_percentage}%`}{' '}
based on your organization's billing address. The final amount may be
adjusted at the end of the billing cycle.
</InfoTooltip>
</div>
</TableCell>
<TableCell className="text-right py-2 px-0" translate="no">
{formatCurrency(upcomingInvoice?.tax?.tax_amount) ?? '-'}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium py-2 px-0 flex items-center">
<span className="mr-2">Projected Total</span>
<InfoTooltip>
Projected costs including applicable tax. The final amount may be adjusted
at the end of the billing cycle.
</InfoTooltip>
</TableCell>
<TableCell className="text-right font-medium py-2 px-0" translate="no">
{formatCurrency(upcomingInvoice?.tax!.total_amount_including_tax) ?? '-'}
</TableCell>
</TableRow>
</>
)}
{taxFailed && (
<TableRow>
<TableCell className="py-2 px-0">
<div className="flex items-center gap-1">
<span>Projected Tax</span>
<InfoTooltip>
We were unable to estimate tax for your organization. Please verify your
billing address in your organization settings.
</InfoTooltip>
</div>
</TableCell>
<TableCell className="text-right py-2 px-0">
<div className="flex items-center justify-end gap-1">
<span className="text-warning">Could not be estimated</span>
</div>
</TableCell>
</TableRow>
)}
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { components } from 'api-types'
import { invoicesKeys } from './keys'
import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
import { get, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
@@ -9,38 +9,7 @@ export type UpcomingInvoiceVariables = {
orgSlug?: string
}
export type UpcomingInvoiceResponse = {
amount_projected: number
amount_total: number
currency: string
customer_balance: number
subscription_id: string
billing_cycle_end: string
billing_cycle_start: string
lines: {
amount: number
amount_before_discount: number
description: string
proration: boolean
period: { start: string; end: string }
quantity?: number
unit_price: number
unit_price_desc: string
usage_based: boolean
usage_metric?: PricingMetric
usage_original?: number
breakdown?: {
project_ref: string
project_name: string
usage: number
amount?: number
}[]
metadata?: {
is_branch: boolean
is_read_replica: boolean
}
}[]
}
export type UpcomingInvoiceResponse = components['schemas']['UpcomingInvoice']
export async function getUpcomingInvoice(
{ orgSlug }: UpcomingInvoiceVariables,
@@ -50,15 +19,12 @@ export async function getUpcomingInvoice(
const { data, error } = await get(`/platform/organizations/{slug}/billing/invoices/upcoming`, {
params: { path: { slug: orgSlug } },
headers: {
Version: '2',
},
headers: { Version: '2' },
signal,
})
if (error) handleError(error)
return data as unknown as UpcomingInvoiceResponse
return data as UpcomingInvoiceResponse
}
export type UpcomingInvoiceData = Awaited<ReturnType<typeof getUpcomingInvoice>>
@@ -13,10 +13,7 @@ async function confirmAccountRequest({ arId }: ConfirmAccountRequestVariables) {
const { data, error } = await post(
'/platform/stripe/projects/provisioning/account_requests/{id}/confirm',
{
params: { path: { id: arId } },
body: {},
}
{ params: { path: { id: arId } } }
)
if (error) handleError(error)
+18 -14
View File
@@ -4692,6 +4692,7 @@ export interface components {
billing_name?: string
/** @enum {boolean} */
clear_tax_id?: true
dry_run?: boolean
tax_id?: {
country?: string
type: string
@@ -4764,7 +4765,6 @@ export interface components {
payment_intent_id: string
size?: string
}
ConfirmRequestDto: Record<string, never>
ConfirmResponseDto: {
organization_slug: string
success: boolean
@@ -5057,6 +5057,8 @@ export interface components {
scopes?: (
| 'analytics:read'
| 'analytics:write'
| 'analytics_config:read'
| 'analytics_config:write'
| 'auth:read'
| 'auth:write'
| 'database:read'
@@ -6261,6 +6263,8 @@ export interface components {
scopes?: (
| 'analytics:read'
| 'analytics:write'
| 'analytics_config:read'
| 'analytics_config:write'
| 'auth:read'
| 'auth:write'
| 'database:read'
@@ -7001,10 +7005,6 @@ export interface components {
MFA_WEB_AUTHN_ENROLL_ENABLED: boolean
MFA_WEB_AUTHN_VERIFY_ENABLED: boolean
NIMBUS_OAUTH_CLIENT_ID: string | null
PASSKEY_ENABLED: boolean
WEBAUTHN_RP_DISPLAY_NAME: string
WEBAUTHN_RP_ID: string
WEBAUTHN_RP_ORIGINS: string
NIMBUS_OAUTH_CLIENT_SECRET: string | null
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
OAUTH_SERVER_AUTHORIZATION_PATH: string | null
@@ -7570,6 +7570,8 @@ export interface components {
scopes?: (
| 'analytics:read'
| 'analytics:write'
| 'analytics_config:read'
| 'analytics_config:write'
| 'auth:read'
| 'auth:write'
| 'database:read'
@@ -9931,6 +9933,7 @@ export interface components {
usage: number
}[]
description: string
item_name: string
metadata?: {
is_branch?: boolean
is_read_replica?: boolean
@@ -10000,6 +10003,15 @@ export interface components {
usage_original?: number
}[]
subscription_id: string
tax: {
currency: string
tax_amount: number
tax_rate_percentage: number
total_amount_excluding_tax: number
total_amount_including_tax: number
} | null
/** @enum {string} */
tax_status: 'calculated' | 'not_applicable' | 'failed'
}
UpdateAddonBody: {
/** @enum {string} */
@@ -10334,10 +10346,6 @@ export interface components {
MFA_WEB_AUTHN_ENROLL_ENABLED?: boolean | null
MFA_WEB_AUTHN_VERIFY_ENABLED?: boolean | null
NIMBUS_OAUTH_CLIENT_ID?: string | null
PASSKEY_ENABLED?: boolean | null
WEBAUTHN_RP_DISPLAY_NAME?: string | null
WEBAUTHN_RP_ID?: string | null
WEBAUTHN_RP_ORIGINS?: string | null
NIMBUS_OAUTH_CLIENT_SECRET?: string | null
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION?: boolean | null
OAUTH_SERVER_AUTHORIZATION_PATH?: string | null
@@ -26437,11 +26445,7 @@ export interface operations {
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['ConfirmRequestDto']
}
}
requestBody?: never
responses: {
200: {
headers: {