shadimage
paddle
libpaddle-helperssource: ok · hit

Paddle helpers

Shared display types, format utilities, and payment method display for Paddle UI components. No SDK dependency — safe to install without @paddle/paddle-js.

npx shadcn add "https://developer.paddle.com/r/paddle-helpers.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files4 · source cached 10/14/2026, 1:09:02 PM

registry/new-york/blocks/paddle-helpers/lib/paddle-types.ts
// ---
// Checkout and pricing types
// Data contracts for checkout and pricing components. Populate from Paddle SDK
// hooks, checkout event callbacks, or any data source with a compatible shape.
// ---

/**
 * Formatted price data for display in pricing card components.
 *
 * Passed as the `priceData` prop to pricing card components.
 * All fields are pre-formatted strings, ready to render directly.
 *
 * Typically populated by `usePaddlePrices`, but you can construct it manually
 * or map it from any price data source.
 */
export type PriceData = {
  /** Formatted price string, e.g. "$9.99" or "£12.00" */
  total: string
  /** Pre-discount price, e.g. "$12.99". Present only when a discount is applied. */
  originalTotal?: string
  /** Billing frequency, e.g. "month", "year", "3 months" */
  interval?: string
  /** Trial period length, e.g. "7 days", "1 month" */
  trialPeriod?: string
}

/**
 * Normalized payload delivered to the `onComplete` callback when a Paddle
 * checkout transaction completes successfully.
 *
 * This is a subset of the full Paddle checkout event data, containing the
 * fields most commonly needed for post-purchase handling (e.g. sending to
 * your backend, showing a confirmation screen).
 */
export type CheckoutCompleteData = {
  transactionId: string
  customerId: string
  customerEmail: string
}

/**
 * A single line item in the checkout order summary.
 *
 * Monetary values are raw decimal numbers — the component formats them using
 * the parent `CheckoutSummaryData.currency`.
 * Produced by `mapCheckoutEventsToSummary` from `CheckoutEventsData`.
 */
export type CheckoutSummaryItem = {
  name: string
  priceName?: string
  quantity: number
  lineTotal: number
}

/**
 * Display contract for the `CheckoutSummary` component.
 *
 * Monetary amounts are raw decimal numbers — the component formats them using `currency`.
 * Produced by `mapCheckoutEventsToSummary` from the `CheckoutEventsData` payload
 * emitted by Paddle.js checkout events.
 */
export type CheckoutSummaryData = {
  items: CheckoutSummaryItem[]
  subtotal: number
  tax: number
  total: number
  discount?: number
  /** ISO 4217 currency code, e.g. "USD" */
  currency: string
  /** Recurring total amount as a decimal number */
  recurringTotal?: number
  /** Recurring billing interval, e.g. "month" */
  recurringInterval?: string
  /** Recurring billing frequency, e.g. 1 */
  recurringFrequency?: number
  /** Trial period label, e.g. "7 days" or "1 month" */
  trialPeriod?: string
}

// ---
// Subscription display types
// Display contracts for subscription management components. Monetary amounts
// are raw decimal numbers; date values are ISO 8601 strings. Populate from
// Paddle's subscription API responses combined with formatting utilities.
// ---

/** Paddle subscription statuses. From `subscription.status`. */
export type SubscriptionStatus = "active" | "canceled" | "past_due" | "paused" | "trialing"

/**
 * A single line item within a subscription.
 *
 * Represents one product/price combination in the subscription.
 * `lineTotal` should come from `recurring_transaction_details.line_items[n].totals.subtotal`
 * as a decimal number (e.g. 29.99).
 */
export type SubscriptionStatusItemData = {
  /** Product name, e.g. "Pro Plan" */
  productName: string
  /** Optional product description */
  productDescription?: string
  /** Optional product image URL */
  productImageUrl?: string
  /** Price/tier name, e.g. "Monthly" or "Annual". From `items[n].price.name`. */
  priceName?: string
  quantity: number
  /**
   * Unit price as a decimal number, e.g. 99.99.
   * From `items[n].price.unit_price.amount`. Only rendered when quantity > 1.
   */
  unitPrice?: number
  /**
   * Line item subtotal as a decimal number (before discount, before tax), e.g. 29.99.
   * From `recurring_transaction_details.line_items[n].totals.subtotal`.
   * Discounts are shown as a separate summary line — this field should NOT include them.
   */
  lineTotal: number
}

