From d7daf963264f1ebb57d85740bf43fd297271b5f4 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 8 Aug 2025 13:15:19 +0300 Subject: [PATCH 1/9] chore(clerk-js,types): Switch to fees for plan prices --- .changeset/rich-drinks-ring.md | 6 + .../src/core/resources/CommercePayment.ts | 8 +- .../src/core/resources/CommercePlan.ts | 56 +--- .../core/resources/CommerceSubscription.ts | 16 +- .../ui/components/Checkout/CheckoutForm.tsx | 14 +- .../PaymentAttempts/PaymentAttemptPage.tsx | 82 ++--- .../src/ui/components/Plans/PlanDetails.tsx | 25 +- .../Plans/__tests__/PlanDetails.test.tsx | 77 ++++- .../PricingTable/PricingTableDefault.tsx | 33 +- .../PricingTable/PricingTableMatrix.tsx | 16 +- .../__tests__/SubscriptionDetails.test.tsx | 296 ++++++++++++------ .../components/SubscriptionDetails/index.tsx | 24 +- .../Subscriptions/SubscriptionsList.tsx | 211 +++++++------ .../src/ui/contexts/components/Plans.tsx | 6 +- packages/clerk-js/src/utils/commerce.ts | 20 +- packages/types/src/commerce.ts | 76 +---- packages/types/src/json.ts | 23 +- 17 files changed, 566 insertions(+), 423 deletions(-) create mode 100644 .changeset/rich-drinks-ring.md 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/packages/clerk-js/src/core/resources/CommercePayment.ts b/packages/clerk-js/src/core/resources/CommercePayment.ts index 8127acb5b23..d801ab8ca16 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, + CommerceFee, CommercePaymentChargeType, CommercePaymentJSON, CommercePaymentResource, @@ -8,13 +8,13 @@ import type { CommerceSubscriptionItemResource, } from '@clerk/types'; -import { commerceMoneyFromJSON } from '../../utils'; +import { commerceFeeFromJSON } 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!: CommerceFee; 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 = commerceFeeFromJSON(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 77b7477cd26..e49926d5146 100644 --- a/packages/clerk-js/src/core/resources/CommercePlan.ts +++ b/packages/clerk-js/src/core/resources/CommercePlan.ts @@ -1,23 +1,15 @@ -import type { - CommercePayerResourceType, - CommercePlanJSON, - CommercePlanJSONSnapshot, - CommercePlanResource, -} from '@clerk/types'; +import type { CommerceFee, CommercePayerResourceType, CommercePlanJSON, CommercePlanResource } from '@clerk/types'; + +import { commerceFeeFromJSON } 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!: CommerceFee; + annualFee!: CommerceFee; + annualMonthlyFee!: CommerceFee; description!: string; isDefault!: boolean; isRecurring!: boolean; @@ -40,14 +32,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 = commerceFeeFromJSON(data.fee); + this.annualFee = commerceFeeFromJSON(data.annual_fee); + this.annualMonthlyFee = commerceFeeFromJSON(data.annual_monthly_fee); this.description = data.description; this.isDefault = data.is_default; this.isRecurring = data.is_recurring; @@ -60,29 +47,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 4c852f1d353..fe804461dce 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, + CommerceFee, CommerceSubscriptionItemJSON, CommerceSubscriptionItemResource, CommerceSubscriptionJSON, @@ -12,7 +12,7 @@ import type { import { unixEpochToDate } from '@/utils/date'; -import { commerceMoneyFromJSON } from '../../utils'; +import { commerceFeeFromJSON } 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: CommerceFee; date: Date; } | null = null; subscriptionItems!: CommerceSubscriptionItemResource[]; @@ -46,7 +46,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: commerceFeeFromJSON(data.next_payment.amount), date: unixEpochToDate(data.next_payment.date), } : null; @@ -70,9 +70,9 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu periodEnd!: number; canceledAt!: number | null; //TODO(@COMMERCE): Why can this be undefined ? - amount?: CommerceMoney; + amount?: CommerceFee; credit?: { - amount: CommerceMoney; + amount: CommerceFee; }; constructor(data: CommerceSubscriptionItemJSON) { @@ -101,8 +101,8 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null; this.canceledAtDate = 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 ? commerceFeeFromJSON(data.amount) : undefined; + this.credit = data.credit && data.credit.amount ? { amount: commerceFeeFromJSON(data.credit.amount) } : undefined; 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..e0cba8cb804 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 { CommerceFee, 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 ( { /> @@ -308,13 +310,7 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { }); const ExistingPaymentSourceForm = withCardStateProvider( - ({ - totalDueNow, - paymentSources, - }: { - totalDueNow: CommerceMoney; - paymentSources: CommercePaymentSourceResource[]; - }) => { + ({ totalDueNow, paymentSources }: { totalDueNow: CommerceFee; paymentSources: CommercePaymentSourceResource[] }) => { const { checkout } = useCheckout(); const { paymentSource } = checkout; 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..8caae5a4e37 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -218,16 +218,27 @@ interface HeaderProps { closeSlot?: React.ReactNode; } +/** + * Only remove decimal places if they are '00', to match previous behavior. + */ +function normalizeFormatted(formatted: string) { + return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; +} + 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 9d2ea35b194..bfbbcebce14 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -144,7 +144,7 @@ function Card(props: CardProps) { if (subscription.canceledAtDate) { 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 { @@ -286,16 +286,29 @@ interface CardHeaderProps { badge?: React.ReactNode; } +/** + * Only remove decimal places if they are '00', to match previous behavior. + */ +function normalizeFormatted(formatted: string) { + return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; +} + 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 ? ( { 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 70a947b2b43..aa99c14ede2 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -324,6 +324,12 @@ function SubscriptionDetailsSummary() { ); } +/** + * Only remove decimal places if they are '00', to match previous behavior. + */ +function normalizeFormatted(formatted: string) { + return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; +} const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionItemResource }) => { const { portalRoot } = useSubscriptionDetailsContext(); const { __internal_openCheckout } = useClerk(); @@ -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 fbb3e77a6f4..c24ef38ee4a 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -1,3 +1,4 @@ +import type { CommerceSubscriptionItemResource } from '@clerk/types'; import { useMemo } from 'react'; import { ProfileSection } from '@/ui/elements/Section'; @@ -39,13 +40,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 +96,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.canceledAtDate ? ( - - ) : 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 +128,112 @@ export function SubscriptionsList({ ); } + +/** + * Only remove decimal places if they are '00', to match previous behavior. + */ +function normalizeFormatted(formatted: string) { + return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; +} + +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.canceledAtDate ? : 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 a18b7995877..d1cbdfeea6d 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -205,11 +205,11 @@ export const usePlansContext = () => { const subscription = sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined); let _selectedPlanPeriod = selectedPlanPeriod; - if (_selectedPlanPeriod === 'annual' && sub?.plan.annualMonthlyAmount === 0) { + if (_selectedPlanPeriod === 'annual' && sub?.plan.annualMonthlyFee.amount === 0) { _selectedPlanPeriod = 'month'; } - const isEligibleForSwitchToAnnual = (plan?.annualMonthlyAmount ?? 0) > 0; + const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0; const getLocalizationKey = () => { // Handle subscription cases @@ -302,7 +302,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..dde1e6bf6d3 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, + CommerceFee, + CommerceFeeJSON, CommerceStatementTotals, CommerceStatementTotalsJSON, } from '@clerk/types'; -export const commerceMoneyFromJSON = (data: CommerceMoneyJSON): CommerceMoney => { +export const commerceFeeFromJSON = (data: CommerceFeeJSON): CommerceFee => { return { amount: data.amount, amountFormatted: data.amount_formatted, @@ -18,22 +18,22 @@ 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), + grandTotal: commerceFeeFromJSON(data.grand_total), + subtotal: commerceFeeFromJSON(data.subtotal), + taxTotal: commerceFeeFromJSON(data.tax_total), }; if ('total_due_now' in data) { // @ts-ignore - totals['totalDueNow'] = commerceMoneyFromJSON(data.total_due_now); + totals['totalDueNow'] = commerceFeeFromJSON(data.total_due_now); } if ('credit' in data) { // @ts-ignore - totals['credit'] = commerceMoneyFromJSON(data.credit); + totals['credit'] = commerceFeeFromJSON(data.credit); } if ('past_due' in data) { // @ts-ignore - totals['pastDue'] = commerceMoneyFromJSON(data.past_due); + totals['pastDue'] = commerceFeeFromJSON(data.past_due); } - return totals as T extends { total_due_now: CommerceMoneyJSON } ? CommerceCheckoutTotals : CommerceStatementTotals; + return totals as T extends { total_due_now: CommerceFeeJSON } ? CommerceCheckoutTotals : CommerceStatementTotals; }; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 3ae559e3c23..c8bd663a408 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: CommerceFee; /** * @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: CommerceFee; /** * @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: CommerceFee; /** * @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. @@ -437,7 +392,6 @@ export interface CommercePlanResource extends ClerkResource { * ``` */ features: CommerceFeatureResource[]; - __internal_toSnapshot: () => CommercePlanJSONSnapshot; } /** @@ -749,7 +703,7 @@ export interface CommercePaymentResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceFee; /** * @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. @@ -1077,7 +1031,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount?: CommerceMoney; + amount?: CommerceFee; /** * @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. @@ -1095,7 +1049,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceFee; }; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. @@ -1161,7 +1115,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ - amount: CommerceMoney; + amount: CommerceFee; /** * @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. @@ -1225,7 +1179,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ -export interface CommerceMoney { +export interface CommerceFee { /** * @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. @@ -1281,7 +1235,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - subtotal: CommerceMoney; + subtotal: CommerceFee; /** * @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. @@ -1290,7 +1244,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - grandTotal: CommerceMoney; + grandTotal: CommerceFee; /** * @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. @@ -1299,7 +1253,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - taxTotal: CommerceMoney; + taxTotal: CommerceFee; /** * @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. @@ -1308,7 +1262,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - totalDueNow: CommerceMoney; + totalDueNow: CommerceFee; /** * @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. @@ -1317,7 +1271,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - credit: CommerceMoney; + credit: CommerceFee; /** * @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. @@ -1326,7 +1280,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - pastDue: CommerceMoney; + pastDue: CommerceFee; } /** diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 11dd4760797..b07f5847ab0 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: CommerceFeeJSON; + annual_fee: CommerceFeeJSON; + annual_monthly_fee: CommerceFeeJSON; amount: number; amount_formatted: string; annual_amount: number; @@ -745,7 +748,7 @@ export interface CommerceStatementGroupJSON extends ClerkResourceJSON { export interface CommercePaymentJSON extends ClerkResourceJSON { object: 'commerce_payment'; id: string; - amount: CommerceMoneyJSON; + amount: CommerceFeeJSON; paid_at?: number; failed_at?: number; updated_at: number; @@ -767,9 +770,9 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { object: 'commerce_subscription_item'; id: string; - amount?: CommerceMoneyJSON; + amount?: CommerceFeeJSON; credit?: { - amount: CommerceMoneyJSON; + amount: CommerceFeeJSON; }; payment_source_id: string; plan: CommercePlanJSON; @@ -797,7 +800,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: CommerceFeeJSON; date: number; }; /** @@ -819,7 +822,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { * * ``` */ -export interface CommerceMoneyJSON { +export interface CommerceFeeJSON { amount: number; amount_formatted: string; currency: string; @@ -835,11 +838,11 @@ export interface CommerceMoneyJSON { * ``` */ export interface CommerceCheckoutTotalsJSON { - grand_total: CommerceMoneyJSON; - subtotal: CommerceMoneyJSON; - tax_total: CommerceMoneyJSON; - total_due_now: CommerceMoneyJSON; - credit: CommerceMoneyJSON; + grand_total: CommerceFeeJSON; + subtotal: CommerceFeeJSON; + tax_total: CommerceFeeJSON; + total_due_now: CommerceFeeJSON; + credit: CommerceFeeJSON; } /** From 53d1c86119fb237544a10479b0915c8c180574df Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 8 Aug 2025 14:06:43 +0300 Subject: [PATCH 2/9] address test in react --- .../__tests__/PlanDetailsButton.test.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx index a58c408126a..b57104fc5c6 100644 --- a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -31,24 +31,33 @@ 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, slug: 'test-plan', avatarUrl: 'https://example.com/avatar.png', features: [], - __internal_toSnapshot: vi.fn(), pathRoot: '', reload: vi.fn(), }; From 6975c497954403f1a1b857536a5341ce704a6694 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 8 Aug 2025 14:26:06 +0300 Subject: [PATCH 3/9] address feedback from rabbit --- .../src/ui/contexts/components/Plans.tsx | 6 ++--- packages/clerk-js/src/utils/commerce.ts | 22 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index d1cbdfeea6d..03173ad9a66 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -205,12 +205,12 @@ export const usePlansContext = () => { const subscription = sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined); let _selectedPlanPeriod = selectedPlanPeriod; - if (_selectedPlanPeriod === 'annual' && sub?.plan.annualMonthlyFee.amount === 0) { + const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0; + + if (_selectedPlanPeriod === 'annual' && !isEligibleForSwitchToAnnual) { _selectedPlanPeriod = 'month'; } - const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0; - const getLocalizationKey = () => { // Handle subscription cases if (subscription) { diff --git a/packages/clerk-js/src/utils/commerce.ts b/packages/clerk-js/src/utils/commerce.ts index dde1e6bf6d3..0b603a5f82b 100644 --- a/packages/clerk-js/src/utils/commerce.ts +++ b/packages/clerk-js/src/utils/commerce.ts @@ -16,23 +16,27 @@ export const commerceFeeFromJSON = (data: CommerceFeeJSON): CommerceFee => { }; }; -export const commerceTotalsFromJSON = (data: T) => { - const totals = { +const hasPastDue = (data: unknown): data is { past_due: CommerceFeeJSON } => { + return typeof data === 'object' && data !== null && 'past_due' in data; +}; + +export const commerceTotalsFromJSON = ( + data: T, +): T extends { total_due_now: CommerceFeeJSON } ? CommerceCheckoutTotals : CommerceStatementTotals => { + const totals: Partial = { grandTotal: commerceFeeFromJSON(data.grand_total), subtotal: commerceFeeFromJSON(data.subtotal), taxTotal: commerceFeeFromJSON(data.tax_total), }; + if ('total_due_now' in data) { - // @ts-ignore - totals['totalDueNow'] = commerceFeeFromJSON(data.total_due_now); + totals.totalDueNow = commerceFeeFromJSON(data.total_due_now); } if ('credit' in data) { - // @ts-ignore - totals['credit'] = commerceFeeFromJSON(data.credit); + totals.credit = commerceFeeFromJSON(data.credit); } - if ('past_due' in data) { - // @ts-ignore - totals['pastDue'] = commerceFeeFromJSON(data.past_due); + if (hasPastDue(data)) { + totals.pastDue = commerceFeeFromJSON(data.past_due); } return totals as T extends { total_due_now: CommerceFeeJSON } ? CommerceCheckoutTotals : CommerceStatementTotals; From 69c583c422c56e93dc34bd1e4eefef9d32672af3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 8 Aug 2025 14:26:59 +0300 Subject: [PATCH 4/9] update typedoc --- .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index be155abe172..93f32ec1bc1 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -28,10 +28,10 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/commerce-checkout-totals.mdx", "types/commerce-feature-json.mdx", "types/commerce-feature-resource.mdx", + "types/commerce-fee-json.mdx", + "types/commerce-fee.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-payer-resource-type.mdx", "types/commerce-payment-charge-type.mdx", "types/commerce-payment-json.mdx", From 360986ca9b6e43174f4e9f3e45f2a6bc316d170e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 12 Aug 2025 18:52:50 +0300 Subject: [PATCH 5/9] remove duplication --- .../src/ui/components/Plans/PlanDetails.tsx | 9 +-------- .../PricingTable/PricingTableDefault.tsx | 9 +-------- .../ui/components/SubscriptionDetails/index.tsx | 14 +++++++------- .../components/Subscriptions/SubscriptionsList.tsx | 8 +------- .../clerk-js/src/ui/contexts/components/Plans.tsx | 7 +++++++ 5 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index 8caae5a4e37..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, @@ -218,13 +218,6 @@ interface HeaderProps { closeSlot?: React.ReactNode; } -/** - * Only remove decimal places if they are '00', to match previous behavior. - */ -function normalizeFormatted(formatted: string) { - return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; -} - const Header = React.forwardRef((props, ref) => { const { plan, closeSlot, planPeriod, setPlanPeriod } = props; diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index bfbbcebce14..d89682334fe 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, @@ -286,13 +286,6 @@ interface CardHeaderProps { badge?: React.ReactNode; } -/** - * Only remove decimal places if they are '00', to match previous behavior. - */ -function normalizeFormatted(formatted: string) { - return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; -} - const CardHeader = React.forwardRef((props, ref) => { const { plan, isCompact, planPeriod, setPlanPeriod, badge } = props; const { name, annualMonthlyFee } = plan; diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index aa99c14ede2..83e2c1b7355 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, @@ -324,12 +330,6 @@ function SubscriptionDetailsSummary() { ); } -/** - * Only remove decimal places if they are '00', to match previous behavior. - */ -function normalizeFormatted(formatted: string) { - return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; -} const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionItemResource }) => { const { portalRoot } = useSubscriptionDetailsContext(); const { __internal_openCheckout } = useClerk(); diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index c24ef38ee4a..759cf299d17 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -5,6 +5,7 @@ import { ProfileSection } from '@/ui/elements/Section'; import { useProtect } from '../../common'; import { + normalizeFormatted, useEnvironment, usePlansContext, useSubscriberTypeContext, @@ -129,13 +130,6 @@ export function SubscriptionsList({ ); } -/** - * Only remove decimal places if they are '00', to match previous behavior. - */ -function normalizeFormatted(formatted: string) { - return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted; -} - function SubscriptionRow({ subscription, length }: { subscription: CommerceSubscriptionItemResource; length: number }) { const subscriberType = useSubscriberTypeContext(); const canManageBilling = diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 03173ad9a66..cca61839f22 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(); From 0bb6872657b9a9080a6d9371e0a331d73df8fadb Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 12 Aug 2025 18:57:05 +0300 Subject: [PATCH 6/9] leftover after conflicts --- .../src/ui/components/Subscriptions/SubscriptionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index 759cf299d17..d2775162d2c 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -162,7 +162,7 @@ function SubscriptionRow({ subscription, length }: { subscription: CommerceSubsc > {subscription.plan.name} - {length > 1 || !!subscription.canceledAtDate ? : null} + {length > 1 || subscription.canceledAt !== null ? : null} {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( From 6a0d739086a6c89849d2e9b39da35b1fe2e9c90f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 12 Aug 2025 19:33:52 +0300 Subject: [PATCH 7/9] update fees to moneyamount --- .../backend/src/api/resources/CommercePlan.ts | 8 +++--- packages/backend/src/api/resources/JSON.ts | 18 ++++++------ .../src/core/resources/CommercePayment.ts | 8 +++--- .../src/core/resources/CommercePlan.ts | 21 ++++++++------ .../core/resources/CommerceSubscription.ts | 17 +++++------ .../ui/components/Checkout/CheckoutForm.tsx | 10 +++++-- packages/clerk-js/src/utils/commerce.ts | 26 +++++++++-------- packages/types/src/commerce.ts | 28 +++++++++---------- packages/types/src/json.ts | 26 ++++++++--------- 9 files changed, 88 insertions(+), 74 deletions(-) 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 d801ab8ca16..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 { - CommerceFee, + CommerceMoneyAmount, CommercePaymentChargeType, CommercePaymentJSON, CommercePaymentResource, @@ -8,13 +8,13 @@ import type { CommerceSubscriptionItemResource, } from '@clerk/types'; -import { commerceFeeFromJSON } 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!: CommerceFee; + 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 = commerceFeeFromJSON(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 e49926d5146..3db0feb3340 100644 --- a/packages/clerk-js/src/core/resources/CommercePlan.ts +++ b/packages/clerk-js/src/core/resources/CommercePlan.ts @@ -1,15 +1,20 @@ -import type { CommerceFee, CommercePayerResourceType, CommercePlanJSON, CommercePlanResource } from '@clerk/types'; +import type { + CommerceMoneyAmount, + CommercePayerResourceType, + CommercePlanJSON, + CommercePlanResource, +} from '@clerk/types'; -import { commerceFeeFromJSON } from '@/utils/commerce'; +import { commerceMoneyAmountFromJSON } from '@/utils/commerce'; import { BaseResource, CommerceFeature } from './internal'; export class CommercePlan extends BaseResource implements CommercePlanResource { id!: string; name!: string; - fee!: CommerceFee; - annualFee!: CommerceFee; - annualMonthlyFee!: CommerceFee; + fee!: CommerceMoneyAmount; + annualFee!: CommerceMoneyAmount; + annualMonthlyFee!: CommerceMoneyAmount; description!: string; isDefault!: boolean; isRecurring!: boolean; @@ -32,9 +37,9 @@ export class CommercePlan extends BaseResource implements CommercePlanResource { this.id = data.id; this.name = data.name; - this.fee = commerceFeeFromJSON(data.fee); - this.annualFee = commerceFeeFromJSON(data.annual_fee); - this.annualMonthlyFee = commerceFeeFromJSON(data.annual_monthly_fee); + 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; diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index 28d90bc5d00..00755f7ed56 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, - CommerceFee, + CommerceMoneyAmount, CommerceSubscriptionItemJSON, CommerceSubscriptionItemResource, CommerceSubscriptionJSON, @@ -12,7 +12,7 @@ import type { import { unixEpochToDate } from '@/utils/date'; -import { commerceFeeFromJSON } 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: CommerceFee; + 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: commerceFeeFromJSON(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?: CommerceFee; + amount?: CommerceMoneyAmount; credit?: { - amount: CommerceFee; + amount: CommerceMoneyAmount; }; isFreeTrial = false; @@ -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 ? commerceFeeFromJSON(data.amount) : undefined; - this.credit = data.credit && data.credit.amount ? { amount: commerceFeeFromJSON(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 e0cba8cb804..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 { CommerceFee, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; +import type { CommerceMoneyAmount, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -310,7 +310,13 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { }); const ExistingPaymentSourceForm = withCardStateProvider( - ({ totalDueNow, paymentSources }: { totalDueNow: CommerceFee; paymentSources: CommercePaymentSourceResource[] }) => { + ({ + totalDueNow, + paymentSources, + }: { + totalDueNow: CommerceMoneyAmount; + paymentSources: CommercePaymentSourceResource[]; + }) => { const { checkout } = useCheckout(); const { paymentSource } = checkout; diff --git a/packages/clerk-js/src/utils/commerce.ts b/packages/clerk-js/src/utils/commerce.ts index 0b603a5f82b..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, - CommerceFee, - CommerceFeeJSON, + CommerceMoneyAmount, + CommerceMoneyAmountJSON, CommerceStatementTotals, CommerceStatementTotalsJSON, } from '@clerk/types'; -export const commerceFeeFromJSON = (data: CommerceFeeJSON): CommerceFee => { +export const commerceMoneyAmountFromJSON = (data: CommerceMoneyAmountJSON): CommerceMoneyAmount => { return { amount: data.amount, amountFormatted: data.amount_formatted, @@ -16,28 +16,30 @@ export const commerceFeeFromJSON = (data: CommerceFeeJSON): CommerceFee => { }; }; -const hasPastDue = (data: unknown): data is { past_due: CommerceFeeJSON } => { +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: CommerceFeeJSON } ? CommerceCheckoutTotals : CommerceStatementTotals => { +): T extends { total_due_now: CommerceMoneyAmountJSON } ? CommerceCheckoutTotals : CommerceStatementTotals => { const totals: Partial = { - grandTotal: commerceFeeFromJSON(data.grand_total), - subtotal: commerceFeeFromJSON(data.subtotal), - taxTotal: commerceFeeFromJSON(data.tax_total), + grandTotal: commerceMoneyAmountFromJSON(data.grand_total), + subtotal: commerceMoneyAmountFromJSON(data.subtotal), + taxTotal: commerceMoneyAmountFromJSON(data.tax_total), }; if ('total_due_now' in data) { - totals.totalDueNow = commerceFeeFromJSON(data.total_due_now); + totals.totalDueNow = commerceMoneyAmountFromJSON(data.total_due_now); } if ('credit' in data) { - totals.credit = commerceFeeFromJSON(data.credit); + totals.credit = commerceMoneyAmountFromJSON(data.credit); } if (hasPastDue(data)) { - totals.pastDue = commerceFeeFromJSON(data.past_due); + totals.pastDue = commerceMoneyAmountFromJSON(data.past_due); } - return totals as T extends { total_due_now: CommerceFeeJSON } ? CommerceCheckoutTotals : CommerceStatementTotals; + return totals as T extends { total_due_now: CommerceMoneyAmountJSON } + ? CommerceCheckoutTotals + : CommerceStatementTotals; }; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index baef2c4d3f0..8c9d57f4f47 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -287,7 +287,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - fee: CommerceFee; + 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. @@ -296,7 +296,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - annualFee: CommerceFee; + 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. @@ -305,7 +305,7 @@ export interface CommercePlanResource extends ClerkResource { * * ``` */ - annualMonthlyFee: CommerceFee; + 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. @@ -703,7 +703,7 @@ export interface CommercePaymentResource extends ClerkResource { * * ``` */ - amount: CommerceFee; + 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. @@ -1019,7 +1019,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount?: CommerceFee; + 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. @@ -1037,7 +1037,7 @@ export interface CommerceSubscriptionItemResource extends ClerkResource { * * ``` */ - amount: CommerceFee; + 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. @@ -1112,7 +1112,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ - amount: CommerceFee; + 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. @@ -1186,7 +1186,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ -export interface CommerceFee { +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. @@ -1242,7 +1242,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - subtotal: CommerceFee; + 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. @@ -1251,7 +1251,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - grandTotal: CommerceFee; + 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. @@ -1260,7 +1260,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - taxTotal: CommerceFee; + 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. @@ -1269,7 +1269,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - totalDueNow: CommerceFee; + 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. @@ -1278,7 +1278,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - credit: CommerceFee; + 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. @@ -1287,7 +1287,7 @@ export interface CommerceCheckoutTotals { * * ``` */ - pastDue: CommerceFee; + pastDue: CommerceMoneyAmount; } /** diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index f166a298253..4684a9ef443 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -632,9 +632,9 @@ export interface CommercePlanJSON extends ClerkResourceJSON { object: 'commerce_plan'; id: string; name: string; - fee: CommerceFeeJSON; - annual_fee: CommerceFeeJSON; - annual_monthly_fee: CommerceFeeJSON; + fee: CommerceMoneyAmountJSON; + annual_fee: CommerceMoneyAmountJSON; + annual_monthly_fee: CommerceMoneyAmountJSON; amount: number; amount_formatted: string; annual_amount: number; @@ -748,7 +748,7 @@ export interface CommerceStatementGroupJSON extends ClerkResourceJSON { export interface CommercePaymentJSON extends ClerkResourceJSON { object: 'commerce_payment'; id: string; - amount: CommerceFeeJSON; + amount: CommerceMoneyAmountJSON; paid_at?: number; failed_at?: number; updated_at: number; @@ -770,9 +770,9 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { object: 'commerce_subscription_item'; id: string; - amount?: CommerceFeeJSON; + amount?: CommerceMoneyAmountJSON; credit?: { - amount: CommerceFeeJSON; + amount: CommerceMoneyAmountJSON; }; payment_source_id: string; plan: CommercePlanJSON; @@ -804,7 +804,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: CommerceFeeJSON; + amount: CommerceMoneyAmountJSON; date: number; }; /** @@ -827,7 +827,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { * * ``` */ -export interface CommerceFeeJSON { +export interface CommerceMoneyAmountJSON { amount: number; amount_formatted: string; currency: string; @@ -843,11 +843,11 @@ export interface CommerceFeeJSON { * ``` */ export interface CommerceCheckoutTotalsJSON { - grand_total: CommerceFeeJSON; - subtotal: CommerceFeeJSON; - tax_total: CommerceFeeJSON; - total_due_now: CommerceFeeJSON; - credit: CommerceFeeJSON; + grand_total: CommerceMoneyAmountJSON; + subtotal: CommerceMoneyAmountJSON; + tax_total: CommerceMoneyAmountJSON; + total_due_now: CommerceMoneyAmountJSON; + credit: CommerceMoneyAmountJSON; } /** From dbe18059de3eaf822175352366896687c509d381 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 12 Aug 2025 22:42:16 +0300 Subject: [PATCH 8/9] update typedocs --- .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index 93f32ec1bc1..c079f5947e2 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -28,10 +28,10 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/commerce-checkout-totals.mdx", "types/commerce-feature-json.mdx", "types/commerce-feature-resource.mdx", - "types/commerce-fee-json.mdx", - "types/commerce-fee.mdx", "types/commerce-initialized-payment-source-json.mdx", "types/commerce-initialized-payment-source-resource.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", From 537703984584287baa5afe2188009b854b8b8534 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 12 Aug 2025 23:04:14 +0300 Subject: [PATCH 9/9] patch test --- .../__tests__/PricingTable.test.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx index 1acf89ed0c3..6613ee1c960 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx @@ -8,17 +8,27 @@ describe('PricingTable - trial info', () => { 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,