From 35478cf47b004c7751c1f4e042d9b37cc559b7f5 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Wed, 15 Apr 2026 12:34:28 -0300 Subject: [PATCH] feat: expose tax in upcoming invoice (#44732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ## 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. --------- Co-authored-by: Joshen Lim --- .../BillingBreakdown/UpcomingInvoice.tsx | 202 ++++++++++++------ .../invoices/org-invoice-upcoming-query.ts | 42 +--- .../stripe-projects-confirm-mutation.ts | 5 +- packages/api-types/types/platform.d.ts | 32 +-- 4 files changed, 158 insertions(+), 123 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index f103bb367d3..335e0e8539b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -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) => { Branching
    - {branchingComputeItems - .flatMap((it) => it.breakdown) - .map((breakdown) => ( -
  • - {breakdown!.project_name} ({breakdown!.usage} Hours) -
  • - ))} + {branchingComputeItemsDisplay.map((breakdown) => ( +
  • + {breakdown!.project_name} ({breakdown!.usage} Hours) +
  • + ))}

See{' '} - docs - {' '} + {' '} on how billing for Branching works.

@@ -202,52 +206,58 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { )} {/* Non-compute items */} - {otherItems.map((item) => ( - - -
- {item.description ?? 'Unknown'} - {((item.breakdown && item.breakdown.length > 0) || - item.usage_metric != null) && ( - - {item.unit_price_desc && ( -

- Pricing: {item.unit_price_desc} -

- )} + {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 && ( - <> -

Projects using {item.description}:

-
    - {item.breakdown.map((breakdown) => ( -
  • - - {breakdown.project_name} - {' '} - {item.usage_metric && ( - - ({formatUsage(item.usage_metric, breakdown)}{' '} - {billingMetricUnit(item.usage_metric)}) - - )} -
  • - ))} -
- - )} + return ( + + +
+ {item.description ?? 'Unknown'} + {(sortedBreakdown.length > 0 || item.usage_metric !== null) && ( + + {item.unit_price_desc && ( +

+ Pricing: {item.unit_price_desc} +

+ )} - {item.usage_metric && - usageBillingDocsLink[item.usage_metric] != null && ( + {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{' '} @@ -257,18 +267,19 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { for a detailed breakdown.

)} -
- )} -
-
- - - -
- ))} +
+ )} +
+
+ + + +
+ ) + })} @@ -284,7 +295,7 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { - {upcomingInvoice?.amount_projected && ( + {(!!upcomingInvoice.amount_projected || hasTax || taxFailed) && ( Projected Costs @@ -296,7 +307,64 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { - {formatCurrency(upcomingInvoice.amount_projected) ?? '-'} + {formatCurrency( + hasTax + ? upcomingInvoice.tax!.total_amount_excluding_tax + : upcomingInvoice.amount_projected + ) ?? '-'} + + + )} + + {hasTax && ( + <> + + +
+ Projected Tax + + 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. + +
+
+ + {formatCurrency(upcomingInvoice?.tax?.tax_amount) ?? '-'} + +
+ + + Projected Total + + Projected costs including applicable tax. The final amount may be adjusted + at the end of the billing cycle. + + + + {formatCurrency(upcomingInvoice?.tax!.total_amount_including_tax) ?? '-'} + + + + )} + + {taxFailed && ( + + +
+ Projected Tax + + We were unable to estimate tax for your organization. Please verify your + billing address in your organization settings. + +
+
+ +
+ Could not be estimated +
)} diff --git a/apps/studio/data/invoices/org-invoice-upcoming-query.ts b/apps/studio/data/invoices/org-invoice-upcoming-query.ts index 60a93ac1b2e..35742464ce2 100644 --- a/apps/studio/data/invoices/org-invoice-upcoming-query.ts +++ b/apps/studio/data/invoices/org-invoice-upcoming-query.ts @@ -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> diff --git a/apps/studio/data/partners/stripe-projects-confirm-mutation.ts b/apps/studio/data/partners/stripe-projects-confirm-mutation.ts index dbc5a55e71f..faf93f77d7f 100644 --- a/apps/studio/data/partners/stripe-projects-confirm-mutation.ts +++ b/apps/studio/data/partners/stripe-projects-confirm-mutation.ts @@ -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) diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index fba009c4b6c..d262b76b518 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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 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: {