/**
 * Display contract for the `SubscriptionStatusCard` component.
 *
 * Pass `undefined` to render a skeleton loading state.
 * Monetary amounts are raw decimal numbers — the component formats them using `currency`.
 * Date values are ISO 8601 strings — the component formats them for display.
 *
 * `totalAmount` should come from `recurring_transaction_details.totals.total` —
 * this is the steady-state recurring amount (after discounts and tax).
 */
export type SubscriptionStatusData = {
  /** Paddle subscription ID, e.g. "sub_01abc..." */
  id?: string
  /** Subscription line items — auto-adapts layout for single vs multi-item */
  items: SubscriptionStatusItemData[]
  /** Recurring total as a decimal number, e.g. 49.99 */
  totalAmount: number
  /** ISO 4217 currency code, e.g. "USD" */
  currency: string
  /** Billing interval, e.g. "month" or "year" */
  interval: string
  /** Billing frequency. Defaults to 1. E.g. 3 for "every 3 months". */
  billingFrequency?: number
  status: SubscriptionStatus
  /** ISO 8601 subscription start date */
  startedAt: string
  /** ISO 8601 next billing date. Absent when paused or canceled. */
  nextBilledAt?: string
  /** ISO 8601 cancellation date. Present only when status is "canceled". */
  canceledAt?: string
  /**
   * Payment collection mode. From `subscription.collection_mode`.
   * "automatic" = auto-renew via saved payment method, "manual" = invoiced.
   */
  collectionMode?: "automatic" | "manual"
  scheduledChange?: {
    /** Type of scheduled change */
    action: "cancel" | "pause" | "resume"
    /** ISO 8601 date when the change takes effect */
    effectiveAt: string
    /** ISO 8601 date when the subscription resumes, only for pause actions with a set resume date */
    resumeAt?: string
  }
  /** Active discount, if any */
  discount?: {
    /** Discount savings amount as a decimal number, e.g. 5.00 */
    savingsAmount: number
    /** ISO 8601 date when the discount expires. Absent if discount recurs forever. */
    endsAt?: string
    /** Discount code, e.g. "SAVE20". From `discount.code`. */
    code?: string
    /** Discount description for billing summary, e.g. "-15%" or "-$5.00". API-provided or derived. */
    description?: string
  }
}

/**
 * Display contract for the `SubscriptionAlert` component.
 *
 * The component derives which alert to render from these fields.
 * Date values are ISO 8601 strings — the component formats them for display.
 * Pass `undefined` to render nothing.
 *
 * `updatePaymentMethodUrl` comes from `subscription.management_urls.update_payment_method`.
 * `trialEndsAt` comes from `subscription.items[0].trial_dates.ends_at`.
 */
export type SubscriptionAlertData = {
  status: SubscriptionStatus
  /** ISO 8601 cancellation date. Present when status is "canceled". */
  canceledAt?: string
  scheduledChange?: {
    /** Type of scheduled change */
    action: "cancel" | "pause" | "resume"
    /** ISO 8601 date when the change takes effect */
    effectiveAt: string
    /** ISO 8601 date when the subscription resumes, only for pause with a set resume date */
    resumeAt?: string
  }
  /** ISO 8601 trial end date. Present when status is "trialing". */
  trialEndsAt?: string
  /** Portal deep link to update payment method. Null for manual collection. */
  updatePaymentMethodUrl?: string
}

/**
 * Next payment details for the `SubscriptionPaymentCard` component.
 *
 * `amount` should come from `next_transaction.details.totals.grand_total`
 * (accounts for credits and adjustments) — pass as a decimal number (e.g. 29.99).
 * `currency` should come from `subscription.currency_code`.
 * `date` should come from `subscription.next_billed_at` as an ISO 8601 string.
 */
