shadimage
paddle
componentplan-change-previewsource: ok · hit

Plan change preview

Compact plan change summary card with side-by-side plan comparison, proration breakdown, upgrade/downgrade badge, discount, scheduled change warning, and optional confirm/cancel actions. Decision-support view for plan change flows.

npx shadcn add "https://developer.paddle.com/r/plan-change-preview.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-helperscardbadgeskeletonseparatoralert

Files3 · source cached 10/9/2026, 4:38:01 AM

registry/new-york/blocks/plan-change-preview/components/plan-change-preview.tsx
"use client"

import * as React from "react"
import { ArrowRight, AlertCircle } 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 { Alert, AlertDescription } from "@/registry/new-york/ui/alert"
import { cn } from "@/lib/utils"
import type { PlanChangePreviewData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import {
  formatMoney,
  formatDate,
  formatBillingCycle,
  formatProrationMode,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"

/** Props for the `PlanChangePreview` component. */
export type PlanChangePreviewProps = {
  preview?: PlanChangePreviewData
  /**
   * Paddle proration billing mode passed to `PATCH /subscriptions/{id}/preview`.
   * The component derives the billing row label and effective date from this value.
   */
  prorationBillingMode?:
    | "prorated_immediately"
    | "full_immediately"
    | "prorated_next_billing_period"
    | "full_next_billing_period"
    | "do_not_bill"
  className?: string
}

export function PlanChangePreview({
  preview,
  prorationBillingMode,
  className,
}: PlanChangePreviewProps) {
  if (!preview) {
    return <PlanChangePreviewSkeleton className={className} />
  }

  const {
    currency,
    currentPlan,
    newPlan,
    costImpact,
    discount,
    scheduledChange,
    subscriptionStatus,
    collectionMode,
  } = preview
  const isCharge = costImpact.resultDirection === "charge"
  const isCredit = costImpact.resultDirection === "credit"
  const isNeutral = costImpact.resultDirection === "none"
  const isManual = collectionMode === "manual"
  const isTrialing = subscriptionStatus === "trialing"

  const changeType = isCharge ? "upgrade" : isCredit ? "downgrade" : "change"

  const prorationLabel = prorationBillingMode
    ? formatProrationMode(prorationBillingMode)
    : undefined

  const isImmediate =
    prorationBillingMode?.includes("immediately") || prorationBillingMode === "do_not_bill"

  function resolveEffectiveDate(): string | undefined {
    if (prorationBillingMode) {
      if (isImmediate) return "Immediately"
      return costImpact.nextBillDate ? formatDate(costImpact.nextBillDate) : undefined
    }
    if (costImpact.immediateAmount !== undefined) return "Immediately"
    return costImpact.nextBillDate ? formatDate(costImpact.nextBillDate) : undefined
  }
  const effectiveDate = resolveEffectiveDate()

  function resolveScheduledChangeMessage(): string | undefined {
    if (!scheduledChange) return undefined
    if (scheduledChange.action === "resume") return undefined
    const actionLabel = scheduledChange.action === "cancel" ? "Cancellation" : "Pause"
    return `${actionLabel} scheduled for ${formatDate(scheduledChange.effectiveAt)}. Billing options may be restricted.`
  }
  const scheduledChangeMessage = resolveScheduledChangeMessage()

  const hasBreakdownRows = costImpact.credit !== undefined || costImpact.charge !== undefined

  const totalLabel = isCredit
    ? "Credit to account"
    : isNeutral
      ? "No charge"
      : isManual
        ? "Invoice amount"
        : costImpact.immediateAmount !== undefined
          ? "Amount due now"
          : "Amount at next billing"

  const currentIntervalLabel =
    formatBillingCycle({
      interval: currentPlan.interval,
      frequency: currentPlan.billingFrequency ?? 1,
    }) ?? currentPlan.interval

  const newIntervalLabel =
    formatBillingCycle({
      interval: newPlan.interval,
      frequency: newPlan.billingFrequency ?? 1,
    }) ?? newPlan.interval

  return (
    <Card className={cn("gap-4", className)}>
      <CardHeader>
        <CardTitle className="text-base font-semibold">Change summary</CardTitle>
        <CardDescription>Review the overview of this change</CardDescription>
      </CardHeader>

      <CardContent className="space-y-4">
        {scheduledChangeMessage && (
          <Alert>
            <AlertCircle className="size-4" />
            <AlertDescription>{scheduledChangeMessage}</AlertDescription>
          </Alert>
        )}

        <div className="flex items-stretch gap-3">
          <div className="flex-1 min-w-0 rounded-lg border bg-muted/40 p-3">
            <div className="text-xs text-muted-foreground mb-1">Current plan</div>
            <div className="font-medium text-sm truncate">{currentPlan.productName}</div>
            <div className="text-muted-foreground text-sm">
              {formatMoney(currentPlan.price, currency)}
              <span className="text-xs"> / {currentIntervalLabel}</span>
            </div>
          </div>

          <div className="flex items-center shrink-0">
            <ArrowRight className="size-4 text-muted-foreground" />
          </div>

          <div className="flex-1 min-w-0 rounded-lg border bg-primary/5 border-primary/20 p-3">
            <div className="text-xs text-muted-foreground mb-1">New plan</div>
            <div className="font-medium text-sm truncate">{newPlan.productName}</div>
            <div className="text-muted-foreground text-sm">
              {formatMoney(newPlan.price, currency)}
              <span className="text-xs"> / {newIntervalLabel}</span>
            </div>
          </div>
        </div>

        <Separator />

        <div className="space-y-2">
          <div className="flex items-center justify-between text-sm">
            <span className="text-muted-foreground">Change type</span>
            <Badge
              variant={
                changeType === "upgrade"
                  ? "default"
                  : changeType === "downgrade"
                    ? "secondary"
                    : "outline"
              }
            >
              {changeType === "upgrade"
                ? "Upgrade"
                : changeType === "downgrade"
                  ? "Downgrade"
                  : "Change"}
            </Badge>
          </div>

          {prorationLabel && (
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Billing</span>
              <span className="text-right">{prorationLabel}</span>
            </div>
          )}

          {effectiveDate && (
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Effective</span>
              <span>{effectiveDate}</span>
            </div>
          )}

          {discount && (
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Discount</span>
              <span className="text-right">
                <span className="text-success-foreground">{discount.description}</span>
                {discount.endsAt && (
                  <span className="text-muted-foreground text-xs block">
                    until {formatDate(discount.endsAt)}
                  </span>
                )}
              </span>
            </div>
          )}
        </div>

        <Separator />

        <div className="space-y-1.5">
          <div className="flex items-center justify-between text-sm">
            <span className="text-muted-foreground">Current</span>
            <span>
              {formatMoney(currentPlan.price, currency)}
              <span className="text-muted-foreground text-xs"> / {currentIntervalLabel}</span>
            </span>
          </div>
          <div className="flex items-center justify-between text-sm">
            <span className="text-muted-foreground">New</span>
            <span>
              {formatMoney(newPlan.price, currency)}
              <span className="text-muted-foreground text-xs"> / {newIntervalLabel}</span>
            </span>
          </div>
        </div>

        {/* Financial summary — credit/charge breakdown + total row */}
        {(hasBreakdownRows || !isNeutral) && (
          <>
            <Separator />
            <div className="space-y-1.5">
              {costImpact.credit !== undefined && (
                <div className="flex items-center justify-between text-sm text-success-foreground">
                  <span>Credit</span>
                  <span>−{formatMoney(costImpact.credit, currency)}</span>
                </div>
              )}
              {costImpact.charge !== undefined && (
                <div className="flex items-center justify-between text-sm">
                  <span className="text-muted-foreground">Charge</span>
                  <span>{formatMoney(costImpact.charge, currency)}</span>
                </div>
              )}
              <div
                className={cn(
                  "flex items-center justify-between font-medium",
                  hasBreakdownRows && "pt-1.5 border-t"
                )}
              >
                <span>{totalLabel}</span>
                <span className={cn(isCredit && "text-success-foreground")}>
                  {isCredit ? "−" : ""}
                  {formatMoney(costImpact.resultAmount, currency)}
                </span>
              </div>
            </div>
          </>
        )}

        {/* Contextual notes derived from subscription state */}
        {isTrialing && isNeutral && (
          <p className="text-xs text-muted-foreground">
            No charges during your trial. Billing begins when your trial ends.
          </p>
        )}
        {isManual && isCharge && (
          <p className="text-xs text-muted-foreground">
            An invoice will be created for this amount.
          </p>
        )}
      </CardContent>
    </Card>
  )
}

function PlanChangePreviewSkeleton({ className }: { className?: string }) {
  return (
    <Card className={cn("gap-4", className)}>
      <CardHeader>
        <Skeleton className="h-4 w-40" />
        <Skeleton className="h-3 w-52" />
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="flex items-stretch gap-3">
          <div className="flex-1 rounded-lg border bg-muted/40 p-3 space-y-2">
            <Skeleton className="h-3 w-20" />
            <Skeleton className="h-4 w-24" />
            <Skeleton className="h-3 w-16" />
          </div>
          <div className="flex items-center">
            <Skeleton className="h-4 w-4 rounded" />
          </div>
          <div className="flex-1 rounded-lg border p-3 space-y-2">
            <Skeleton className="h-3 w-16" />
            <Skeleton className="h-4 w-20" />
            <Skeleton className="h-3 w-16" />
          </div>
        </div>
        <Separator />
        <div className="space-y-2">
          <div className="flex justify-between">
            <Skeleton className="h-4 w-20" />
            <Skeleton className="h-5 w-16" />
          </div>
          <div className="flex justify-between">
            <Skeleton className="h-4 w-16" />
            <Skeleton className="h-4 w-32" />
          </div>
        </div>
        <Separator />
        <div className="space-y-1.5">
          <div className="flex justify-between">
            <Skeleton className="h-4 w-16" />
            <Skeleton className="h-4 w-20" />
          </div>
          <div className="flex justify-between">
            <Skeleton className="h-4 w-8" />
            <Skeleton className="h-4 w-20" />
          </div>
        </div>
      </CardContent>
    </Card>
  )
}
registry/new-york/blocks/plan-change-preview/components/paddle-plan-change-preview.tsx
"use client"

import * as React from "react"
import { mapPreviewToPlanChangeData } from "@/registry/new-york/blocks/plan-change-preview/lib/plan-change-preview-utils"
import { PlanChangePreview, type PlanChangePreviewProps } from "./plan-change-preview"

type PaddleSubscription = Parameters<typeof mapPreviewToPlanChangeData>[0]
type PreviewResponse = Parameters<typeof mapPreviewToPlanChangeData>[1]
type PaddleDiscount = Parameters<typeof mapPreviewToPlanChangeData>[2]

export type PaddlePlanChangePreviewProps = {
  /** Current Paddle Subscription (before the change) */
  subscription: PaddleSubscription
  /** Subscription update preview response with the proposed changes */
  previewResponse: PreviewResponse
  /** Paddle Discount for enriched discount display */
  discount?: PaddleDiscount
} & Omit<PlanChangePreviewProps, "preview">

/**
 * Paddle-aware wrapper for `PlanChangePreview`.
 *
 * Accepts the current subscription entity and the Paddle preview update response,
 * maps them to `PlanChangePreviewData`, and renders the UI component.
 *
 * Pass `prorationBillingMode` through to control which billing row and label
 * the component displays.
 *
 * `subscription.items[].price.unit_price` must be present for the current plan
 * price to display correctly — fetch with full price details, not a list response.
 *
 * @example
 * <PaddlePlanChangePreview
 *   subscription={subscription}
 *   previewResponse={previewResponse}
 *   discount={discount}
 *   prorationBillingMode="prorated_immediately"
 * />
 */
export function PaddlePlanChangePreview({
  subscription,
  previewResponse,
  discount,
  ...uiProps
}: PaddlePlanChangePreviewProps) {
  const preview = mapPreviewToPlanChangeData(subscription, previewResponse, discount)
  return <PlanChangePreview preview={preview} {...uiProps} />
}
registry/new-york/blocks/plan-change-preview/lib/plan-change-preview-utils.ts
import type { PlanChangePreviewData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import { parseAmount } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"

// ---
// Input shapes — minimal types matching the Paddle Node SDK / API response.
// The preview endpoint (PATCH /subscriptions/{id}/preview) returns a full
// subscription snapshot plus update_summary and transaction previews.
// ---

type AmountWithCurrency = {
  amount: string
  currencyCode: string
}

type UpdateSummary = {
  credit: AmountWithCurrency
  charge: AmountWithCurrency
  result: {
    action: "credit" | "charge"
    amount: string
    currencyCode: string
  }
}

type TransactionPreview = {
  billingPeriod?: { startsAt?: string | null } | null
  details?: {
    totals?: {
      grandTotal?: string | null
    } | null
  } | null
} | null

type RecurringDetails = {
  totals: { total: string; currencyCode: string }
  lineItems?: Array<{ totals: { total: string } }> | null
}

type PlanItem = {
  product: { name: string }
  price: {
    unitPrice?: { amount: string; currencyCode: string } | null
    billingCycle?: { interval: string; frequency: number } | null
  }
}

type SubscriptionPreviewResponse = {
  currencyCode: string
  billingCycle: { interval: string; frequency: number }
  updateSummary: UpdateSummary
  immediateTransaction?: TransactionPreview
  nextTransaction?: TransactionPreview
  recurringTransactionDetails: RecurringDetails
  items: PlanItem[]
}

// Matches SDK SubscriptionDiscount — subscription.discount only has id + dates
type SubscriptionDiscountField = {
  id: string
  startsAt?: string | null
  endsAt?: string | null
} | null

type PaddleSubscription = {
  currencyCode: string
  billingCycle: { interval: string; frequency: number }
  items: PlanItem[]
  status?: "active" | "canceled" | "past_due" | "paused" | "trialing"
  collectionMode?: "automatic" | "manual"
  scheduledChange?: {
    action: "cancel" | "pause" | "resume"
    effectiveAt: string
  } | null
  discount?: SubscriptionDiscountField
}

// Full Discount catalog entity — fetched separately via paddle.discounts.get(id)
type PaddleDiscount = {
  id: string
  description: string
  code?: string | null
  type: "flat" | "flat_per_seat" | "percentage"
  amount: string
}

/**
 * Maps a Paddle subscription preview API response to the `PlanChangePreviewData`
 * display contract consumed by `PlanChangePreview`.
 *
 * Pass `prorationBillingMode` directly to the `<PlanChangePreview>` component as a prop.
 * Optionally pass a Paddle `Discount` to enrich the discount display.
 *
 * @param subscription - Current Paddle Subscription (before the change)
 * @param preview - Subscription update preview response
 * @param discount - Paddle Discount for enriched discount display
 * @returns Mapped preview data for `<PlanChangePreview />`
 *
 * @example
 * const subscription = await paddle.subscriptions.get(subscriptionId)
 * const preview = await paddle.subscriptions.previewUpdate(subscriptionId, {
 *   items: [{ priceId: newPriceId, quantity: 1 }],
 *   prorationBillingMode: "prorated_immediately",
 * })
 * const data = mapPreviewToPlanChangeData(subscription, preview)
 */
export function mapPreviewToPlanChangeData(
  subscription: PaddleSubscription,
  preview: SubscriptionPreviewResponse,
  discount?: PaddleDiscount | null
): PlanChangePreviewData {
  const currencyCode = preview.currencyCode
  const { 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 resultDirection: PlanChangePreviewData["costImpact"]["resultDirection"] =
    resultAmount === 0 ? "none" : updateSummary.result.action

  const immediateAmount = immediateTransaction?.details?.totals?.grandTotal
    ? parseAmount(immediateTransaction.details.totals.grandTotal, currencyCode)
    : undefined

  const nextBillAmount = nextTransaction?.details?.totals?.grandTotal
    ? parseAmount(nextTransaction.details.totals.grandTotal, currencyCode)
    : undefined

  const nextBillDate = nextTransaction?.billingPeriod?.startsAt ?? undefined

  const currentItem = subscription.items[0]
  const newItem = preview.items[0]

  // description is required by PlanChangePreviewData.discount — only set when full entity provided
  const discountOutput: PlanChangePreviewData["discount"] = discount
    ? {
        description: discount.description,
        endsAt: subscription.discount?.endsAt ?? undefined,
      }
    : undefined

  const scheduledChange: PlanChangePreviewData["scheduledChange"] = subscription.scheduledChange
    ? {
        action: subscription.scheduledChange.action,
        effectiveAt: subscription.scheduledChange.effectiveAt,
      }
    : undefined

  return {
    currency: currencyCode,
    currentPlan: {
      productName: currentItem.product.name,
      price: currentItem.price.unitPrice
        ? parseAmount(currentItem.price.unitPrice.amount, currencyCode)
        : 0,
      interval: subscription.billingCycle.interval,
      billingFrequency: subscription.billingCycle.frequency,
    },
    newPlan: {
      productName: newItem.product.name,
      price: parseAmount(recurringTransactionDetails.totals.total, currencyCode),
      interval: preview.billingCycle.interval,
      billingFrequency: preview.billingCycle.frequency,
    },
    costImpact: {
      resultDirection,
      resultAmount,
      credit: creditAmount > 0 ? creditAmount : undefined,
      charge: chargeAmount > 0 ? chargeAmount : undefined,
      immediateAmount,
      nextBillAmount,
      nextBillDate,
      newRecurringTotal: parseAmount(recurringTransactionDetails.totals.total, currencyCode),
      newBillingInterval: preview.billingCycle.interval,
      newBillingFrequency: preview.billingCycle.frequency,
    },
    discount: discountOutput,
    scheduledChange,
    subscriptionStatus: subscription.status,
    collectionMode: subscription.collectionMode,
  }
}

More from paddle

Browse all components in this library →