feat(billing): show tax in subscription upgrade preview (#44258)

This PR moves the client-side "Charge Today" calculation to the backend
- relying on the `preview` endpoint data to show the `Charge Today`,
`Prorated Credits` and `Customer Balance` in the Subscription Upgrade
Preview.

It also includes fields for displaying Tax information in the preview,
only for enabled orgs. Do note that at the moment the Tax Preview will
not change if the address in the Subscription Preview changes, that will
be tackled in a follow-up PR.

## Changes

- Replace client-side charge calculation with backend data: The upgrade
dialog previously computed the prorated credit and total charge locally
(using subscription period timestamps and plan prices). This PR uses the
`upfront_charge` object returned by the subscription preview API
instead.
- Display itemized tax breakdown: When the backend returns tax data, the
dialog now shows a line-by-line breakdown: plan cost → subtotal (if
different) → tax (with rate %) → total charged today.
- Use `tax_status` from the subscription preview to conditionally show
tax details, and display a warning when tax calculation fails.


## Testing

### No taxes - Upgrade from Free to Paid Plan
- With a Free Org with a billing address in Canada or any other
non-enabled jurisdiction, start the upgrade to the Pro Plan
- Assert that only the Charge Today field is shown in the summary
<img width="410" height="298" alt="image"
src="https://github.com/user-attachments/assets/e8c7e12e-833d-41d5-aec3-00092b12782f"
/>

### Taxes - Upgrade Plan
- Update an Org's Orb Customer on the Free or Pro Plan with the
`automatic_tax_enabled: true` flag.
```
curl --location --request PUT 'https://api.withorb.com/v1/customers/external_customer_id/{ORG_SLUG}' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'Authorization: ••••••' \
--data '{
  "tax_configuration": {
    "tax_exempt": false,
    "tax_provider": "numeral",
    "automatic_tax_enabled": true
  }
}'
```
- Update the Org address to a valid taxable address, like: 
```
  "address": {
    "country": "US",
    "line1": "100 Congress Ave",
    "city": "Austin",
    "state": "TX",
    "postal_code": "78701"
  }
```
- Assert that the tax information is shown in the preview, including Tax
Rate, Prorated credits (if applicable), Customer balance (if applicable)
and the price of the intended plan.

<img width="465" height="470" alt="image"
src="https://github.com/user-attachments/assets/dd6a7e27-1708-48a8-bbf3-5435e6d582c2"
/>






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

## Summary by CodeRabbit

* **New Features**
* Added itemized breakdown of subscription charges, including plan cost,
unused time credits, subtotal, and applicable taxes.
  * Enhanced billing estimates with detailed tax information display.

* **Improvements**
* Updated "Charge today" calculations to reflect real-time preview data
for greater accuracy.
* Improved billing estimate UI layout and clarity with better
organization of charge details.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ignacio Dobronich authored and GitHub committed 2026-04-06 13:24:59 -03:00
1 parent 1355ea7db1
commit 41e6b66d7b
4 files changed
+888 -135

No files matched your search

@@ -20,7 +20,7 @@ import {
getPlanChangeType,
} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import AlertError from '@/components/ui/AlertError'
import { OrganizationBillingSubscriptionPreviewResponse } from '@/data/organizations/organization-billing-subscription-preview'
import { OrganizationBillingSubscriptionPreviewData } from '@/data/organizations/organization-billing-subscription-preview'
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change'
import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
@@ -54,6 +54,10 @@ const DOWNGRADE_PLAN_HEADINGS = {
type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS
type BreakdownItem =
| { type: 'amount'; label: string; amount: number; tooltip?: string }
| { type: 'notice'; label: string }
interface Props {
selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
onClose: () => void
@@ -61,7 +65,7 @@ interface Props {
subscriptionPreviewError: any
subscriptionPreviewIsLoading: boolean
subscriptionPreviewInitialized: boolean
subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined
subscriptionPreview: OrganizationBillingSubscriptionPreviewData | undefined
subscription: any
currentPlanMeta: any
projects: OrgProject[]
@@ -211,23 +215,94 @@ export const SubscriptionPlanUpdateDialog = ({
})
: []
// Calculate remaining days in current billing cycle
const now = Math.floor(Date.now() / 1000) // current time in seconds
const remainingSeconds = subscription?.current_period_end - now
const totalSeconds = subscription?.current_period_end - subscription?.current_period_start
const remainingRatio = remainingSeconds / totalSeconds
const upfrontCharge = subscriptionPreview?.upfront_charge
// Calculate prorated credit for current plan
const currentPlanMonthlyPrice = currentPlanMeta?.price ?? 0
const proratedCredit = currentPlanMonthlyPrice * remainingRatio
const proratedCredit = upfrontCharge?.prorated_credit ?? 0
const customerBalance = upfrontCharge?.customer_balance ?? 0
const totalCharge = upfrontCharge?.total ?? 0
const tax = upfrontCharge?.tax
const taxableAmount = upfrontCharge?.taxable_amount
const taxStatus = upfrontCharge?.tax_status
const hasTax = taxStatus === 'calculated' && (tax?.tax_amount ?? 0) > 0
const taxFailed = taxStatus === 'failed'
// Calculate new plan cost
const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) ?? 0
const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) || 0
const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1
const currentPlanId = subscription?.plan?.id
const currentPlanName = subscription?.plan?.name
// Calculate total charge (new plan - prorated credit)
const totalCharge = Math.max(0, newPlanCost - proratedCredit - customerBalance)
// Derives the itemized charge breakdown rows shown above "Charge today".
// Example: Pro -> Team upgrade with proration, tax, and credits:
// Team Plan $25.00
// Unused Time on Pro -$8.33
// Subtotal $16.67
// Tax (10%) $1.67
// Credits -$5.00
// ─────────────────────────────
// Charge today $13.34
const breakdownItems = useMemo(() => {
const items: BreakdownItem[] = []
if (currentPlanId !== 'free' && proratedCredit > 0) {
items.push({
type: 'amount',
label: `Unused Time on ${currentPlanName} Plan`,
amount: -proratedCredit,
tooltip:
'Your previous plan was charged upfront, so a plan change will prorate any unused time in credits. If the prorated credits exceed the new plan charge, the excessive credits are added to your organization for future use.',
})
}
if (hasTax && tax) {
if (taxableAmount !== newPlanCost) {
items.push({ type: 'amount', label: 'Subtotal', amount: taxableAmount! })
}
items.push({
type: 'amount',
label: `Tax (${tax.tax_rate_percentage}%)`,
amount: tax.tax_amount,
})
}
if (taxFailed) {
items.push({
type: 'notice',
label: 'Tax could not be estimated and may be applied separately',
})
}
if (customerBalance > 0) {
items.push({
type: 'amount',
label: 'Credits',
amount: -customerBalance,
tooltip: 'Credits will be used first before charging your card.',
})
}
// Prepend the plan cost row when there are adjustment items to show
if (changeType !== 'downgrade' && items.length > 0) {
items.unshift({
type: 'amount',
label: `${subscriptionPlanMeta?.name} Plan`,
amount: newPlanCost,
})
}
return items
}, [
currentPlanId,
currentPlanName,
proratedCredit,
hasTax,
tax,
taxableAmount,
newPlanCost,
taxFailed,
customerBalance,
changeType,
subscriptionPlanMeta?.name,
])
return (
<Dialog
@@ -294,11 +369,37 @@ export const SubscriptionPlanUpdateDialog = ({
{subscriptionPreviewInitialized && (
<>
<div className="mt-2 mb-4 text-foreground-light text-sm">
{breakdownItems.map((item, i) =>
item.type === 'amount' ? (
<div
key={i}
className="flex items-center justify-between gap-2 border-b border-muted text-xs"
>
<div className="py-2 pl-0 flex items-center gap-1">
<span>{item.label}</span>
{item.tooltip && (
<InfoTooltip className="max-w-sm">{item.tooltip}</InfoTooltip>
)}
</div>
<div className="py-2 pr-0 text-right tabular-nums" translate="no">
{formatCurrency(item.amount)}
</div>
</div>
) : (
<div
key={i}
className="flex items-center justify-between gap-2 border-b border-muted text-xs"
>
<div className="py-2 pl-0 text-foreground-lighter">{item.label}</div>
</div>
)
)}
<div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
<div className="py-2 pl-0">Charge today</div>
<div className="py-2 pr-0 text-right" translate="no">
<div className="py-2 pr-0 text-right tabular-nums" translate="no">
{formatCurrency(totalCharge)}
{subscription?.plan?.id !== 'free' && (
{currentPlanId !== 'free' && (
<>
{' '}
<Link
@@ -313,39 +414,7 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will prorate
any unused time in credits. If the prorated credits exceed the new plan
charge, the excessive credits are added to your organization for future
use.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs">
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs mt-4">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Monthly invoice estimate</span>
<InfoTooltip side="right">
@@ -543,7 +612,7 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
<div className="py-2 pr-0 text-right tabular-nums" translate="no">
{formatCurrency(
subscriptionPreview?.breakdown.reduce(
(prev: number, cur) => prev + cur.total_price,
@@ -10,42 +10,6 @@ export type OrganizationBillingSubscriptionPreviewVariables = {
tier?: SubscriptionTier
}
export type OrganizationBillingSubscriptionPreviewResponse = {
breakdown: {
description: string
unit_price: number
unit_price_desc?: string
quantity?: number
total_price: number
breakdown?: {
project_name: string
project_ref: string
usage: number
}[]
}[]
number_of_projects?: number
plan_change_type?: 'downgrade' | 'none' | 'upgrade'
active_projects?: {
status:
| 'INACTIVE'
| 'ACTIVE_HEALTHY'
| 'ACTIVE_UNHEALTHY'
| 'COMING_UP'
| 'UNKNOWN'
| 'GOING_DOWN'
| 'INIT_FAILED'
| 'REMOVED'
| 'RESTORING'
| 'RESTARTING'
| 'RESIZING'
| 'UPGRADING'
instance_size: string
name: string
ref: string
}[]
billed_via_partner?: boolean
}
export async function previewOrganizationBillingSubscription({
organizationSlug,
tier,
@@ -68,7 +32,7 @@ export async function previewOrganizationBillingSubscription({
if (error) handleError(error)
return data as OrganizationBillingSubscriptionPreviewResponse
return data
}
export type OrganizationBillingSubscriptionPreviewData = Awaited<
+539 -44
View File
File diff suppressed because it is too large. Load diff
+228 -3
View File
@@ -1335,6 +1335,26 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/organizations/{slug}/documents/dpa-signed': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Check if organization has signed any version of the DPA
* @description Results are cached per organization for up to 24 hours. Signed status may not reflect immediately after a document is completed.
*/
get: operations['OrgDocumentsController_getDpaSignedStatus']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/organizations/{slug}/documents/soc2-type-2-report': {
parameters: {
query?: never
@@ -4653,6 +4673,13 @@ export interface components {
state?: string | null
}
billing_name?: string
/** @enum {boolean} */
clear_tax_id?: true
tax_id?: {
country?: string
type: string
value: string
}
}
Buffer: Record<string, never>
BulkDeleteUserContentResponse: {
@@ -6099,6 +6126,11 @@ export interface components {
}
timestamp: string
}
DocumentSignedStatusResponse: {
/** Format: date-time */
checked_at: string
signed: boolean
}
DownloadableBackupsResponse: {
backups: {
id: number
@@ -6964,6 +6996,7 @@ export interface components {
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
OAUTH_SERVER_AUTHORIZATION_PATH: string | null
OAUTH_SERVER_ENABLED: boolean
PASSKEY_ENABLED: boolean
PASSWORD_HIBP_ENABLED: boolean
PASSWORD_MIN_LENGTH: number
PASSWORD_REQUIRED_CHARACTERS: string
@@ -6984,6 +7017,7 @@ export interface components {
SECURITY_MANUAL_LINKING_ENABLED: boolean
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number
SECURITY_SB_FORWARDED_FOR_ENABLED: boolean
SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD: boolean
SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: boolean
SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD: boolean
SESSIONS_INACTIVITY_TIMEOUT: number
@@ -7022,6 +7056,9 @@ export interface components {
SMTP_SENDER_NAME: string
SMTP_USER: string
URI_ALLOW_LIST: string
WEBAUTHN_RP_DISPLAY_NAME: string | null
WEBAUTHN_RP_ID: string | null
WEBAUTHN_RP_ORIGINS: string | null
}
InstallPlatformAppBody: {
/** Format: uuid */
@@ -7661,7 +7698,10 @@ export interface components {
name: string
projects: {
name: string
/** @description Project ref */
/**
* @description Project ref
* @example abcdefghijklmnopqrst
*/
ref: string
}[]
}[]
@@ -7672,7 +7712,10 @@ export interface components {
name: string
projects: {
name: string
/** @description Project ref */
/**
* @description Project ref
* @example abcdefghijklmnopqrst
*/
ref: string
}[]
}[]
@@ -8183,6 +8226,127 @@ export interface components {
message: string
}[]
}
PreviewSubscriptionChangeResponse: {
active_projects: {
/** @enum {string} */
instance_size:
| 'pico'
| 'nano'
| 'micro'
| 'small'
| 'medium'
| 'large'
| 'xlarge'
| '2xlarge'
| '4xlarge'
| '8xlarge'
| '12xlarge'
| '16xlarge'
| '24xlarge'
| '24xlarge_optimized_memory'
| '24xlarge_optimized_cpu'
| '24xlarge_high_memory'
| '48xlarge'
| '48xlarge_optimized_memory'
| '48xlarge_optimized_cpu'
| '48xlarge_high_memory'
name: string
ref: string
/** @enum {string} */
status:
| 'INACTIVE'
| 'ACTIVE_HEALTHY'
| 'ACTIVE_UNHEALTHY'
| 'COMING_UP'
| 'UNKNOWN'
| 'GOING_DOWN'
| 'INIT_FAILED'
| 'REMOVED'
| 'RESTORING'
| 'UPGRADING'
| 'PAUSING'
| 'RESTORE_FAILED'
| 'RESTARTING'
| 'PAUSE_FAILED'
| 'RESIZING'
}[]
billed_via_partner: boolean
breakdown: {
breakdown?: {
amount?: number
/** @enum {string} */
project_db_instance_size?:
| 'pico'
| 'nano'
| 'micro'
| 'small'
| 'medium'
| 'large'
| 'xlarge'
| '2xlarge'
| '4xlarge'
| '8xlarge'
| '12xlarge'
| '16xlarge'
| '24xlarge'
| '24xlarge_optimized_memory'
| '24xlarge_optimized_cpu'
| '24xlarge_high_memory'
| '48xlarge'
| '48xlarge_optimized_memory'
| '48xlarge_optimized_cpu'
| '48xlarge_high_memory'
project_name: string
project_ref: string
/** @enum {string} */
project_status?:
| 'INACTIVE'
| 'ACTIVE_HEALTHY'
| 'ACTIVE_UNHEALTHY'
| 'COMING_UP'
| 'UNKNOWN'
| 'GOING_DOWN'
| 'INIT_FAILED'
| 'REMOVED'
| 'RESTORING'
| 'UPGRADING'
| 'PAUSING'
| 'RESTORE_FAILED'
| 'RESTARTING'
| 'PAUSE_FAILED'
| 'RESIZING'
usage: number
}[]
description: string
quantity?: number
total_price: number
unit_price?: number
unit_price_desc?: string
}[]
/** @enum {string} */
monthly_invoice_tax_status: 'calculated' | 'not_applicable' | 'failed'
number_of_projects: number
/** @enum {boolean} */
pending_subscription_flow: true
/** @enum {string} */
plan_change_type: 'upgrade' | 'downgrade' | 'none'
slug: string
upfront_charge: {
customer_balance: number
prorated_credit: number
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'
taxable_amount: number
total: number
} | null
}
PrivateLinkResponse: {
appliedSuccessfully: boolean
currentConfig: {
@@ -10131,6 +10295,7 @@ export interface components {
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION?: boolean | null
OAUTH_SERVER_AUTHORIZATION_PATH?: string | null
OAUTH_SERVER_ENABLED?: boolean | null
PASSKEY_ENABLED?: boolean
PASSWORD_HIBP_ENABLED?: boolean | null
PASSWORD_MIN_LENGTH?: number | null
/** @enum {string|null} */
@@ -10158,6 +10323,7 @@ export interface components {
SECURITY_MANUAL_LINKING_ENABLED?: boolean | null
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL?: number | null
SECURITY_SB_FORWARDED_FOR_ENABLED?: boolean | null
SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD?: boolean | null
SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION?: boolean | null
SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD?: boolean | null
SESSIONS_INACTIVITY_TIMEOUT?: number | null
@@ -10198,6 +10364,9 @@ export interface components {
SMTP_SENDER_NAME?: string | null
SMTP_USER?: string | null
URI_ALLOW_LIST?: string | null
WEBAUTHN_RP_DISPLAY_NAME?: string | null
WEBAUTHN_RP_ID?: string | null
WEBAUTHN_RP_ORIGINS?: string | null
}
UpdateGoTrueConfigHooksBody: {
HOOK_AFTER_USER_CREATED_ENABLED?: boolean | null
@@ -10266,6 +10435,9 @@ export interface components {
slug: string
stripe_customer_id: string
}
/** @example {
* "password": "correct-horse-battery-staple"
* } */
UpdatePasswordBody: {
password: string
}
@@ -10769,6 +10941,14 @@ export interface components {
file_size_limit?: number | null
public: boolean
}
/** @example {
* "fileSizeLimit": 10485760,
* "features": {
* "imageTransformation": {
* "enabled": true
* }
* }
* } */
UpdateStorageConfigBody: {
external?: {
/** @enum {string} */
@@ -14707,7 +14887,9 @@ export interface operations {
headers: {
[name: string]: unknown
}
content?: never
content: {
'application/json': components['schemas']['PreviewSubscriptionChangeResponse']
}
}
/** @description Unauthorized */
401: {
@@ -15042,6 +15224,49 @@ export interface operations {
}
}
}
OrgDocumentsController_getDpaSignedStatus: {
parameters: {
query?: never
header?: never
path: {
/** @description Organization slug */
slug: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['DocumentSignedStatusResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden action */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
OrgDocumentsController_getSoc2Type2ReportUrl: {
parameters: {
query?: never