export type NextPaymentData = {
  /** Payment amount as a decimal number, e.g. 29.99 */
  amount: number
  /** ISO 4217 currency code, e.g. "USD" */
  currency: string
  /** ISO 8601 date string for the next billing date, e.g. "2025-02-01T00:00:00Z" */
  date: string
}

/**
 * Payment method details for the `SubscriptionPaymentCard` component.
 *
 * Pass the raw fields from `transaction.payments[0].method_details` directly —
 * the component resolves the display label automatically via `getPaymentMethodDisplay`.
 *
 * Sourced from the most recent completed transaction for the subscription
 * (not the saved payment methods API — see spec for sourcing details).
 *
 * @example
 * // From a completed Paddle transaction:
 * const payment = transaction.payments[0].methodDetails
 * paymentMethod={payment ? {
 *   type: payment.type,
 *   cardBrand: payment.card?.type,
 *   last4: payment.card?.last4,
 * } : undefined}
 */
export type PaymentMethodData = {
  /** Paddle payment method type from `method_details.type`, e.g. "card", "paypal", "apple_pay" */
  type: string
  /** Card brand from `method_details.card.type`, e.g. "visa", "mastercard". Card payments only. */
  cardBrand?: string
  /** Last 4 digits from `method_details.card.last4`, e.g. "4242". Card payments only. */
  last4?: string
  /** Card expiry month (1–12) from `method_details.card.expiry_month`. Card payments only. */
  expiryMonth?: number
  /** Card expiry year (4-digit) from `method_details.card.expiry_year`. Card payments only. */
  expiryYear?: number
  /**
   * Optional display label override. When provided, renders as-is instead of
   * auto-resolving from `type`/`cardBrand`/`last4`. Use for custom formats
   * (e.g. "Visa •••• 4242") or when you've already formatted the label upstream.
   */
  label?: string
}

/**
 * Display contract for the `PlanChangePreview` component.
 *
 * Sourced from the current subscription entity and the response from
 * `PATCH /subscriptions/{id}/preview`. Pass `undefined` to render a skeleton.
 *
 * Monetary amounts are raw decimal numbers — the component formats them using `currency`.
 * Date values are ISO 8601 strings — the component formats them for display.
 * UI labels and contextual messages belong on component props, not here.
 */
