diff --git a/.changeset/rich-drinks-ring.md b/.changeset/rich-drinks-ring.md new file mode 100644 index 00000000000..90b199a0ad8 --- /dev/null +++ b/.changeset/rich-drinks-ring.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +[Billing Beta] Replace usage of top level amounts in plan with fees for displaying prices. diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index be155abe172..c079f5947e2 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -30,8 +30,8 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/commerce-feature-resource.mdx", "types/commerce-initialized-payment-source-json.mdx", "types/commerce-initialized-payment-source-resource.mdx", - "types/commerce-money-json.mdx", - "types/commerce-money.mdx", + "types/commerce-money-amount-json.mdx", + "types/commerce-money-amount.mdx", "types/commerce-payer-resource-type.mdx", "types/commerce-payment-charge-type.mdx", "types/commerce-payment-json.mdx", diff --git a/packages/backend/src/api/resources/CommercePlan.ts b/packages/backend/src/api/resources/CommercePlan.ts index 931729e74e0..06a73a44029 100644 --- a/packages/backend/src/api/resources/CommercePlan.ts +++ b/packages/backend/src/api/resources/CommercePlan.ts @@ -1,7 +1,7 @@ import { Feature } from './Feature'; import type { CommercePlanJSON } from './JSON'; -type CommerceFee = { +type CommerceMoneyAmount = { amount: number; amountFormatted: string; currency: string; @@ -53,15 +53,15 @@ export class CommercePlan { /** * The monthly fee of the plan. */ - readonly fee: CommerceFee, + readonly fee: CommerceMoneyAmount, /** * The annual fee of the plan. */ - readonly annualFee: CommerceFee, + readonly annualFee: CommerceMoneyAmount, /** * The annual fee of the plan on a monthly basis. */ - readonly annualMonthlyFee: CommerceFee, + readonly annualMonthlyFee: CommerceMoneyAmount, /** * The type of payer for the plan. */ diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index a38f75ca216..06fef96008f 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -800,7 +800,7 @@ interface CommercePayeeJSON { gateway_status: 'active' | 'pending' | 'restricted' | 'disconnected'; } -interface CommerceFeeJSON { +interface CommerceMoneyAmountJSON { amount: number; amount_formatted: string; currency: string; @@ -808,9 +808,9 @@ interface CommerceFeeJSON { } interface CommerceTotalsJSON { - subtotal: CommerceFeeJSON; - tax_total: CommerceFeeJSON; - grand_total: CommerceFeeJSON; + subtotal: CommerceMoneyAmountJSON; + tax_total: CommerceMoneyAmountJSON; + grand_total: CommerceMoneyAmountJSON; } export interface FeatureJSON extends ClerkResourceJSON { @@ -836,9 +836,9 @@ export interface CommercePlanJSON extends ClerkResourceJSON { is_recurring: boolean; has_base_fee: boolean; publicly_visible: boolean; - fee: CommerceFeeJSON; - annual_fee: CommerceFeeJSON; - annual_monthly_fee: CommerceFeeJSON; + fee: CommerceMoneyAmountJSON; + annual_fee: CommerceMoneyAmountJSON; + annual_monthly_fee: CommerceMoneyAmountJSON; for_payer_type: 'org' | 'user'; features: FeatureJSON[]; } @@ -847,7 +847,7 @@ export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { object: typeof ObjectType.CommerceSubscriptionItem; status: 'abandoned' | 'active' | 'canceled' | 'ended' | 'expired' | 'incomplete' | 'past_due' | 'upcoming'; credit: { - amount: CommerceFeeJSON; + amount: CommerceMoneyAmountJSON; cycle_days_remaining: number; cycle_days_total: number; cycle_remaining_percent: number; @@ -861,7 +861,7 @@ export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { lifetime_paid: number; next_payment_amount: number; next_payment_date: number; - amount: CommerceFeeJSON; + amount: CommerceMoneyAmountJSON; plan: { id: string; instance_id: string; diff --git a/packages/clerk-js/src/core/resources/CommercePayment.ts b/packages/clerk-js/src/core/resources/CommercePayment.ts index 8127acb5b23..70741f4d0d3 100644 --- a/packages/clerk-js/src/core/resources/CommercePayment.ts +++ b/packages/clerk-js/src/core/resources/CommercePayment.ts @@ -1,5 +1,5 @@ import type { - CommerceMoney, + CommerceMoneyAmount, CommercePaymentChargeType, CommercePaymentJSON, CommercePaymentResource, @@ -8,13 +8,13 @@ import type { CommerceSubscriptionItemResource, } from '@clerk/types'; -import { commerceMoneyFromJSON } from '../../utils'; +import { commerceMoneyAmountFromJSON } from '../../utils'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource, CommercePaymentSource, CommerceSubscriptionItem } from './internal'; export class CommercePayment extends BaseResource implements CommercePaymentResource { id!: string; - amount!: CommerceMoney; + amount!: CommerceMoneyAmount; failedAt?: Date; paidAt?: Date; updatedAt!: Date; @@ -38,7 +38,7 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso } this.id = data.id; - this.amount = commerceMoneyFromJSON(data.amount); + this.amount = commerceMoneyAmountFromJSON(data.amount); this.paidAt = data.paid_at ? unixEpochToDate(data.paid_at) : undefined; this.failedAt = data.failed_at ? unixEpochToDate(data.failed_at) : undefined; this.updatedAt = unixEpochToDate(data.updated_at); diff --git a/packages/clerk-js/src/core/resources/CommercePlan.ts b/packages/clerk-js/src/core/resources/CommercePlan.ts index 163e459f0a7..ba55f177c12 100644 --- a/packages/clerk-js/src/core/resources/CommercePlan.ts +++ b/packages/clerk-js/src/core/resources/CommercePlan.ts @@ -1,23 +1,20 @@ import type { + CommerceMoneyAmount, CommercePayerResourceType, CommercePlanJSON, - CommercePlanJSONSnapshot, CommercePlanResource, } from '@clerk/types'; +import { commerceMoneyAmountFromJSON } from '@/utils/commerce'; + import { BaseResource, CommerceFeature } from './internal'; export class CommercePlan extends BaseResource implements CommercePlanResource { id!: string; name!: string; - amount!: number; - amountFormatted!: string; - annualAmount!: number; - annualAmountFormatted!: string; - annualMonthlyAmount!: number; - annualMonthlyAmountFormatted!: string; - currencySymbol!: string; - currency!: string; + fee!: CommerceMoneyAmount; + annualFee!: CommerceMoneyAmount; + annualMonthlyFee!: CommerceMoneyAmount; description!: string; isDefault!: boolean; isRecurring!: boolean; @@ -42,14 +39,9 @@ export class CommercePlan extends BaseResource implements CommercePlanResource { this.id = data.id; this.name = data.name; - this.amount = data.amount; - this.amountFormatted = data.amount_formatted; - this.annualAmount = data.annual_amount; - this.annualAmountFormatted = data.annual_amount_formatted; - this.annualMonthlyAmount = data.annual_monthly_amount; - this.annualMonthlyAmountFormatted = data.annual_monthly_amount_formatted; - this.currencySymbol = data.currency_symbol; - this.currency = data.currency; + this.fee = commerceMoneyAmountFromJSON(data.fee); + this.annualFee = commerceMoneyAmountFromJSON(data.annual_fee); + this.annualMonthlyFee = commerceMoneyAmountFromJSON(data.annual_monthly_fee); this.description = data.description; this.isDefault = data.is_default; this.isRecurring = data.is_recurring; @@ -64,29 +56,4 @@ export class CommercePlan extends BaseResource implements CommercePlanResource { return this; } - - public __internal_toSnapshot(): CommercePlanJSONSnapshot { - return { - object: 'commerce_plan', - id: this.id, - name: this.name, - amount: this.amount, - amount_formatted: this.amountFormatted, - annual_amount: this.annualAmount, - annual_amount_formatted: this.annualAmountFormatted, - annual_monthly_amount: this.annualMonthlyAmount, - annual_monthly_amount_formatted: this.annualMonthlyAmountFormatted, - currency: this.currency, - currency_symbol: this.currencySymbol, - description: this.description, - is_default: this.isDefault, - is_recurring: this.isRecurring, - has_base_fee: this.hasBaseFee, - for_payer_type: this.forPayerType, - publicly_visible: this.publiclyVisible, - slug: this.slug, - avatar_url: this.avatarUrl, - features: this.features.map(feature => feature.__internal_toSnapshot()), - }; - } } diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index 1a0853ee23b..5c776c0e1d2 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -1,6 +1,6 @@ import type { CancelSubscriptionParams, - CommerceMoney, + CommerceMoneyAmount, CommerceSubscriptionItemJSON, CommerceSubscriptionItemResource, CommerceSubscriptionJSON, @@ -12,7 +12,7 @@ import type { import { unixEpochToDate } from '@/utils/date'; -import { commerceMoneyFromJSON } from '../../utils'; +import { commerceMoneyAmountFromJSON } from '../../utils'; import { BaseResource, CommercePlan, DeletedObject } from './internal'; export class CommerceSubscription extends BaseResource implements CommerceSubscriptionResource { @@ -23,7 +23,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr pastDueAt!: Date | null; updatedAt!: Date | null; nextPayment: { - amount: CommerceMoney; + amount: CommerceMoneyAmount; date: Date; } | null = null; subscriptionItems!: CommerceSubscriptionItemResource[]; @@ -47,7 +47,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null; this.nextPayment = data.next_payment ? { - amount: commerceMoneyFromJSON(data.next_payment.amount), + amount: commerceMoneyAmountFromJSON(data.next_payment.amount), date: unixEpochToDate(data.next_payment.date), } : null; @@ -69,9 +69,9 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu canceledAt!: Date | null; pastDueAt!: Date | null; //TODO(@COMMERCE): Why can this be undefined ? - amount?: CommerceMoney; + amount?: CommerceMoneyAmount; credit?: { - amount: CommerceMoney; + amount: CommerceMoneyAmount; }; isFreeTrial!: boolean; @@ -98,8 +98,9 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu this.periodEnd = data.period_end ? unixEpochToDate(data.period_end) : null; this.canceledAt = data.canceled_at ? unixEpochToDate(data.canceled_at) : null; - this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined; - this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined; + this.amount = data.amount ? commerceMoneyAmountFromJSON(data.amount) : undefined; + this.credit = + data.credit && data.credit.amount ? { amount: commerceMoneyAmountFromJSON(data.credit.amount) } : undefined; this.isFreeTrial = this.withDefault(data.is_free_trial, false); return this; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 9fb0cbb07ef..20e8e9f86cb 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,5 +1,5 @@ import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; -import type { CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; +import type { CommerceMoneyAmount, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -35,6 +35,8 @@ export const CheckoutForm = withCardStateProvider(() => { const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; + const fee = planPeriod === 'month' ? plan.fee : plan.annualMonthlyFee; + return ( { /> @@ -312,7 +314,7 @@ const ExistingPaymentSourceForm = withCardStateProvider( totalDueNow, paymentSources, }: { - totalDueNow: CommerceMoney; + totalDueNow: CommerceMoneyAmount; paymentSources: CommercePaymentSourceResource[]; }) => { const { checkout } = useCheckout(); diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index e5790a594e3..182dec622a4 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,4 +1,5 @@ import { useClerk, useOrganization } from '@clerk/shared/react'; +import type { CommerceSubscriptionItemResource } from '@clerk/types'; import useSWR from 'swr'; import { Alert } from '@/ui/elements/Alert'; @@ -157,41 +158,7 @@ export const PaymentAttemptPage = () => { {paymentAttempt.status} - ({ - padding: t.space.$4, - })} - > - {subscriptionItem && ( - - - - - - - - - - {subscriptionItem.credit && subscriptionItem.credit.amount.amount > 0 && ( - - - - - )} - - )} - + { ); }; +function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: CommerceSubscriptionItemResource | undefined }) { + if (!subscriptionItem) { + return null; + } + + const fee = + subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.fee : subscriptionItem.plan.annualMonthlyFee; + + return ( + ({ + padding: t.space.$4, + })} + > + + + + + + + + + + {subscriptionItem.credit && subscriptionItem.credit.amount.amount > 0 && ( + + + + + )} + + + ); +} + function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) { const { onCopy, hasCopied } = useClipboard(text); diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index b10f6685aaa..4a9ec723545 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -14,7 +14,7 @@ import { Avatar } from '@/ui/elements/Avatar'; import { Drawer } from '@/ui/elements/Drawer'; import { Switch } from '@/ui/elements/Switch'; -import { SubscriberTypeContext } from '../../contexts'; +import { normalizeFormatted, SubscriberTypeContext } from '../../contexts'; import { Box, Col, @@ -221,13 +221,17 @@ interface HeaderProps { const Header = React.forwardRef((props, ref) => { const { plan, closeSlot, planPeriod, setPlanPeriod } = props; - const getPlanFee = useMemo(() => { - if (plan.annualMonthlyAmount <= 0) { - return plan.amountFormatted; + const fee = useMemo(() => { + if (plan.annualMonthlyFee.amount <= 0) { + return plan.fee; } - return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; + return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee; }, [plan, planPeriod]); + const feeFormatted = React.useMemo(() => { + return normalizeFormatted(fee.amountFormatted); + }, [fee.amountFormatted]); + return ( ((props, ref) => { variant='h1' colorScheme='body' > - {plan.currencySymbol} - {getPlanFee} + {fee.currencySymbol} + {feeFormatted} ((props, ref) => { - {plan.annualMonthlyAmount > 0 ? ( + {plan.annualMonthlyFee.amount > 0 ? ( ({ diff --git a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx index b8328caded2..9810e552f1d 100644 --- a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx @@ -32,17 +32,27 @@ describe('PlanDetails', () => { const mockPlan = { id: 'plan_123', name: 'Test Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 10000, - annualAmountFormatted: '100.00', - annualMonthlyAmount: 833, - annualMonthlyAmountFormatted: '8.33', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 10000, + amountFormatted: '100.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 833, + amountFormatted: '8.33', + currencySymbol: '$', + currency: 'USD', + }, description: 'Test Plan Description', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], forPayerType: 'user' as const, @@ -96,7 +106,7 @@ describe('PlanDetails', () => { expect(spinner).toBeNull(); expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible(); expect(getByText('Test Plan Description')).toBeVisible(); - expect(getByText('$10.00')).toBeVisible(); + expect(getByText('$10')).toBeVisible(); expect(getByText('Feature 1')).toBeVisible(); expect(getByText('Feature 1 Description')).toBeVisible(); expect(getByText('Feature 2')).toBeVisible(); @@ -124,7 +134,7 @@ describe('PlanDetails', () => { expect(fixtures.clerk.billing.getPlan).toHaveBeenCalledWith({ id: 'plan_123' }); expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible(); expect(getByText('Test Plan Description')).toBeVisible(); - expect(getByText('$10.00')).toBeVisible(); + expect(getByText('$10')).toBeVisible(); }); }); @@ -144,7 +154,7 @@ describe('PlanDetails', () => { ); await waitFor(() => { - expect(getByText('$10.00')).toBeVisible(); + expect(getByText('$10')).toBeVisible(); expect(queryByText('$8.33')).toBeNull(); }); }); @@ -169,7 +179,7 @@ describe('PlanDetails', () => { await waitFor(() => { expect(getByText('$8.33')).toBeVisible(); - expect(queryByText('$10.00')).toBeNull(); + expect(queryByText('$10')).toBeNull(); }); }); @@ -189,7 +199,7 @@ describe('PlanDetails', () => { ); await waitFor(() => { - expect(getByText('$10.00')).toBeVisible(); + expect(getByText('$10')).toBeVisible(); }); const switchButton = getByRole('switch', { name: /billed annually/i }); @@ -203,7 +213,24 @@ describe('PlanDetails', () => { it('does not show period toggle for plans with no annual pricing', async () => { const planWithoutAnnual = { ...mockPlan, - annualMonthlyAmount: 0, + fee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, }; const { wrapper } = await createFixtures(f => { @@ -229,9 +256,25 @@ describe('PlanDetails', () => { it('shows "Always free" notice for default free plans', async () => { const freePlan = { ...mockPlan, - amount: 0, - amountFormatted: '0.00', - annualMonthlyAmount: 0, + + fee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, isDefault: true, }; diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index a810c41394e..37c8d2f8786 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -7,7 +7,7 @@ import { Tooltip } from '@/ui/elements/Tooltip'; import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox'; import { useProtect } from '../../common'; -import { usePlansContext, usePricingTableContext, useSubscriberTypeContext } from '../../contexts'; +import { normalizeFormatted, usePlansContext, usePricingTableContext, useSubscriberTypeContext } from '../../contexts'; import { Box, Button, @@ -144,7 +144,7 @@ function Card(props: CardProps) { if (subscription.canceledAt) { shouldShowFooter = true; shouldShowFooterNotice = false; - } else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyAmount > 0) { + } else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0) { shouldShowFooter = true; shouldShowFooterNotice = false; } else if (plan.freeTrialEnabled && subscription.isFreeTrial) { @@ -295,14 +295,20 @@ interface CardHeaderProps { const CardHeader = React.forwardRef((props, ref) => { const { plan, isCompact, planPeriod, setPlanPeriod, badge } = props; - const { name, annualMonthlyAmount } = plan; + const { name, annualMonthlyFee } = plan; - const getPlanFee = React.useMemo(() => { - if (annualMonthlyAmount <= 0) { - return plan.amountFormatted; + const planSupportsAnnual = annualMonthlyFee.amount > 0; + + const fee = React.useMemo(() => { + if (!planSupportsAnnual) { + return plan.fee; } - return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; - }, [annualMonthlyAmount, planPeriod, plan.amountFormatted, plan.annualMonthlyAmountFormatted]); + return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee; + }, [planSupportsAnnual, planPeriod, plan.fee, plan.annualMonthlyFee]); + + const feeFormatted = React.useMemo(() => { + return normalizeFormatted(fee.amountFormatted); + }, [fee.amountFormatted]); return ( ((props, ref variant={isCompact ? 'h2' : 'h1'} colorScheme='body' > - {plan.currencySymbol} - {getPlanFee} + {fee.currencySymbol} + {feeFormatted} {!plan.isDefault ? ( ((props, ref ) : null} - {annualMonthlyAmount > 0 && setPlanPeriod ? ( + {planSupportsAnnual && setPlanPeriod ? ( ({ diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx index 16169a43d0a..7255c98744e 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx @@ -60,7 +60,7 @@ export function PricingTableMatrix({ const gridTemplateColumns = React.useMemo(() => `repeat(${plans.length + 1}, minmax(9.375rem,1fr))`, [plans.length]); - const renderBillingCycleControls = React.useMemo(() => plans.some(plan => plan.annualMonthlyAmount > 0), [plans]); + const renderBillingCycleControls = React.useMemo(() => plans.some(plan => plan.annualMonthlyFee.amount > 0), [plans]); const getAllFeatures = React.useMemo(() => { const featuresSet = new Set(); @@ -157,11 +157,11 @@ export function PricingTableMatrix({ {plans.map(plan => { const highlight = plan.slug === highlightedPlan; const planFee = - plan.annualMonthlyAmount <= 0 - ? plan.amountFormatted + plan.annualMonthlyFee.amount <= 0 + ? plan.fee : planPeriod === 'annual' - ? plan.annualMonthlyAmountFormatted - : plan.amountFormatted; + ? plan.annualMonthlyFee + : plan.fee; return ( - {plan.currencySymbol} - {planFee} + {planFee.currencySymbol} + {planFee.amountFormatted} - {plan.annualMonthlyAmount > 0 ? ( + {plan.annualMonthlyFee.amount > 0 ? ( { const trialPlan = { id: 'plan_trial', name: 'Pro', - amount: 2000, - amountFormatted: '20.00', - annualAmount: 20000, - annualAmountFormatted: '200.00', - annualMonthlyAmount: 1667, - annualMonthlyAmountFormatted: '16.67', - currencySymbol: '$', + fee: { + amount: 2000, + amountFormatted: '20.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 20000, + amountFormatted: '200.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 1667, + amountFormatted: '16.67', + currencySymbol: '$', + currency: 'USD', + }, description: 'Pro plan with trial', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, forPayerType: 'user', publiclyVisible: true, diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index 143ef4e22e4..188c1d7e2fe 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -54,17 +54,27 @@ describe('SubscriptionDetails', () => { plan: { id: 'plan_123', name: 'Test Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 10000, - annualAmountFormatted: '100.00', - annualMonthlyAmount: 8333, - annualMonthlyAmountFormatted: '83.33', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 10000, + amountFormatted: '100.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 8333, + amountFormatted: '83.33', + currencySymbol: '$', + currency: 'USD', + }, description: 'Test Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, }, createdAt: new Date('2021-01-01'), @@ -116,7 +126,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to annual $100.00 / year')).toBeVisible(); + expect(getByText('Switch to annual $100 / year')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -147,17 +157,27 @@ describe('SubscriptionDetails', () => { plan: { id: 'plan_123', name: 'Test Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 10000, - annualAmountFormatted: '100.00', - annualMonthlyAmount: 8333, - annualMonthlyAmountFormatted: '83.33', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 10000, + amountFormatted: '100.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 8333, + amountFormatted: '83.33', + currencySymbol: '$', + currency: 'USD', + }, description: 'Test Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, }, createdAt: new Date('2021-01-01'), @@ -209,7 +229,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to monthly $10.00 / month')).toBeVisible(); + expect(getByText('Switch to monthly $10 / month')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -232,17 +252,27 @@ describe('SubscriptionDetails', () => { plan: { id: 'plan_123', name: 'Free Plan', - amount: 0, - amountFormatted: '0.00', - annualAmount: 0, - annualAmountFormatted: '0.00', - annualMonthlyAmount: 0, - annualMonthlyAmountFormatted: '0.00', - currencySymbol: '$', + fee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, description: 'Free Plan description', hasBaseFee: false, isRecurring: true, - currency: 'USD', isDefault: true, }, createdAt: new Date('2021-01-01'), @@ -294,17 +324,27 @@ describe('SubscriptionDetails', () => { const planAnnual = { id: 'plan_annual', name: 'Annual Plan', - amount: 1300, - amountFormatted: '13.00', - annualAmount: 12000, - annualAmountFormatted: '120.00', - annualMonthlyAmount: 1000, - annualMonthlyAmountFormatted: '10.00', - currencySymbol: '$', + fee: { + amount: 1300, + amountFormatted: '13.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 12000, + amountFormatted: '120.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, description: 'Annual Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], publiclyVisible: true, @@ -315,17 +355,27 @@ describe('SubscriptionDetails', () => { const planMonthly = { id: 'plan_monthly', name: 'Monthly Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 9000, - annualAmountFormatted: '90.00', - annualMonthlyAmount: 750, - annualMonthlyAmountFormatted: '7.50', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 9099, + amountFormatted: '90.99', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 750, + amountFormatted: '7.50', + currencySymbol: '$', + currency: 'USD', + }, description: 'Monthly Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], publiclyVisible: true, @@ -412,7 +462,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to monthly $13.00 / month')).toBeVisible(); + expect(getByText('Switch to monthly $13 / month')).toBeVisible(); expect(getByText('Resubscribe')).toBeVisible(); expect(queryByText('Cancel subscription')).toBeNull(); }); @@ -420,7 +470,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(upcomingMenuButton); await waitFor(() => { - expect(getByText('Switch to annual $90.00 / year')).toBeVisible(); + expect(getByText('Switch to annual $90.99 / year')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -433,17 +483,28 @@ describe('SubscriptionDetails', () => { const planMonthly = { id: 'plan_monthly', name: 'Monthly Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 9000, - annualAmountFormatted: '90.00', - annualMonthlyAmount: 750, - annualMonthlyAmountFormatted: '7.50', - currencySymbol: '$', + + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 9000, + amountFormatted: '90.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 750, + amountFormatted: '7.50', + currencySymbol: '$', + currency: 'USD', + }, description: 'Monthly Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], publiclyVisible: true, @@ -455,17 +516,27 @@ describe('SubscriptionDetails', () => { const planFreeUpcoming = { id: 'plan_free_upcoming', name: 'Free Plan (Upcoming)', - amount: 0, - amountFormatted: '0.00', - annualAmount: 0, - annualAmountFormatted: '0.00', - annualMonthlyAmount: 0, - annualMonthlyAmountFormatted: '0.00', - currencySymbol: '$', + fee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, description: 'Upcoming Free Plan', hasBaseFee: false, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], publiclyVisible: true, @@ -568,17 +639,27 @@ describe('SubscriptionDetails', () => { plan: { id: 'plan_123', name: 'Monthly Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 10000, - annualAmountFormatted: '100.00', - annualMonthlyAmount: 8333, - annualMonthlyAmountFormatted: '83.33', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 10000, + amountFormatted: '100.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 8333, + amountFormatted: '83.33', + currencySymbol: '$', + currency: 'USD', + }, description: 'Monthly Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, }, createdAt: new Date('2021-01-01'), @@ -642,13 +723,25 @@ describe('SubscriptionDetails', () => { const plan = { id: 'plan_annual', name: 'Annual Plan', - amount: 12000, - amountFormatted: '120.00', - annualAmount: 12000, - annualAmountFormatted: '120.00', - annualMonthlyAmount: 1000, - annualMonthlyAmountFormatted: '10.00', - currencySymbol: '$', + + fee: { + amount: 12000, + amountFormatted: '120.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 12000, + amountFormatted: '120.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, description: 'Annual Plan', hasBaseFee: true, isRecurring: true, @@ -732,13 +825,26 @@ describe('SubscriptionDetails', () => { const plan = { id: 'plan_annual', name: 'Annual Plan', - amount: 12000, - amountFormatted: '120.00', - annualAmount: 12000, - annualAmountFormatted: '120.00', - annualMonthlyAmount: 1000, - annualMonthlyAmountFormatted: '10.00', - currencySymbol: '$', + + fee: { + amount: 12000, + amountFormatted: '120.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 12000, + amountFormatted: '120.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + description: 'Annual Plan', hasBaseFee: true, isRecurring: true, @@ -824,17 +930,27 @@ describe('SubscriptionDetails', () => { const plan = { id: 'plan_monthly', name: 'Monthly Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 9000, - annualAmountFormatted: '90.00', - annualMonthlyAmount: 750, - annualMonthlyAmountFormatted: '7.50', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 9000, + amountFormatted: '90.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 750, + amountFormatted: '7.50', + currencySymbol: '$', + currency: 'USD', + }, description: 'Monthly Plan', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, payerType: ['user'], publiclyVisible: true, diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 125e80c462c..318c14b50ec 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -22,7 +22,13 @@ import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; import { formatDate } from '@/ui/utils/formatDate'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscription } from '../../contexts'; +import { + normalizeFormatted, + SubscriberTypeContext, + usePlansContext, + useSubscriberTypeContext, + useSubscription, +} from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Button, @@ -335,7 +341,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc const canManageBilling = subscriberType === 'user' || canOrgManageBilling; const isSwitchable = - ((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || + ((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyFee.amount > 0) || subscription.planPeriod === 'annual') && subscription.status !== 'past_due'; const isFree = isFreePlan(subscription.plan); @@ -370,12 +376,12 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc label: subscription.planPeriod === 'month' ? localizationKeys('commerce.switchToAnnualWithAnnualPrice', { - price: subscription.plan.annualAmountFormatted, - currency: subscription.plan.currencySymbol, + price: normalizeFormatted(subscription.plan.annualFee.amountFormatted), + currency: subscription.plan.annualFee.currencySymbol, }) : localizationKeys('commerce.switchToMonthlyWithPrice', { - price: subscription.plan.amountFormatted, - currency: subscription.plan.currencySymbol, + price: normalizeFormatted(subscription.plan.fee.amountFormatted), + currency: subscription.plan.fee.currencySymbol, }), onClick: () => { openCheckout({ @@ -437,6 +443,8 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionItemResource }) => { const { t } = useLocalizations(); + const fee = subscription.planPeriod === 'month' ? subscription.plan.fee : subscription.plan.annualFee; + return ( - {subscription.planPeriod === 'month' - ? `${subscription.plan.currencySymbol}${subscription.plan.amountFormatted} / ${t(localizationKeys('commerce.month'))}` - : `${subscription.plan.currencySymbol}${subscription.plan.annualAmountFormatted} / ${t(localizationKeys('commerce.year'))}`} + {fee.currencySymbol} + {fee.amountFormatted} /{' '} + {t(localizationKeys(`commerce.${subscription.planPeriod === 'month' ? 'month' : 'year'}`))} diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index 7db9432768d..d2775162d2c 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -1,9 +1,11 @@ +import type { CommerceSubscriptionItemResource } from '@clerk/types'; import { useMemo } from 'react'; import { ProfileSection } from '@/ui/elements/Section'; import { useProtect } from '../../common'; import { + normalizeFormatted, useEnvironment, usePlansContext, useSubscriberTypeContext, @@ -39,13 +41,9 @@ export function SubscriptionsList({ arrowButtonText: LocalizationKey; arrowButtonEmptyText: LocalizationKey; }) { - const { captionForSubscription, openSubscriptionDetails } = usePlansContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const subscriberType = useSubscriberTypeContext(); const { subscriptionItems } = useSubscription(); - const canManageBilling = useProtect( - has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', - ); const { navigate } = useRouter(); const { commerceSettings } = useEnvironment(); @@ -99,98 +97,11 @@ export function SubscriptionsList({ {sortedSubscriptions.map(subscription => ( - - - - - ({ - width: t.sizes.$4, - height: t.sizes.$4, - opacity: t.opacity.$inactive, - })} - /> - ({ marginRight: t.sizes.$1 })} - > - {subscription.plan.name} - - {sortedSubscriptions.length > 1 || !!subscription.canceledAt ? ( - - ) : null} - - - {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( - // here - - )} - - - ({ - textAlign: 'right', - })} - > - - {subscription.plan.currencySymbol} - {subscription.planPeriod === 'annual' - ? subscription.plan.annualAmountFormatted - : subscription.plan.amountFormatted} - {(subscription.plan.amount > 0 || subscription.plan.annualAmount > 0) && ( - ({ - color: t.colors.$colorMutedForeground, - textTransform: 'lowercase', - ':before': { - content: '"/"', - marginInline: t.space.$1, - }, - })} - localizationKey={ - subscription.planPeriod === 'annual' - ? localizationKeys('commerce.year') - : localizationKeys('commerce.month') - } - /> - )} - - - ({ - textAlign: 'right', - })} - > - - - + ))} @@ -218,3 +129,105 @@ export function SubscriptionsList({ ); } + +function SubscriptionRow({ subscription, length }: { subscription: CommerceSubscriptionItemResource; length: number }) { + const subscriberType = useSubscriberTypeContext(); + const canManageBilling = + useProtect(has => has({ permission: 'org:sys_billing:manage' })) || subscriberType === 'user'; + const fee = subscription.planPeriod === 'annual' ? subscription.plan.annualFee : subscription.plan.fee; + const { captionForSubscription, openSubscriptionDetails } = usePlansContext(); + + const feeFormatted = useMemo(() => { + return normalizeFormatted(fee.amountFormatted); + }, [fee.amountFormatted]); + return ( + + + + + ({ + width: t.sizes.$4, + height: t.sizes.$4, + opacity: t.opacity.$inactive, + })} + /> + ({ marginRight: t.sizes.$1 })} + > + {subscription.plan.name} + + {length > 1 || subscription.canceledAt !== null ? : null} + + + {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( + // here + + )} + + + ({ + textAlign: 'right', + })} + > + + {fee.currencySymbol} + {feeFormatted} + {fee.amount > 0 && ( + ({ + color: t.colors.$colorMutedForeground, + textTransform: 'lowercase', + ':before': { + content: '"/"', + marginInline: t.space.$1, + }, + })} + localizationKey={ + subscription.planPeriod === 'annual' + ? localizationKeys('commerce.year') + : localizationKeys('commerce.month') + } + /> + )} + + + ({ + textAlign: 'right', + })} + > + + + + ); +} diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 7c3b22bd99e..6f36256bad9 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -21,6 +21,13 @@ import type { LocalizationKey } from '../../localization'; import { localizationKeys } from '../../localization'; import { useSubscriberTypeContext } from './SubscriberType'; +/** + * Only remove decimal places if they are '00', to match previous behavior. + */ +export function normalizeFormatted(formatted: string) { + return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; +} + // TODO(@COMMERCE): Rename payment sources to payment methods at the API level export const usePaymentMethods = () => { const subscriberType = useSubscriberTypeContext(); @@ -206,12 +213,12 @@ export const usePlansContext = () => { const subscription = sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined); let _selectedPlanPeriod = selectedPlanPeriod; - if (_selectedPlanPeriod === 'annual' && sub?.plan.annualMonthlyAmount === 0) { + const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0; + + if (_selectedPlanPeriod === 'annual' && !isEligibleForSwitchToAnnual) { _selectedPlanPeriod = 'month'; } - const isEligibleForSwitchToAnnual = (plan?.annualMonthlyAmount ?? 0) > 0; - const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => { if (plan?.freeTrialEnabled) { // Show trial CTA if user is signed out OR if signed in and eligible for free trial @@ -317,7 +324,7 @@ export const usePlansContext = () => { clerk.__internal_openCheckout({ planId: plan.id, // if the plan doesn't support annual, use monthly - planPeriod: planPeriod === 'annual' && plan.annualMonthlyAmount === 0 ? 'month' : planPeriod, + planPeriod: planPeriod === 'annual' && plan.annualMonthlyFee.amount === 0 ? 'month' : planPeriod, for: subscriberType, onSubscriptionComplete: () => { revalidateAll(); diff --git a/packages/clerk-js/src/utils/commerce.ts b/packages/clerk-js/src/utils/commerce.ts index 1430f752b34..a705fa465c2 100644 --- a/packages/clerk-js/src/utils/commerce.ts +++ b/packages/clerk-js/src/utils/commerce.ts @@ -1,13 +1,13 @@ import type { CommerceCheckoutTotals, CommerceCheckoutTotalsJSON, - CommerceMoney, - CommerceMoneyJSON, + CommerceMoneyAmount, + CommerceMoneyAmountJSON, CommerceStatementTotals, CommerceStatementTotalsJSON, } from '@clerk/types'; -export const commerceMoneyFromJSON = (data: CommerceMoneyJSON): CommerceMoney => { +export const commerceMoneyAmountFromJSON = (data: CommerceMoneyAmountJSON): CommerceMoneyAmount => { return { amount: data.amount, amountFormatted: data.amount_formatted, @@ -16,24 +16,30 @@ export const commerceMoneyFromJSON = (data: CommerceMoneyJSON): CommerceMoney => }; }; -export const commerceTotalsFromJSON = (data: T) => { - const totals = { - grandTotal: commerceMoneyFromJSON(data.grand_total), - subtotal: commerceMoneyFromJSON(data.subtotal), - taxTotal: commerceMoneyFromJSON(data.tax_total), +const hasPastDue = (data: unknown): data is { past_due: CommerceMoneyAmountJSON } => { + return typeof data === 'object' && data !== null && 'past_due' in data; +}; + +export const commerceTotalsFromJSON = ( + data: T, +): T extends { total_due_now: CommerceMoneyAmountJSON } ? CommerceCheckoutTotals : CommerceStatementTotals => { + const totals: Partial = { + grandTotal: commerceMoneyAmountFromJSON(data.grand_total), + subtotal: commerceMoneyAmountFromJSON(data.subtotal), + taxTotal: commerceMoneyAmountFromJSON(data.tax_total), }; + if ('total_due_now' in data) { - // @ts-ignore - totals['totalDueNow'] = commerceMoneyFromJSON(data.total_due_now); + totals.totalDueNow = commerceMoneyAmountFromJSON(data.total_due_now); } if ('credit' in data) { - // @ts-ignore - totals['credit'] = commerceMoneyFromJSON(data.credit); + totals.credit = commerceMoneyAmountFromJSON(data.credit); } - if ('past_due' in data) { - // @ts-ignore - totals['pastDue'] = commerceMoneyFromJSON(data.past_due); + if (hasPastDue(data)) { + totals.pastDue = commerceMoneyAmountFromJSON(data.past_due); } - return totals as T extends { total_due_now: CommerceMoneyJSON } ? CommerceCheckoutTotals : CommerceStatementTotals; + return totals as T extends { total_due_now: CommerceMoneyAmountJSON } + ? CommerceCheckoutTotals + : CommerceStatementTotals; }; diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx index c722dee2fe8..f9e0b182a15 100644 --- a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -31,17 +31,27 @@ vi.mock('../withClerk', () => { const mockPlanResource: CommercePlanResource = { id: 'plan_123', name: 'Test Plan', - amount: 1000, - amountFormatted: '10.00', - annualAmount: 10000, - annualAmountFormatted: '100.00', - annualMonthlyAmount: 833, - annualMonthlyAmountFormatted: '8.33', - currencySymbol: '$', + fee: { + amount: 1000, + amountFormatted: '10.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: 833, + amountFormatted: '8.33', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: 10000, + amountFormatted: '100.00', + currencySymbol: '$', + currency: 'USD', + }, description: 'Test Plan Description', hasBaseFee: true, isRecurring: true, - currency: 'USD', isDefault: false, forPayerType: 'user' as CommercePayerResourceType, publiclyVisible: true, @@ -50,7 +60,6 @@ const mockPlanResource: CommercePlanResource = { freeTrialDays: 0, freeTrialEnabled: false, features: [], - __internal_toSnapshot: vi.fn(), pathRoot: '', reload: vi.fn(), }; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index c0389262fcb..ea240964533 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -1,7 +1,7 @@ import type { DeletedObjectResource } from './deletedObject'; import type { ClerkPaginatedResponse, ClerkPaginationParams } from './pagination'; import type { ClerkResource } from './resource'; -import type { CommerceFeatureJSONSnapshot, CommercePlanJSONSnapshot } from './snapshots'; +import type { CommerceFeatureJSONSnapshot } from './snapshots'; type WithOptionalOrgType = T & { orgId?: string; @@ -287,43 +287,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - amount: number; - /** - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. - * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. - * @example - * ```tsx - * - * ``` - */ - amountFormatted: string; - /** - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. - * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. - * @example - * ```tsx - * - * ``` - */ - annualAmount: number; - /** - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. - * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. - * @example - * ```tsx - * - * ``` - */ - annualAmountFormatted: string; - /** - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. - * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. - * @example - * ```tsx - * - * ``` - */ - annualMonthlyAmount: number; + fee: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -332,7 +296,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - annualMonthlyAmountFormatted: string; + annualFee: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -341,16 +305,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - currencySymbol: string; - /** - * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. - * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. - * @example - * ```tsx - * - * ``` - */ - currency: string; + annualMonthlyFee: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -455,7 +410,6 @@ export interface CommercePlanResource extends ClerkResource { * ``` */ freeTrialEnabled: boolean; - __internal_toSnapshot: () => CommercePlanJSONSnapshot; } /** @@ -767,7 +721,7 @@ export interface CommercePaymentResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1083,7 +1037,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount?: CommerceMoney; + amount?: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1101,7 +1055,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceMoneyAmount; }; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. @@ -1176,7 +1130,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1250,7 +1204,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ -export interface CommerceMoney { +export interface CommerceMoneyAmount { /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1306,7 +1260,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - subtotal: CommerceMoney; + subtotal: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1315,7 +1269,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - grandTotal: CommerceMoney; + grandTotal: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1324,7 +1278,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - taxTotal: CommerceMoney; + taxTotal: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1333,7 +1287,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - totalDueNow: CommerceMoney; + totalDueNow: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1342,7 +1296,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - credit: CommerceMoney; + credit: CommerceMoneyAmount; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -1351,7 +1305,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - pastDue: CommerceMoney; + pastDue: CommerceMoneyAmount; } /** diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index df2c8aa88dd..7344a8b0d02 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -632,6 +632,9 @@ export interface CommercePlanJSON extends ClerkResourceJSON { object: 'commerce_plan'; id: string; name: string; + fee: CommerceMoneyAmountJSON; + annual_fee: CommerceMoneyAmountJSON; + annual_monthly_fee: CommerceMoneyAmountJSON; amount: number; amount_formatted: string; annual_amount: number; @@ -747,7 +750,7 @@ export interface CommerceStatementGroupJSON extends ClerkResourceJSON { export interface CommercePaymentJSON extends ClerkResourceJSON { object: 'commerce_payment'; id: string; - amount: CommerceMoneyJSON; + amount: CommerceMoneyAmountJSON; paid_at?: number; failed_at?: number; updated_at: number; @@ -769,9 +772,9 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { object: 'commerce_subscription_item'; id: string; - amount?: CommerceMoneyJSON; + amount?: CommerceMoneyAmountJSON; credit?: { - amount: CommerceMoneyJSON; + amount: CommerceMoneyAmountJSON; }; payment_source_id: string; plan: CommercePlanJSON; @@ -804,7 +807,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { * Describes the details for the next payment cycle. It is `undefined` for subscription items that are cancelled or on the free plan. */ next_payment?: { - amount: CommerceMoneyJSON; + amount: CommerceMoneyAmountJSON; date: number; }; /** @@ -827,7 +830,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { * * ``` */ -export interface CommerceMoneyJSON { +export interface CommerceMoneyAmountJSON { amount: number; amount_formatted: string; currency: string; @@ -843,11 +846,11 @@ export interface CommerceMoneyJSON { * ``` */ export interface CommerceCheckoutTotalsJSON { - grand_total: CommerceMoneyJSON; - subtotal: CommerceMoneyJSON; - tax_total: CommerceMoneyJSON; - total_due_now: CommerceMoneyJSON; - credit: CommerceMoneyJSON; + grand_total: CommerceMoneyAmountJSON; + subtotal: CommerceMoneyAmountJSON; + tax_total: CommerceMoneyAmountJSON; + total_due_now: CommerceMoneyAmountJSON; + credit: CommerceMoneyAmountJSON; } /**