mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
committed by
GitHub
parent
bacd524b22
commit
35478cf47b
+135
-67
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user