export type PlanChangePreviewData = {
  /** ISO 4217 currency code, e.g. "USD" */
  currency: string
  /** The plan being replaced */
  currentPlan: {
    /** Product name of the current plan */
    productName: string
    /** Current recurring total as a decimal number, e.g. 9.99 */
    price: number
    /** Current billing interval, e.g. "month" */
    interval: string
    /** Current billing frequency, defaults to 1 */
    billingFrequency?: number
  }
  /** The plan being switched to */
  newPlan: {
    /** Product name of the new plan */
    productName: string
    /** New recurring total as a decimal number, e.g. 29.99 */
    price: number
    /** New billing interval, e.g. "month" */
    interval: string
    /** New billing frequency, defaults to 1 */
    billingFrequency?: number
  }
  /** Financial impact of the plan change */
  costImpact: {
    /**
     * Direction of the net financial impact.
     * - `"charge"` — customer owes money (upgrade)
     * - `"credit"` — customer receives credit (downgrade)
     * - `"none"` — no financial movement (e.g. `do_not_bill` proration mode,
     *   trial upgrade, or paused subscription change)
     */
    resultDirection: "credit" | "charge" | "none"
    /** Net financial impact as a decimal number, e.g. 20.00. From `update_summary.result.amount`. */
    resultAmount: number
    /** Prorated credit for unused time on old plan as a decimal number. From `update_summary.credit.amount`. */
    credit?: number
    /** Prorated charge for new plan as a decimal number. From `update_summary.charge.amount`. */
    charge?: number
    /**
     * Amount charged immediately as a decimal number.
     * Present for `*_immediately` proration modes. From `immediate_transaction.details.totals.grand_total`.
     */
    immediateAmount?: number
    /**
     * Next bill total as a decimal number.
     * Present for `*_next_billing_period` proration modes. From `next_transaction.details.totals.grand_total`.
     */
    nextBillAmount?: number
    /** ISO 8601 next bill date. From `next_transaction.billing_period.starts_at`. */
    nextBillDate?: string
    /**
     * New steady-state recurring total as a decimal number after the change settles.
     * From `recurring_transaction_details.totals.total`.
     */
    newRecurringTotal: number
    /** New billing interval. From `billing_cycle.interval`. */
    newBillingInterval: string
    /** New billing frequency. From `billing_cycle.frequency`. Defaults to 1. */
    newBillingFrequency?: number
  }
  /** Active discount on the subscription. From `subscription.discount`. */
  discount?: {
    /** Discount description, e.g. "20% off" or "-$5.00". From `subscription.discount.description`. */
    description: string
    /** ISO 8601 expiry date. Absent if discount recurs forever. From `subscription.discount.ends_at`. */
    endsAt?: string
  }
  /** Pending scheduled change. From `subscription.scheduled_change`. */
  scheduledChange?: {
    /** Type of scheduled change */
    action: "cancel" | "pause" | "resume"
    /** ISO 8601 date when the change takes effect */
    effectiveAt: string
  }
  /**
   * Subscription status. From `subscription.status`.
   * Used by the component to render contextual messaging — e.g. a trial notice
   * when `status === "trialing"` and there is no immediate charge.
   */
  subscriptionStatus?: SubscriptionStatus
  /**
   * Payment collection mode. From `subscription.collection_mode`.
   * Used by the component to adapt labels — e.g. "Invoice amount" instead of
   * "Amount due now" when `collectionMode === "manual"`.
   */
  collectionMode?: "automatic" | "manual"
}

// ---
// Plan change breakdown types
// Display contract for the detailed financial breakdown component.
// Shows per-transaction line items, tax, proration, and totals.
// ---

/**
 * A single line item within a transaction preview.
 * Monetary amounts are raw decimal numbers — the component formats them
 * using the parent `PlanChangeBreakdownData.currency`.
 *
 * Maps to `details.line_items[]` (immediate/next transaction) or
 * `line_items[]` (recurring_transaction_details) in the preview response.
 */
export type PlanChangeLineItemData = {
  /** Product name, e.g. "Pro Plan". From `line_items[].product.name`. */
  productName: string
  /** Item quantity. From `line_items[].quantity`. */
  quantity: number
  /** Unit price as a decimal number, e.g. 49.00. From `line_items[].unit_totals.subtotal`. */
  unitPrice: number
  /** Line total as a decimal number, e.g. 26.95. From `line_items[].totals.total`. */
  total: number
  /** Whether this line item is prorated. Derived from `line_items[].proration !== null`. */
  isProrated?: boolean
  /** Proration period label, e.g. "15 of 31 days". Derived from `proration.rate` and `proration.billing_period`. */
  prorationPeriod?: string
}

/**
 * Totals breakdown for a single transaction section.
 * All values are raw decimal numbers — the component formats them using
 * the parent `PlanChangeBreakdownData.currency`.
 *
 * Maps to `details.totals` (immediate/next) or `totals` (recurring) in the preview response.
 */
export type PlanChangeTransactionTotalsData = {
  /** Subtotal as a decimal number (before discount, tax, and deductions). From `totals.subtotal`. */
  subtotal: number
  /** Discount amount as a decimal number. Present when a discount is active. From `totals.discount`. */
  discount?: number
  /** Tax amount as a decimal number. From `totals.tax`. */
  tax: number
  /** Credit applied to this transaction as a decimal number. From `totals.credit`. */
  credit?: number
  /** Surplus credit added to customer balance as a decimal number. From `totals.credit_to_balance`. */
  creditToBalance?: number
  /** Total due as a decimal number (after credits). From `totals.grand_total`. */
  total: number
}

/**
 * A complete transaction section (immediate, next, or recurring).
 * The section's position within `PlanChangeBreakdownData` determines its
 * role — the component derives titles and descriptions from that context.
 */
