paddle
componentplan-change-breakdownsource: ok · hit
Plan change breakdown
Detailed financial breakdown of a subscription plan change. Invoice-style view with per-transaction line items, proration periods, tax, credits, and totals for immediate, next, and recurring billing. Financial-transparency view complementing the compact plan-change-preview.
npx shadcn add "https://developer.paddle.com/r/plan-change-breakdown.json"free
Where it goes
testimonial
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
@paddle/paddle-helperscardbadgeskeletonseparator
Files3 · source cached 10/14/2026, 1:53:08 PM
registry/new-york/blocks/plan-change-breakdown/components/plan-change-breakdown.tsx
"use client"
import * as React from "react"
import { TrendingDown, TrendingUp, Minus } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card"
import { Badge } from "@/registry/new-york/ui/badge"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { Separator } from "@/registry/new-york/ui/separator"
import { cn } from "@/lib/utils"
import type {
PlanChangeBreakdownData,
PlanChangeTransactionSectionData,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import {
formatMoney,
formatDate,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"
/** Props for the `PlanChangeBreakdown` component. */
export type PlanChangeBreakdownProps = {
breakdown?: PlanChangeBreakdownData
/**
* Payment collection mode. From `subscription.collection_mode`.
* Affects section titles: "Charged Today" vs "Invoice Created" for immediate transactions.
*/
collectionMode?: "automatic" | "manual"
className?: string
}
const SECTION_TITLES: Record<
"immediate" | "next" | "recurring",
{ automatic: string; manual: string }
> = {
immediate: { automatic: "Charged today", manual: "Invoice created" },
next: { automatic: "Next invoice", manual: "Next invoice" },
recurring: { automatic: "Ongoing billing", manual: "Ongoing billing" },
}
function TransactionSection({
section,
kind,
collectionMode = "automatic",
currency,
}: {
section: PlanChangeTransactionSectionData
kind: "immediate" | "next" | "recurring"
collectionMode?: "automatic" | "manual"
currency: string
}) {
const title = SECTION_TITLES[kind][collectionMode]
const description =
kind === "immediate"
? collectionMode === "manual"
? "An invoice will be created for this amount"
: "This amount will be charged immediately"
: kind === "next"
? section.billingDate
? `Charged on ${formatDate(section.billingDate)}`
: undefined
: "Recurring amount after this change"
return (
<div className="flex flex-col gap-3">
<div>
<h4 className="text-sm font-medium">{title}</h4>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
<div className="flex flex-col gap-2">
{section.lineItems.map((item, index) => (
<div key={index} className="flex items-start justify-between gap-4 text-sm">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-medium truncate">{item.productName}</span>
<span className="text-xs text-muted-foreground">
{item.quantity > 1 && `${item.quantity} \u00d7 `}
{formatMoney(item.unitPrice, currency)}
{item.isProrated && (
<Badge variant="secondary" className="ml-1 text-[10px] px-1 py-0">
Prorated
</Badge>
)}
</span>
{item.prorationPeriod && (
<span className="text-xs text-muted-foreground">{item.prorationPeriod}</span>
)}
</div>
<span className="tabular-nums shrink-0">{formatMoney(item.total, currency)}</span>
</div>
))}
</div>
<Separator />
<dl className="flex flex-col gap-1.5 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{formatMoney(section.totals.subtotal, currency)}</dd>
</div>
{section.totals.discount !== undefined && (
<div className="flex justify-between gap-4">
<dt className="text-success-foreground">Discount</dt>
<dd className="tabular-nums text-success-foreground">
−{formatMoney(section.totals.discount, currency)}
</dd>
</div>
)}
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Tax</dt>
<dd className="tabular-nums">{formatMoney(section.totals.tax, currency)}</dd>
</div>
{section.totals.credit !== undefined && (
<div className="flex justify-between gap-4">
<dt className="text-success-foreground">Credit applied</dt>
<dd className="tabular-nums text-success-foreground">
−{formatMoney(section.totals.credit, currency)}
</dd>
</div>
)}
{section.totals.creditToBalance !== undefined && (
<div className="flex justify-between gap-4">
<dt className="text-success-foreground">Credit to balance</dt>
<dd className="tabular-nums text-success-foreground">
{formatMoney(section.totals.creditToBalance, currency)}
</dd>
</div>
)}
<div className="flex justify-between gap-4 pt-1.5 border-t font-medium">
<dt>Total</dt>
<dd className="tabular-nums">{formatMoney(section.totals.total, currency)}</dd>
</div>
</dl>
</div>
)
}
export function PlanChangeBreakdown({
breakdown,
collectionMode,
className,
}: PlanChangeBreakdownProps) {
if (!breakdown) {
return <PlanChangeBreakdownSkeleton className={className} />
}
const {
currency,
result,
breakdown: summaryBreakdown,
immediateTransaction,
nextTransaction,
recurringTransaction,
} = breakdown
const isCredit = result.direction === "credit"
const isNone = result.direction === "none"
const resultLabel = isNone
? "No charge"
: isCredit
? "Credit to account"
: immediateTransaction
? "Amount due"
: "Added to next bill"
return (
<Card className={cn("flex flex-col", className)}>
<CardHeader>
<CardTitle className="text-base font-semibold">Change summary</CardTitle>
<CardDescription>Review the financial impact of this change</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div
className={cn(
"flex items-center justify-between rounded-lg p-4",
isCredit ? "bg-success/10" : "bg-muted"
)}
>
<div className="flex items-center gap-2">
{isCredit ? (
<TrendingDown className="size-5 text-success-foreground" />
) : isNone ? (
<Minus className="size-5 text-muted-foreground" />
) : (
<TrendingUp className="size-5 text-foreground" />
)}
<span className="font-medium">{resultLabel}</span>
</div>
<span
className={cn("text-lg font-bold tabular-nums", isCredit && "text-success-foreground")}
>
{isCredit
? `−${formatMoney(result.amount, currency)}`
: formatMoney(result.amount, currency)}
</span>
</div>
{/* Credit/charge breakdown from update_summary */}
{summaryBreakdown &&
(summaryBreakdown.credit !== undefined || summaryBreakdown.charge !== undefined) && (
<div className="flex flex-col gap-2 text-sm">
{summaryBreakdown.credit !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Credit from current plan</span>
<span className="text-success-foreground tabular-nums">
−{formatMoney(summaryBreakdown.credit, currency)}
</span>
</div>
)}
{summaryBreakdown.charge !== undefined && (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Charge for new plan</span>
<span className="tabular-nums">
{formatMoney(summaryBreakdown.charge, currency)}
</span>
</div>
)}
</div>
)}
{immediateTransaction && (
<>
<Separator />
<TransactionSection
section={immediateTransaction}
kind="immediate"
collectionMode={collectionMode}
currency={currency}
/>
</>
)}
{nextTransaction && (
<>
<Separator />
<TransactionSection
section={nextTransaction}
kind="next"
collectionMode={collectionMode}
currency={currency}
/>
</>
)}
{recurringTransaction && (
<>
<Separator />
<TransactionSection
section={recurringTransaction}
kind="recurring"
collectionMode={collectionMode}
currency={currency}
/>
</>
)}
</CardContent>
</Card>
)
}
function PlanChangeBreakdownSkeleton({ className }: { className?: string }) {
return (
<Card className={cn("flex flex-col", className)}>
<CardHeader>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-56" />
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="rounded-lg bg-muted p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-28" />
</div>
<Skeleton className="h-6 w-16" />
</div>
<div className="space-y-2">
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-16" />
</div>
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Separator />
<div className="space-y-3">
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-48" />
</div>
<div className="space-y-2">
<div className="flex justify-between gap-4">
<div className="space-y-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-4 w-14" />
</div>
</div>
<Separator />
<div className="space-y-1.5">
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-14" />
</div>
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex justify-between gap-4 pt-1.5 border-t">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-14" />
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<div className="space-y-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-52" />
</div>
<div className="space-y-2">
<div className="flex justify-between gap-4">
<div className="space-y-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-4 w-14" />
</div>
</div>
<Separator />
<div className="space-y-1.5">
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-14" />
</div>
<div className="flex justify-between gap-4">
<Skeleton className="h-3 w-8" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex justify-between gap-4 pt-1.5 border-t">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-14" />
</div>
</div>
</div>
</CardContent>
</Card>
)
}
registry/new-york/blocks/plan-change-breakdown/components/paddle-plan-change-breakdown.tsx
"use client"
import * as React from "react"
import { mapPreviewToBreakdownData } from "@/registry/new-york/blocks/plan-change-breakdown/lib/plan-change-breakdown-utils"
import { PlanChangeBreakdown, type PlanChangeBreakdownProps } from "./plan-change-breakdown"
type BreakdownPreviewResponse = Parameters<typeof mapPreviewToBreakdownData>[0]
export type PaddlePlanChangeBreakdownProps = {
/** Subscription update preview response with the proposed changes */
previewResponse: BreakdownPreviewResponse
} & Omit<PlanChangeBreakdownProps, "breakdown">
/**
* Paddle-aware wrapper for `PlanChangeBreakdown`.
*
* Accepts the response from the Paddle preview update endpoint, maps it to
* `PlanChangeBreakdownData`, and renders the detailed financial breakdown UI.
*
* Pass `collectionMode` from the subscription to control billing terminology
* (e.g. "Invoice created" vs "Charged today").
*
* @example
* <PaddlePlanChangeBreakdown
* previewResponse={previewResponse}
* prorationBillingMode="prorated_immediately"
* collectionMode={subscription.collectionMode}
* />
*/
export function PaddlePlanChangeBreakdown({
previewResponse,
...uiProps
}: PaddlePlanChangeBreakdownProps) {
const breakdown = mapPreviewToBreakdownData(previewResponse)
return <PlanChangeBreakdown breakdown={breakdown} {...uiProps} />
}
registry/new-york/blocks/plan-change-breakdown/lib/plan-change-breakdown-utils.ts
import type {
PlanChangeBreakdownData,
PlanChangeLineItemData,
PlanChangeTransactionSectionData,
PlanChangeTransactionTotalsData,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import { parseAmount } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"
// ---
// Input shapes — matching Paddle Node SDK / API response structures.
// `immediate_transaction` and `next_transaction` share a shape with
// `details.line_items` and `details.totals`. `recurring_transaction_details`
// has `line_items` and `totals` at the top level (no `details` wrapper)
// and omits `billing_period`.
// ---
type AmountWithCurrency = {
amount: string
currencyCode: string
}
type LineItemTotals = {
subtotal: string
total: string
tax: string
discount: string
}
type Proration = {
rate: string
billingPeriod: {
startsAt: string
endsAt: string
}
} | null
type LineItem = {
product: { name: string }
quantity: number
unitTotals: { subtotal: string }
totals: LineItemTotals
proration?: Proration
}
type TransactionTotals = {
subtotal: string
discount: string
tax: string
total: string
grandTotal: string
credit: string
creditToBalance?: string
}
type TransactionPreview = {
billingPeriod?: { startsAt?: string | null; endsAt?: string | null } | null
details: {
totals: TransactionTotals
lineItems: LineItem[]
}
}
type RecurringTransactionDetails = {
totals: TransactionTotals & { currencyCode: string }
lineItems: LineItem[]
}
type UpdateSummary = {
credit: AmountWithCurrency
charge: AmountWithCurrency
result: {
action: "credit" | "charge"
amount: string
currencyCode: string
}
}
type BreakdownPreviewResponse = {
currencyCode: string
updateSummary: UpdateSummary
immediateTransaction?: TransactionPreview | null
nextTransaction?: TransactionPreview | null
recurringTransactionDetails?: RecurringTransactionDetails | null
}
function mapLineItems(items: LineItem[], currencyCode: string): PlanChangeLineItemData[] {
return items.map((item) => {
const hasProration = item.proration != null
let prorationPeriod: string | undefined
if (hasProration && item.proration) {
const rate = parseFloat(item.proration.rate)
if (!isNaN(rate) && rate > 0 && rate < 1) {
const start = new Date(item.proration.billingPeriod.startsAt)
const end = new Date(item.proration.billingPeriod.endsAt)
const totalDays = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
const proratedDays = Math.round(totalDays * rate)
prorationPeriod = `${proratedDays} of ${totalDays} days`
}
}
return {
productName: item.product.name,
quantity: item.quantity,
unitPrice: parseAmount(item.unitTotals.subtotal, currencyCode),
total: parseAmount(item.totals.total, currencyCode),
isProrated: hasProration || undefined,
prorationPeriod,
}
})
}
function mapTotals(
totals: TransactionTotals,
currencyCode: string
): PlanChangeTransactionTotalsData {
const discount = parseAmount(totals.discount, currencyCode)
const credit = parseAmount(totals.credit, currencyCode)
const creditToBalance = totals.creditToBalance
? parseAmount(totals.creditToBalance, currencyCode)
: 0
return {
subtotal: parseAmount(totals.subtotal, currencyCode),
discount: discount > 0 ? discount : undefined,
tax: parseAmount(totals.tax, currencyCode),
credit: credit > 0 ? credit : undefined,
creditToBalance: creditToBalance > 0 ? creditToBalance : undefined,
total: parseAmount(totals.grandTotal, currencyCode),
}
}
/**
* Maps a Paddle subscription preview API response to the `PlanChangeBreakdownData`
* display contract consumed by `PlanChangeBreakdown`.
*
* Provides full financial detail — line items, tax, proration periods, and totals.
* Pass `collectionMode` directly to `<PlanChangeBreakdown>` as a prop.
*
* @param preview - Subscription update preview response
* @returns Mapped breakdown data for `<PlanChangeBreakdown />`
*
* @example
* const preview = await paddle.subscriptions.previewUpdate(subscriptionId, {
* items: [{ priceId: newPriceId, quantity: 1 }],
* prorationBillingMode: "prorated_immediately",
* })
* const data = mapPreviewToBreakdownData(preview)
*/
export function mapPreviewToBreakdownData(
preview: BreakdownPreviewResponse
): PlanChangeBreakdownData {
const {
currencyCode,
updateSummary,
immediateTransaction,
nextTransaction,
recurringTransactionDetails,
} = preview
const resultAmount = parseAmount(updateSummary.result.amount, currencyCode)
const creditAmount = parseAmount(updateSummary.credit.amount, currencyCode)
const chargeAmount = parseAmount(updateSummary.charge.amount, currencyCode)
const isNone = resultAmount === 0
const result: PlanChangeBreakdownData["result"] = {
direction: isNone ? "none" : updateSummary.result.action,
amount: resultAmount,
}
const breakdown: PlanChangeBreakdownData["breakdown"] =
creditAmount > 0 || chargeAmount > 0
? {
credit: creditAmount > 0 ? creditAmount : undefined,
charge: chargeAmount > 0 ? chargeAmount : undefined,
}
: undefined
let immediateSectionData: PlanChangeTransactionSectionData | undefined
if (immediateTransaction) {
immediateSectionData = {
lineItems: mapLineItems(immediateTransaction.details.lineItems, currencyCode),
totals: mapTotals(immediateTransaction.details.totals, currencyCode),
}
}
let nextSectionData: PlanChangeTransactionSectionData | undefined
if (nextTransaction) {
nextSectionData = {
billingDate: nextTransaction.billingPeriod?.startsAt ?? undefined,
lineItems: mapLineItems(nextTransaction.details.lineItems, currencyCode),
totals: mapTotals(nextTransaction.details.totals, currencyCode),
}
}
let recurringSectionData: PlanChangeTransactionSectionData | undefined
if (recurringTransactionDetails) {
recurringSectionData = {
lineItems: mapLineItems(recurringTransactionDetails.lineItems, currencyCode),
totals: mapTotals(recurringTransactionDetails.totals, currencyCode),
}
}
return {
currency: currencyCode,
result,
breakdown,
immediateTransaction: immediateSectionData,
nextTransaction: nextSectionData,
recurringTransaction: recurringSectionData,
}
}