shadimage
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 · light
iconslucide-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,
  }
}

More from paddle

Browse all components in this library →