export type PlanChangeTransactionSectionData = {
  /**
   * ISO 8601 billing date for this transaction period.
   * Only relevant for `nextTransaction` — sourced from `billing_period.starts_at`.
   * Omitted for immediate and recurring sections.
   */
  billingDate?: string
  lineItems: PlanChangeLineItemData[]
  totals: PlanChangeTransactionTotalsData
}

/**
 * Display contract for the `PlanChangeBreakdown` component.
 *
 * Sourced from `PATCH /subscriptions/{id}/preview`. Pass `undefined` to render a skeleton.
 * Shows the full financial detail: per-transaction line items, tax, proration, and totals.
 */
export type PlanChangeBreakdownData = {
  /** ISO 4217 currency code, e.g. "USD" */
  currency: string
  /** Net financial result of the change. From `update_summary.result`. */
  result: {
    /**
     * Direction of the net result.
     * - `"charge"` — customer owes money (upgrade)
     * - `"credit"` — customer receives credit (downgrade)
     * - `"none"` — no financial movement
     */
    direction: "charge" | "credit" | "none"
    /** Net amount as a decimal number, e.g. 17.45. From `update_summary.result.amount`. */
    amount: number
  }
  /** Credit/charge breakdown from `update_summary` */
  breakdown?: {
    /** Credit from current plan as a decimal number. From `update_summary.credit`. */
    credit?: number
    /** Charge for new plan as a decimal number. From `update_summary.charge`. */
    charge?: number
  }
  /**
   * Immediate transaction details.
   * Present for `*_immediately` proration modes. From `immediate_transaction`.
   */
  immediateTransaction?: PlanChangeTransactionSectionData
  /** Next billing period transaction details. From `next_transaction`. */
  nextTransaction?: PlanChangeTransactionSectionData
  /** Recurring (steady-state) billing details. From `recurring_transaction_details`. */
  recurringTransaction?: PlanChangeTransactionSectionData
}
registry/new-york/blocks/paddle-helpers/lib/paddle-format.ts
/**
 * Formats an ISO 8601 date string as a locale-aware date string.
 *
 * @param isoString - ISO 8601 date string (e.g. "2024-01-15T00:00:00Z")
 * @param locale - Optional BCP 47 locale tag (e.g. "en-GB"). Defaults to "en-US" so SSR
 *   and client produce identical output (required for React hydration).
 * @returns Formatted date string, e.g. "Jan 15, 2024"
 *
 * @example
 * formatDate("2024-01-15T00:00:00Z") // "Jan 15, 2024"
 * formatDate("2024-01-15T00:00:00Z", "en-GB") // "15 Jan 2024"
 */
export function formatDate(isoString: string, locale: string = "en-US"): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "short",
    day: "numeric",
  }).format(new Date(isoString))
}

// Structural alias — accepts both CheckoutEventsTimePeriod and TimePeriod
type TimePeriodLike = {
  frequency: number
  interval: string
}

/**
 * Formats a billing cycle for display.
 * @param billingCycle - The billing cycle to format
 * @returns Formatted string like "month", "year", or "3 months", or undefined if no billing cycle
 *
 * @example
 * formatBillingCycle({ frequency: 1, interval: "month" }) // "month"
 * formatBillingCycle({ frequency: 3, interval: "month" }) // "3 months"
 */
export function formatBillingCycle(
  billingCycle: TimePeriodLike | null | undefined
): string | undefined {
  if (!billingCycle) {
    return undefined
  }

  const { frequency, interval } = billingCycle
  return frequency === 1 ? interval : `${frequency} ${interval}s`
}

/**
 * Formats a trial period for display.
 * @param trialPeriod - The trial period to format
 * @returns Formatted string like "7 days" or "1 month"
 *
 * @example
 * formatTrialPeriod({ frequency: 7, interval: "day" }) // "7 days"
 * formatTrialPeriod({ frequency: 1, interval: "month" }) // "1 month"
 */
export function formatTrialPeriod(trialPeriod: TimePeriodLike): string {
  const interval = trialPeriod.frequency === 1 ? trialPeriod.interval : `${trialPeriod.interval}s`

  return `${trialPeriod.frequency} ${interval}`
}

/**
 * Formats a numeric monetary amount as a localised currency string.
 *
 * @param amount - Raw numeric amount (e.g. from Paddle checkout event totals)
 * @param currencyCode - ISO 4217 currency code (e.g. "USD", "GBP")
 * @param locale - Optional BCP 47 locale tag (e.g. "en-GB"). Defaults to "en-US" so SSR
 *   and client produce identical output (required for React hydration).
 * @returns Formatted currency string, e.g. "$29.99" or "£12.00"
 *
 * @example
 * formatMoney(29.99, "USD") // "$29.99"
 * formatMoney(12, "GBP", "en-GB") // "£12.00"
 */
export function formatMoney(
  amount: number,
  currencyCode: string,
  locale: string = "en-US"
): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency: currencyCode,
  }).format(amount)
}

// Paddle-supported zero-decimal currencies — amounts are already whole units.
// https://developer.paddle.com/concepts/payment-methods/currencies
const ZERO_DECIMAL_CURRENCIES = new Set(["JPY", "KRW", "VND", "CLP"])

/**
 * Parses a Paddle API monetary amount string (lowest denomination, e.g. "1500")
 * to a decimal number suitable for `formatMoney` (e.g. 15.00).
 *
 * Paddle returns amounts as strings in the lowest denomination of the currency.
 * Standard currencies (USD, EUR, GBP, etc.) use cents — divide by 100.
 * Zero-decimal currencies (JPY, KRW, VND, CLP) are already whole units — no division.
 *
 * @param raw - Amount string in lowest denomination (e.g. "1500" for $15.00 USD or ¥1500 JPY)
 * @param currencyCode - ISO 4217 currency code used to determine decimal handling
 * @returns Decimal number ready for `formatMoney`
 *
 * @example
 * parseAmount("1500", "USD") // 15
 * parseAmount("999", "USD")  // 9.99
 * parseAmount("1500", "JPY") // 1500
 */
export function parseAmount(raw: string, currencyCode: string): number {
  const value = parseInt(raw, 10)
  if (ZERO_DECIMAL_CURRENCIES.has(currencyCode.toUpperCase())) {
    return value
  }
  return value / 100
}

/**
 * Returns a human-readable label for a Paddle proration billing mode.
 *
 * @param mode - Paddle `proration_billing_mode` value
 * @returns Display label, e.g. "Charge prorated amount now"
 *
 * @example
 * formatProrationMode("prorated_immediately") // "Charge prorated amount now"
 * formatProrationMode("full_next_billing_period") // "Full charge at next billing"
 */
export function formatProrationMode(mode: string): string {
  const labels: Record<string, string> = {
    prorated_immediately: "Charge prorated amount now",
    full_immediately: "Charge full amount now",
    prorated_next_billing_period: "Prorated at next billing",
    full_next_billing_period: "Full charge at next billing",
    do_not_bill: "No charge",
  }
  return labels[mode] ?? mode
}

const INTERVAL_LABELS: Record<string, { noun: string; adjective: string }> = {
  day: { noun: "Daily", adjective: "daily" },
  week: { noun: "Weekly", adjective: "weekly" },
  month: { noun: "Monthly", adjective: "monthly" },
  year: { noun: "Annually", adjective: "annual" },
}

/**
 * Returns a human-readable label for a Paddle billing interval.
 *
 * @param interval - Paddle interval key, e.g. "month", "year"
 * @param style - "noun" for toggle labels ("Monthly"), "adjective" for inline labels ("monthly")
 * @returns Display label, e.g. "Monthly" or "monthly"
 *
 * @example
 * formatIntervalLabel("month")             // "Monthly"
 * formatIntervalLabel("year", "adjective") // "annual"
 * formatIntervalLabel("month", "noun")     // "Monthly"
 */
export function formatIntervalLabel(
  interval: string,
  style: "noun" | "adjective" = "noun"
): string {
  const entry = INTERVAL_LABELS[interval]
  if (entry) return entry[style]
  return interval.charAt(0).toUpperCase() + interval.slice(1)
}
registry/new-york/blocks/paddle-helpers/lib/paddle-payment-method-display.ts
/**
 * Maps Paddle payment method types and card brands to human-readable display labels.
 * Sourced from `transaction.payments[0].method_details` on completed transactions.
 */

/** Paddle payment method types from `transaction.payments[0].method_details.type` */
export type PaddlePaymentMethodType =
  | "alipay"
  | "apple_pay"
  | "bancontact"
  | "blik"
  | "card"
  | "google_pay"
  | "ideal"
  | "kakao_pay"
  | "south_korea_local_card"
  | "mb_way"
  | "naver_pay"
  | "offline"
  | "payco"
  | "paypal"
  | "pix"
  | "samsung_pay"
  | "unknown"
  | "upi"
  | "wechat_pay"
  | "wire_transfer"
  /** @deprecated Returned on historical transactions but replaced by `south_korea_local_card` in newer transactions */
  | "korea_local"
  | (string & {})

/** All Paddle card brand types from `transaction.payments[0].method_details.card.type` */
export type PaddleCardType =
  | "american_express"
  | "diners_club"
  | "discover"
  | "jcb"
  | "mada"
  | "maestro"
  | "mastercard"
  | "union_pay"
  | "unknown"
  | "visa"
  | (string & {})

const PAYMENT_METHOD_LABELS: Record<string, string> = {
  alipay: "Alipay",
  apple_pay: "Apple Pay",
  bancontact: "Bancontact",
  blik: "BLIK",
  card: "Card",
  google_pay: "Google Pay",
  ideal: "iDEAL",
  kakao_pay: "Kakao Pay",
  south_korea_local_card: "Korea local card",
  mb_way: "MB WAY",
  naver_pay: "Naver Pay",
  offline: "Offline",
  payco: "PAYCO",
  paypal: "PayPal",
  pix: "Pix",
  samsung_pay: "Samsung Pay",
  unknown: "Payment method",
  upi: "UPI",
  wechat_pay: "WeChat Pay",
  wire_transfer: "Wire transfer",
  korea_local: "Korean payment methods",
}

const CARD_BRAND_LABELS: Record<string, string> = {
  american_express: "American Express",
  diners_club: "Diners Club",
  discover: "Discover",
  jcb: "JCB",
  mada: "Mada",
  maestro: "Maestro",
  mastercard: "Mastercard",
  union_pay: "UnionPay",
  unknown: "Card",
  visa: "Visa",
}

function formatUnknownType(type: string): string {
  return type
    .split("_")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ")
}

/**
 * Returns the full display label for a Paddle payment method.
 *
 * For card payments, pass `cardBrand` (from `method_details.card.type`) to
 * get the brand name, and `last4` (from `method_details.card.last4`) to
 * append the masked number. Both are optional — the label degrades gracefully.
 *
 * @param type - Paddle payment method type from `method_details.type`
 * @param cardBrand - Card brand from `method_details.card.type` (card payments only)
 * @param last4 - Last 4 digits from `method_details.card.last4` (card payments only)
 * @returns Human-readable payment method label
 *
 * @example
 * getPaymentMethodDisplay("card", "visa", "4242")    // "Visa ending 4242"
 * getPaymentMethodDisplay("card", "mastercard")       // "Mastercard"
 * getPaymentMethodDisplay("card")                     // "Card"
 * getPaymentMethodDisplay("paypal")                   // "PayPal"
 * getPaymentMethodDisplay("apple_pay")                // "Apple Pay"
 */
export function getPaymentMethodDisplay(
  type: PaddlePaymentMethodType,
  cardBrand?: PaddleCardType,
  last4?: string
): string {
  if (type === "card") {
    const brand = cardBrand
      ? (CARD_BRAND_LABELS[cardBrand] ?? formatUnknownType(cardBrand))
      : "Card"
    return last4 ? `${brand} ending ${last4}` : brand
  }
  return PAYMENT_METHOD_LABELS[type] ?? formatUnknownType(type)
}
registry/new-york/blocks/paddle-helpers/lib/payment-method-icons.tsx
import * as React from "react"
import type { PaddlePaymentMethodType, PaddleCardType } from "./paddle-payment-method-display"

// --- Brand icon components ---

function VisaIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <path
        d="M16.5 7.5L14.1 16.5H11.7L14.1 7.5H16.5ZM26.4 13.5L27.6 10.1L28.3 13.5H26.4ZM29.1 16.5H31.3L29.4 7.5H27.4C26.9 7.5 26.5 7.8 26.3 8.2L22.8 16.5H25.3L25.8 15.1H28.8L29.1 16.5ZM23.1 13.5C23.1 11.1 19.7 11 19.7 9.9C19.7 9.6 20 9.2 20.7 9.1C21 9.1 22 9 23.1 9.5L23.5 7.8C22.9 7.6 22.1 7.4 21.1 7.4C18.7 7.4 17.1 8.7 17.1 10.5C17.1 11.9 18.4 12.6 19.3 13.1C20.3 13.5 20.6 13.8 20.6 14.2C20.6 14.8 19.9 15.1 19.2 15.1C18 15.1 17.3 14.8 16.7 14.5L16.3 16.3C16.9 16.6 18 16.8 19.1 16.8C21.6 16.8 23.1 15.5 23.1 13.5Z"
        fill="currentColor"
      />
    </svg>
  )
}

function MastercardIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <circle cx="15" cy="12" r="5" fill="currentColor" fillOpacity="0.6" />
      <circle cx="23" cy="12" r="5" fill="currentColor" fillOpacity="0.4" />
    </svg>
  )
}

function AmexIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <text
        x="19"
        y="16"
        textAnchor="middle"
        fontSize="8"
        fontWeight="700"
        fill="currentColor"
        fontFamily="sans-serif"
      >
        AMEX
      </text>
    </svg>
  )
}

function PayPalIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <text
        x="19"
        y="16"
        textAnchor="middle"
        fontSize="7"
        fontWeight="700"
        fill="currentColor"
        fontFamily="sans-serif"
      >
        PayPal
      </text>
    </svg>
  )
}

function ApplePayIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <path
        d="M19 7.5C18 7.5 17.2 8 16.7 8.7C16.2 8.1 15.4 7.5 14.4 7.5C12.6 7.5 11.2 9 11.2 11C11.2 14 14.4 16.5 15.6 16.5C16.1 16.5 16.6 16.2 17 16.2C17.4 16.2 17.9 16.5 18.4 16.5C19.6 16.5 22.8 14 22.8 11C22.8 9 21.4 7.5 19 7.5Z"
        fill="currentColor"
        fillOpacity="0.7"
      />
    </svg>
  )
}

function GooglePayIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 38 24" className={className} aria-hidden="true">
      <rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
      <text
        x="19"
        y="16"
        textAnchor="middle"
        fontSize="7"
        fontWeight="700"
        fill="currentColor"
        fontFamily="sans-serif"
      >
        GPay
      </text>
    </svg>
  )
}

// --- Lookup ---

const CARD_BRAND_ICONS: Record<string, React.ElementType> = {
  visa: VisaIcon,
  mastercard: MastercardIcon,
  american_express: AmexIcon,
}

const PAYMENT_METHOD_ICONS: Record<string, React.ElementType> = {
  paypal: PayPalIcon,
  apple_pay: ApplePayIcon,
  google_pay: GooglePayIcon,
}

/**
 * Returns an icon component for the given Paddle payment method type and optional card brand.
 * Returns `null` when no branded icon is available — callers should fall back to a generic icon.
 */
export function getPaymentMethodIcon(
  type: PaddlePaymentMethodType,
  cardBrand?: PaddleCardType
): React.ElementType | null {
  if (type === "card" && cardBrand) {
    return CARD_BRAND_ICONS[cardBrand] ?? null
  }
  return PAYMENT_METHOD_ICONS[type] ?? null
}

More from paddle

Browse all components in this library →