From e783dd0c06f71f738fc6ff38025fa0482e1d4f0a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 29 Oct 2025 19:14:18 +0200 Subject: [PATCH 01/19] feat(backend, clerk-js): Update the supported API version to `2025-10-01` --- packages/backend/src/constants.ts | 2 +- packages/clerk-js/src/core/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index ac7b9fa1d30..0e5d7acb7f3 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -3,7 +3,7 @@ export const API_VERSION = 'v1'; export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60; -export const SUPPORTED_BAPI_VERSION = '2025-04-10'; +export const SUPPORTED_BAPI_VERSION = '2025-10-01'; const Attributes = { AuthToken: '__clerkAuthToken', diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index dac4f71a494..674b184a351 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -54,4 +54,4 @@ export const SIGN_UP_MODES = { } satisfies Record; // This is the currently supported version of the Frontend API -export const SUPPORTED_FAPI_VERSION = '2025-04-10'; +export const SUPPORTED_FAPI_VERSION = '2025-10-01'; From 263b29bcb29541033944e113b1a5f82b0172646a Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 29 Oct 2025 19:48:52 +0200 Subject: [PATCH 02/19] feat(backend, clerk-js): Update the supported API version to `2025-10-01` --- .changeset/hot-jars-smell.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/hot-jars-smell.md diff --git a/.changeset/hot-jars-smell.md b/.changeset/hot-jars-smell.md new file mode 100644 index 00000000000..e193a730ace --- /dev/null +++ b/.changeset/hot-jars-smell.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/backend': patch +--- + +Update the supported API version to `2025-10-01`. From 4731af28cdac3a1a58dabaed55a92fa5072ad579 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 29 Oct 2025 19:53:19 +0200 Subject: [PATCH 03/19] update test case --- packages/backend/src/tokens/__tests__/handshake.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 29eed7d2838..16444a964db 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -427,7 +427,7 @@ describe('HandshakeService', () => { // Verify all required parameters are present expect(url.searchParams.get('redirect_url')).toBeDefined(); - expect(url.searchParams.get('__clerk_api_version')).toBe('2025-04-10'); + expect(url.searchParams.get('__clerk_api_version')).toBe('2025-10-01'); expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/); expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason'); }); From 38b1d42ba4fb23524a2b56a07e33bfdef9777995 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 29 Oct 2025 22:30:48 +0200 Subject: [PATCH 04/19] fix nullable fees in BillingPlan --- .../src/core/resources/BillingPlan.ts | 14 +++++++--- .../ui/components/Checkout/CheckoutForm.tsx | 6 +++- .../PaymentAttempts/PaymentAttemptPage.tsx | 5 +++- .../src/ui/components/Plans/PlanDetails.tsx | 4 +-- .../Plans/__tests__/PlanDetails.test.tsx | 28 +++---------------- .../PricingTable/PricingTableDefault.tsx | 8 ++++-- .../PricingTable/PricingTableMatrix.tsx | 15 +++++----- .../utils/pricing-footer-state.ts | 2 +- .../__tests__/SubscriptionDetails.test.tsx | 14 ++-------- .../components/SubscriptionDetails/index.tsx | 14 +++++++--- .../Subscriptions/SubscriptionsList.tsx | 3 +- .../src/ui/contexts/components/Plans.tsx | 4 +-- packages/types/src/billing.ts | 8 +++--- packages/types/src/json.ts | 4 +-- 14 files changed, 61 insertions(+), 68 deletions(-) diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index dfd5f96da9d..c8383c54eef 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -8,8 +8,8 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { id!: string; name!: string; fee!: BillingMoneyAmount; - annualFee!: BillingMoneyAmount; - annualMonthlyFee!: BillingMoneyAmount; + annualFee: BillingMoneyAmount | null = null; + annualMonthlyFee: BillingMoneyAmount | null = null; description!: string; isDefault!: boolean; isRecurring!: boolean; @@ -32,11 +32,17 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { return this; } + console.log('data', { + fee: data.fee, + annual_fee: data.annual_fee, + annual_monthly_fee: data.annual_monthly_fee, + }); + this.id = data.id; this.name = data.name; this.fee = billingMoneyAmountFromJSON(data.fee); - this.annualFee = billingMoneyAmountFromJSON(data.annual_fee); - this.annualMonthlyFee = billingMoneyAmountFromJSON(data.annual_monthly_fee); + this.annualFee = data.annual_fee ? billingMoneyAmountFromJSON(data.annual_fee) : null; + this.annualMonthlyFee = data.annual_monthly_fee ? billingMoneyAmountFromJSON(data.annual_monthly_fee) : null; this.description = data.description; this.isDefault = data.is_default; this.isRecurring = data.is_recurring; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 1fd038d7578..8e6e5f514df 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -39,7 +39,11 @@ export const CheckoutForm = withCardStateProvider(() => { const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; - const fee = planPeriod === 'month' ? plan.fee : plan.annualMonthlyFee; + const fee = + planPeriod === 'month' + ? plan.fee + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + plan.annualMonthlyFee!; return ( diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index 1721aae96f7..24d5f795b80 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -215,7 +215,10 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub } const fee = - subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.fee : subscriptionItem.plan.annualMonthlyFee; + subscriptionItem.planPeriod === 'month' + ? subscriptionItem.plan.fee + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + subscriptionItem.plan.annualMonthlyFee!; return ( ((props, ref) => { const { plan, closeSlot, planPeriod, setPlanPeriod } = props; const fee = useMemo(() => { - if (plan.annualMonthlyFee.amount <= 0) { + if (!plan.annualMonthlyFee) { return plan.fee; } return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee; @@ -333,7 +333,7 @@ const Header = React.forwardRef((props, ref) => { - {plan.annualMonthlyFee.amount > 0 ? ( + {plan.annualMonthlyFee ? ( ({ 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 1c9faf04ab4..fab81348ba5 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 @@ -221,18 +221,8 @@ describe('PlanDetails', () => { currencySymbol: '$', currency: 'USD', }, - annualFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, - annualMonthlyFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, + annualFee: null, + annualMonthlyFee: null, }; const { wrapper } = await createFixtures(f => { @@ -265,18 +255,8 @@ describe('PlanDetails', () => { currencySymbol: '$', currency: 'USD', }, - annualFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, - annualMonthlyFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, + annualFee: null, + annualMonthlyFee: null, 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 84317a3ca3d..2a5d19b85ec 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -282,13 +282,17 @@ const CardHeader = React.forwardRef((props, ref const { plan, isCompact, planPeriod, setPlanPeriod, badge } = props; const { name, annualMonthlyFee } = plan; - const planSupportsAnnual = annualMonthlyFee.amount > 0; + const planSupportsAnnual = Boolean(annualMonthlyFee); const fee = React.useMemo(() => { if (!planSupportsAnnual) { return plan.fee; } - return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee; + + return planPeriod === 'annual' + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + plan.annualMonthlyFee! + : plan.fee; }, [planSupportsAnnual, planPeriod, plan.fee, plan.annualMonthlyFee]); const feeFormatted = React.useMemo(() => { diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx index 0ec8b725f05..b9629bc3fd2 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.annualMonthlyFee.amount > 0), [plans]); + const renderBillingCycleControls = React.useMemo(() => plans.some(plan => Boolean(plan.annualMonthlyFee)), [plans]); const getAllFeatures = React.useMemo(() => { const featuresSet = new Set(); @@ -156,12 +156,11 @@ export function PricingTableMatrix({ {plans.map(plan => { const highlight = plan.slug === highlightedPlan; - const planFee = - plan.annualMonthlyFee.amount <= 0 - ? plan.fee - : planPeriod === 'annual' - ? plan.annualMonthlyFee - : plan.fee; + const planFee = !plan.annualMonthlyFee + ? plan.fee + : planPeriod === 'annual' + ? plan.annualMonthlyFee + : plan.fee; return ( - {plan.annualMonthlyFee.amount > 0 ? ( + {plan.annualMonthlyFee ? ( 0; + const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && Boolean(plan.annualMonthlyFee); const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial; if (isCanceled || isSwitchingPaidPeriod) { 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 bb0dbffce26..038f9117152 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 @@ -262,18 +262,8 @@ describe('SubscriptionDetails', () => { currencySymbol: '$', currency: 'USD', }, - annualFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, - annualMonthlyFee: { - amount: 0, - amountFormatted: '0.00', - currencySymbol: '$', - currency: 'USD', - }, + annualFee: null, + annualMonthlyFee: null, description: 'Free Plan description', hasBaseFee: false, isRecurring: true, diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index a8828713205..f9292278433 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -374,7 +374,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr const canManageBilling = subscriberType === 'user' || canOrgManageBilling; const isSwitchable = - ((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyFee.amount > 0) || + ((subscription.planPeriod === 'month' && Boolean(subscription.plan.annualMonthlyFee)) || subscription.planPeriod === 'annual') && subscription.status !== 'past_due'; const isFree = isFreePlan(subscription.plan); @@ -409,8 +409,10 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr label: subscription.planPeriod === 'month' ? localizationKeys('billing.switchToAnnualWithAnnualPrice', { - price: normalizeFormatted(subscription.plan.annualFee.amountFormatted), - currency: subscription.plan.annualFee.currencySymbol, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + price: normalizeFormatted(subscription.plan.annualFee!.amountFormatted), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + currency: subscription.plan.annualFee!.currencySymbol, }) : localizationKeys('billing.switchToMonthlyWithPrice', { price: normalizeFormatted(subscription.plan.fee.amountFormatted), @@ -480,7 +482,11 @@ const SubscriptionCardActions = ({ subscription }: { subscription: BillingSubscr const SubscriptionCard = ({ subscription }: { subscription: BillingSubscriptionItemResource }) => { const { t } = useLocalizations(); - const fee = subscription.planPeriod === 'month' ? subscription.plan.fee : subscription.plan.annualFee; + const fee = + subscription.planPeriod === 'month' + ? subscription.plan.fee + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + subscription.plan.annualFee!; return ( { diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index f76d0d7e0fb..5136cf42c83 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -213,7 +213,7 @@ export const usePlansContext = () => { const subscription = sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined); let _selectedPlanPeriod = selectedPlanPeriod; - const isEligibleForSwitchToAnnual = (plan?.annualMonthlyFee.amount ?? 0) > 0; + const isEligibleForSwitchToAnnual = Boolean(plan?.annualMonthlyFee); if (_selectedPlanPeriod === 'annual' && !isEligibleForSwitchToAnnual) { _selectedPlanPeriod = 'month'; @@ -326,7 +326,7 @@ export const usePlansContext = () => { clerk.__internal_openCheckout({ planId: plan.id, // if the plan doesn't support annual, use monthly - planPeriod: planPeriod === 'annual' && plan.annualMonthlyFee.amount === 0 ? 'month' : planPeriod, + planPeriod: planPeriod === 'annual' && !plan.annualMonthlyFee ? 'month' : planPeriod, for: subscriberType, onSubscriptionComplete: () => { revalidateAll(); diff --git a/packages/types/src/billing.ts b/packages/types/src/billing.ts index c181b7b55cf..80d6364cdfb 100644 --- a/packages/types/src/billing.ts +++ b/packages/types/src/billing.ts @@ -131,13 +131,13 @@ export interface BillingPlanResource extends ClerkResource { */ fee: BillingMoneyAmount; /** - * The annual price of the plan. + * The annual price of the plan or `null` if the plan is not annual. */ - annualFee: BillingMoneyAmount; + annualFee: BillingMoneyAmount | null; /** - * The effective monthly price when billed annually. + * The effective monthly price when billed annually or `null` if the plan is not annual. */ - annualMonthlyFee: BillingMoneyAmount; + annualMonthlyFee: BillingMoneyAmount | null; /** * A short description of what the plan offers. */ diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 2da7732ac6a..9f63617cf7d 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -634,8 +634,8 @@ export interface BillingPlanJSON extends ClerkResourceJSON { id: string; name: string; fee: BillingMoneyAmountJSON; - annual_fee: BillingMoneyAmountJSON; - annual_monthly_fee: BillingMoneyAmountJSON; + annual_fee: BillingMoneyAmountJSON | null; + annual_monthly_fee: BillingMoneyAmountJSON | null; amount: number; amount_formatted: string; annual_amount: number; From 52a240944a70d6182b39d707b6b22550fc5ff6a3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 29 Oct 2025 23:07:45 +0200 Subject: [PATCH 05/19] update nullable totals --- packages/clerk-js/src/utils/billing.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 496dd10520b..edc373a6968 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -25,15 +25,17 @@ export const billingTotalsFromJSON = Date: Wed, 29 Oct 2025 23:25:10 +0200 Subject: [PATCH 06/19] update `paymentSourceId` to `paymentMethodId` param --- .../clerk-js/src/ui/components/Checkout/CheckoutForm.tsx | 4 ++-- packages/types/src/billing.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 8e6e5f514df..460c7e74d54 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -170,10 +170,10 @@ const useCheckoutMutations = () => { e.preventDefault(); const data = new FormData(e.currentTarget); - const paymentSourceId = data.get(HIDDEN_INPUT_NAME) as string; + const paymentMethodId = data.get(HIDDEN_INPUT_NAME) as string; return confirmCheckout({ - paymentSourceId, + paymentMethodId, }); }; diff --git a/packages/types/src/billing.ts b/packages/types/src/billing.ts index 80d6364cdfb..bbbd8f2e405 100644 --- a/packages/types/src/billing.ts +++ b/packages/types/src/billing.ts @@ -688,10 +688,10 @@ export type CreateCheckoutParams = WithOptionalOrgType<{ }>; /** - * The `confirm()` method accepts the following parameters. **Only one of `paymentSourceId`, `paymentToken`, or `useTestCard` should be provided.** + * The `confirm()` method accepts the following parameters. **Only one of `paymentMethodId`, `paymentToken`, or `useTestCard` should be provided.** * * @unionReturnHeadings - * ["paymentSourceId", "paymentToken", "useTestCard"] + * ["paymentMethodId", "paymentToken", "useTestCard"] * * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ @@ -700,7 +700,7 @@ export type ConfirmCheckoutParams = /** * The ID of a saved payment method to use for this checkout. */ - paymentSourceId?: string; + paymentMethodId?: string; } | { /** From 398219b02a3e9ee4737b6d583945ee46672e4916 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 30 Oct 2025 09:41:38 +0200 Subject: [PATCH 07/19] wip --- .../backend/src/api/resources/CommercePlan.ts | 20 +++-- .../src/api/resources/CommerceSubscription.ts | 2 +- packages/backend/src/api/resources/Feature.ts | 6 +- packages/backend/src/api/resources/JSON.ts | 14 ++-- .../src/core/resources/BillingPayer.ts | 36 ++++---- .../core/resources/BillingPaymentMethod.ts | 36 +++++--- .../src/core/resources/BillingPlan.ts | 14 +--- .../clerk-js/src/core/resources/Feature.ts | 8 +- .../components/Checkout/CheckoutComplete.tsx | 33 ++++++-- .../ui/components/Checkout/CheckoutForm.tsx | 44 ++++++---- .../PaymentMethods/PaymentMethodRow.tsx | 10 ++- .../PaymentMethods/PaymentMethods.tsx | 35 ++++++-- packages/clerk-js/src/utils/billing.ts | 26 ++++-- packages/types/src/billing.ts | 83 ++++++++++++------- packages/types/src/json.ts | 79 ++++++++++-------- 15 files changed, 286 insertions(+), 160 deletions(-) diff --git a/packages/backend/src/api/resources/CommercePlan.ts b/packages/backend/src/api/resources/CommercePlan.ts index 1432bd87e07..9b1348e7c0e 100644 --- a/packages/backend/src/api/resources/CommercePlan.ts +++ b/packages/backend/src/api/resources/CommercePlan.ts @@ -29,7 +29,7 @@ export class BillingPlan { /** * The description of the plan. */ - readonly description: string | undefined, + readonly description: string | null, /** * Whether the plan is the default plan. */ @@ -53,11 +53,11 @@ export class BillingPlan { /** * The annual fee of the plan. */ - readonly annualFee: BillingMoneyAmount, + readonly annualFee: BillingMoneyAmount | null, /** * The annual fee of the plan on a monthly basis. */ - readonly annualMonthlyFee: BillingMoneyAmount, + readonly annualMonthlyFee: BillingMoneyAmount | null, /** * The type of payer for the plan. */ @@ -69,7 +69,11 @@ export class BillingPlan { ) {} static fromJSON(data: BillingPlanJSON): BillingPlan { - const formatAmountJSON = (fee: BillingPlanJSON['fee']) => { + const formatAmountJSON = (fee: BillingPlanJSON['fee'] | null | undefined): BillingMoneyAmount | null => { + if (!fee) { + return null; + } + return { amount: fee.amount, amountFormatted: fee.amount_formatted, @@ -82,16 +86,18 @@ export class BillingPlan { data.product_id, data.name, data.slug, - data.description, + data.description ?? null, data.is_default, data.is_recurring, data.has_base_fee, data.publicly_visible, - formatAmountJSON(data.fee), + // fee is required and should not be null in API responses + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + formatAmountJSON(data.fee)!, formatAmountJSON(data.annual_fee), formatAmountJSON(data.annual_monthly_fee), data.for_payer_type, - data.features.map(feature => Feature.fromJSON(feature)), + (data.features ?? []).map(feature => Feature.fromJSON(feature)), ); } } diff --git a/packages/backend/src/api/resources/CommerceSubscription.ts b/packages/backend/src/api/resources/CommerceSubscription.ts index c4849225d45..64d04089170 100644 --- a/packages/backend/src/api/resources/CommerceSubscription.ts +++ b/packages/backend/src/api/resources/CommerceSubscription.ts @@ -73,7 +73,7 @@ export class BillingSubscription { data.updated_at, data.active_at ?? null, data.past_due_at ?? null, - data.subscription_items.map(item => BillingSubscriptionItem.fromJSON(item)), + (data.subscription_items ?? []).map(item => BillingSubscriptionItem.fromJSON(item)), nextPayment, data.eligible_for_free_trial ?? false, ); diff --git a/packages/backend/src/api/resources/Feature.ts b/packages/backend/src/api/resources/Feature.ts index ca3821ddf50..819f2b4ee63 100644 --- a/packages/backend/src/api/resources/Feature.ts +++ b/packages/backend/src/api/resources/Feature.ts @@ -18,7 +18,7 @@ export class Feature { /** * The description of the feature. */ - readonly description: string, + readonly description: string | null, /** * The URL-friendly identifier of the feature. */ @@ -26,10 +26,10 @@ export class Feature { /** * The URL of the feature's avatar image. */ - readonly avatarUrl: string, + readonly avatarUrl: string | null, ) {} static fromJSON(data: FeatureJSON): Feature { - return new Feature(data.id, data.name, data.description, data.slug, data.avatar_url); + return new Feature(data.id, data.name, data.description ?? null, data.slug, data.avatar_url ?? null); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 587a86b79c5..f43835ae5ea 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -834,9 +834,9 @@ interface BillingTotalsJSON { export interface FeatureJSON extends ClerkResourceJSON { object: typeof ObjectType.Feature; name: string; - description: string; + description?: string | null; slug: string; - avatar_url: string; + avatar_url?: string | null; } /** @@ -848,16 +848,16 @@ export interface BillingPlanJSON extends ClerkResourceJSON { product_id: string; name: string; slug: string; - description?: string; + description?: string | null; is_default: boolean; is_recurring: boolean; has_base_fee: boolean; publicly_visible: boolean; fee: BillingMoneyAmountJSON; - annual_fee: BillingMoneyAmountJSON; - annual_monthly_fee: BillingMoneyAmountJSON; + annual_fee?: BillingMoneyAmountJSON | null; + annual_monthly_fee?: BillingMoneyAmountJSON | null; for_payer_type: 'org' | 'user'; - features: FeatureJSON[]; + features?: FeatureJSON[]; } type BillingSubscriptionItemStatus = @@ -886,7 +886,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { updated_at: number; canceled_at: number | null; past_due_at: number | null; - lifetime_paid: BillingMoneyAmountJSON; + lifetime_paid: BillingMoneyAmountJSON | null; next_payment: { amount: number; date: number; diff --git a/packages/clerk-js/src/core/resources/BillingPayer.ts b/packages/clerk-js/src/core/resources/BillingPayer.ts index 67eafb0588a..cc77bc4a4d8 100644 --- a/packages/clerk-js/src/core/resources/BillingPayer.ts +++ b/packages/clerk-js/src/core/resources/BillingPayer.ts @@ -6,15 +6,15 @@ import { BaseResource } from './internal'; export class BillingPayer extends BaseResource implements BillingPayerResource { id!: string; - createdAt!: Date; - updatedAt!: Date; - imageUrl!: string | null; - userId?: string; - email?: string; - firstName?: string; - lastName?: string; - organizationId?: string; - organizationName?: string; + createdAt?: Date | null; + updatedAt?: Date | null; + imageUrl?: string | null; + userId?: string | null; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + organizationId?: string | null; + organizationName?: string | null; constructor(data: BillingPayerJSON) { super(); @@ -27,15 +27,17 @@ export class BillingPayer extends BaseResource implements BillingPayerResource { } this.id = data.id; - this.createdAt = unixEpochToDate(data.created_at); - this.updatedAt = unixEpochToDate(data.updated_at); + this.createdAt = + data.created_at === undefined ? undefined : data.created_at === null ? null : unixEpochToDate(data.created_at); + this.updatedAt = + data.updated_at === undefined ? undefined : data.updated_at === null ? null : unixEpochToDate(data.updated_at); this.imageUrl = data.image_url; - this.userId = data.user_id; - this.email = data.email; - this.firstName = data.first_name; - this.lastName = data.last_name; - this.organizationId = data.organization_id; - this.organizationName = data.organization_name; + this.userId = data.user_id ?? null; + this.email = data.email ?? null; + this.firstName = data.first_name ?? null; + this.lastName = data.last_name ?? null; + this.organizationId = data.organization_id ?? null; + this.organizationName = data.organization_name ?? null; return this; } } diff --git a/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts b/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts index 7d2cc33843b..f3cfe756bcc 100644 --- a/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts +++ b/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts @@ -10,18 +10,23 @@ import type { } from '@clerk/types'; import { Billing } from '@/core/modules/billing'; +import { unixEpochToDate } from '@/utils/date'; import { BaseResource, DeletedObject } from './internal'; export class BillingPaymentMethod extends BaseResource implements BillingPaymentMethodResource { id!: string; - last4!: string; - paymentType!: 'card' | 'link'; - cardType!: string; - isDefault!: boolean; - isRemovable!: boolean; + last4: string | null = null; + paymentType?: 'card' | 'link'; + cardType: string | null = null; + isDefault?: boolean; + isRemovable?: boolean; status!: BillingPaymentMethodStatus; - walletType: string | undefined; + walletType?: string | null; + expiryYear?: number | null; + expiryMonth?: number | null; + createdAt?: Date | null; + updatedAt?: Date | null; constructor(data: BillingPaymentMethodJSON) { super(); @@ -34,13 +39,20 @@ export class BillingPaymentMethod extends BaseResource implements BillingPayment } this.id = data.id; - this.last4 = data.last4; - this.paymentType = data.payment_type; - this.cardType = data.card_type; - this.isDefault = data.is_default; - this.isRemovable = data.is_removable; + this.last4 = data.last4 ?? null; + const rawPaymentType = data.payment_type ?? data.payment_method; + this.paymentType = rawPaymentType === undefined ? undefined : (rawPaymentType as 'card' | 'link'); + this.cardType = data.card_type ?? null; + this.isDefault = data.is_default ?? undefined; + this.isRemovable = data.is_removable ?? undefined; this.status = data.status; - this.walletType = data.wallet_type ?? undefined; + this.walletType = data.wallet_type === undefined ? undefined : data.wallet_type; + this.expiryYear = data.expiry_year ?? null; + this.expiryMonth = data.expiry_month ?? null; + this.createdAt = + data.created_at === undefined ? undefined : data.created_at === null ? null : unixEpochToDate(data.created_at); + this.updatedAt = + data.updated_at === undefined ? undefined : data.updated_at === null ? null : unixEpochToDate(data.updated_at); return this; } diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index c8383c54eef..86d47d2da89 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -10,14 +10,14 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { fee!: BillingMoneyAmount; annualFee: BillingMoneyAmount | null = null; annualMonthlyFee: BillingMoneyAmount | null = null; - description!: string; + description: string | null = null; isDefault!: boolean; isRecurring!: boolean; hasBaseFee!: boolean; forPayerType!: BillingPayerResourceType; publiclyVisible!: boolean; slug!: string; - avatarUrl!: string; + avatarUrl: string | null = null; features!: Feature[]; freeTrialDays!: number | null; freeTrialEnabled!: boolean; @@ -32,25 +32,19 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { return this; } - console.log('data', { - fee: data.fee, - annual_fee: data.annual_fee, - annual_monthly_fee: data.annual_monthly_fee, - }); - this.id = data.id; this.name = data.name; this.fee = billingMoneyAmountFromJSON(data.fee); this.annualFee = data.annual_fee ? billingMoneyAmountFromJSON(data.annual_fee) : null; this.annualMonthlyFee = data.annual_monthly_fee ? billingMoneyAmountFromJSON(data.annual_monthly_fee) : null; - this.description = data.description; + this.description = data.description ?? null; this.isDefault = data.is_default; this.isRecurring = data.is_recurring; this.hasBaseFee = data.has_base_fee; this.forPayerType = data.for_payer_type; this.publiclyVisible = data.publicly_visible; this.slug = data.slug; - this.avatarUrl = data.avatar_url; + this.avatarUrl = data.avatar_url ?? null; this.freeTrialDays = this.withDefault(data.free_trial_days, null); this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false); this.features = (data.features || []).map(feature => new Feature(feature)); diff --git a/packages/clerk-js/src/core/resources/Feature.ts b/packages/clerk-js/src/core/resources/Feature.ts index 6b46c351b85..f5ef95f241a 100644 --- a/packages/clerk-js/src/core/resources/Feature.ts +++ b/packages/clerk-js/src/core/resources/Feature.ts @@ -5,9 +5,9 @@ import { BaseResource } from './internal'; export class Feature extends BaseResource implements FeatureResource { id!: string; name!: string; - description!: string; + description: string | null = null; slug!: string; - avatarUrl!: string; + avatarUrl: string | null = null; constructor(data: FeatureJSON) { super(); @@ -21,9 +21,9 @@ export class Feature extends BaseResource implements FeatureResource { this.id = data.id; this.name = data.name; - this.description = data.description; + this.description = data.description ?? null; this.slug = data.slug; - this.avatarUrl = data.avatar_url; + this.avatarUrl = data.avatar_url ?? null; return this; } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index e5e02c9b2f2..dbac8437b31 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -11,7 +11,26 @@ import { transitionDurationValues, transitionTiming } from '../../foundations/tr import { usePrefersReducedMotion } from '../../hooks'; import { useRouter } from '../../router'; -const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); +const capitalize = (name?: string | null, fallback = '') => { + if (!name) { + return fallback; + } + + return name.charAt(0).toUpperCase() + name.slice(1); +}; + +const formatPaymentMethodLabel = (method: BillingPaymentMethodResource) => { + const paymentType = method.paymentType ?? 'card'; + + if (paymentType !== 'card') { + return capitalize(paymentType, 'Payment'); + } + + const brand = capitalize(method.cardType, 'Card'); + const suffix = method.last4 ? ` ⋯ ${method.last4}` : ''; + + return `${brand}${suffix}`; +}; const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; const SuccessRing = ({ positionX, positionY }: { positionX: number; positionY: number }) => { @@ -418,7 +437,13 @@ export const CheckoutComplete = () => { - + {freeTrialEndsAt ? ( @@ -439,9 +464,7 @@ export const CheckoutComplete = () => { text={ needsPaymentMethod ? paymentMethod - ? paymentMethod.paymentType !== 'card' - ? `${capitalize(paymentMethod.paymentType)}` - : `${capitalize(paymentMethod.cardType)} ⋯ ${paymentMethod.last4}` + ? formatPaymentMethodLabel(paymentMethod) : '–' : planPeriodStart ? formatDate(new Date(planPeriodStart)) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 460c7e74d54..8a657597e43 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -22,7 +22,26 @@ import { SubscriptionBadge } from '../Subscriptions/badge'; type PaymentMethodSource = 'existing' | 'new'; -const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); +const capitalize = (name?: string | null, fallback = '') => { + if (!name) { + return fallback; + } + + return name.charAt(0).toUpperCase() + name.slice(1); +}; + +const formatPaymentMethodLabel = (method: BillingPaymentMethodResource) => { + const paymentType = method.paymentType ?? 'card'; + + if (paymentType !== 'card') { + return capitalize(paymentType, 'Payment'); + } + + const brand = capitalize(method.cardType, 'Card'); + const suffix = method.last4 ? ` ⋯ ${method.last4}` : ''; + + return `${brand}${suffix}`; +}; const HIDDEN_INPUT_NAME = 'payment_method_id'; @@ -45,6 +64,10 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; + const totalDueNowDisplay = totals.totalDueNow + ? `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}` + : `${fee.currencySymbol}0.00`; + return ( { - + @@ -345,7 +368,7 @@ const useSubmitLabel = () => { return localizationKeys('billing.startFreeTrial'); } - if (totals.totalDueNow.amount > 0) { + if (totals.totalDueNow && totals.totalDueNow.amount > 0) { return localizationKeys('billing.pay', { amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`, }); @@ -427,17 +450,10 @@ const ExistingPaymentMethodForm = withCardStateProvider( ); const options = useMemo(() => { - return paymentMethods.map(method => { - const label = - method.paymentType !== 'card' - ? `${capitalize(method.paymentType)}` - : `${capitalize(method.cardType)} ⋯ ${method.last4}`; - - return { - value: method.id, - label, - }; - }); + return paymentMethods.map(method => ({ + value: method.id, + label: formatPaymentMethodLabel(method), + })); }, [paymentMethods]); const showPaymentMethods = isImmediatePlanChange && needsPaymentMethod; diff --git a/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx b/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx index e576f960407..4e016aa273a 100644 --- a/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx +++ b/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx @@ -4,6 +4,10 @@ import { Badge, descriptors, Flex, Icon, localizationKeys, Text } from '../../cu import { CreditCard, GenericPayment } from '../../icons'; export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaymentMethodResource }) => { + const paymentType = paymentMethod.paymentType ?? 'card'; + const cardLabel = paymentMethod.cardType ?? 'card'; + const last4 = paymentMethod.last4 ? `⋯ ${paymentMethod.last4}` : null; + return ( ({ alignSelf: 'center', color: t.colors.$colorMutedForeground })} elementDescriptor={descriptors.paymentMethodRowIcon} /> @@ -22,7 +26,7 @@ export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaym elementDescriptor={descriptors.paymentMethodRowType} > {/* TODO(@COMMERCE): Localize this */} - {paymentMethod.paymentType === 'card' ? paymentMethod.cardType : paymentMethod.paymentType} + {paymentType === 'card' ? cardLabel : paymentType} ({ color: t.colors.$colorMutedForeground })} @@ -30,7 +34,7 @@ export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaym truncate elementDescriptor={descriptors.paymentMethodRowValue} > - {paymentMethod.paymentType === 'card' ? `⋯ ${paymentMethod.last4}` : null} + {paymentType === 'card' ? last4 : null} {paymentMethod.isDefault && ( (value ? value.charAt(0).toUpperCase() + value.slice(1) : ''); + +const formatPaymentMethodIdentifier = (paymentMethod: BillingPaymentMethodResource) => { + const paymentType = paymentMethod.paymentType ?? 'card'; + + if (paymentType !== 'card') { + return capitalize(paymentType) || paymentType; + } + + const brand = capitalize(paymentMethod.cardType) || 'Card'; + const last4 = paymentMethod.last4 ? ` ⋯ ${paymentMethod.last4}` : ''; + + return `${brand}${last4}`; +}; + const AddScreen = withCardStateProvider(({ onSuccess }: { onSuccess: () => void }) => { const { close } = useActionContext(); const clerk = useClerk(); @@ -62,9 +77,7 @@ const RemoveScreen = ({ const subscriberType = useSubscriberTypeContext(); const { organization } = useOrganization(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const ref = useRef( - `${paymentMethod.paymentType === 'card' ? paymentMethod.cardType : paymentMethod.paymentType} ${paymentMethod.paymentType === 'card' ? `⋯ ${paymentMethod.last4}` : '-'}`, - ); + const ref = useRef(formatPaymentMethodIdentifier(paymentMethod)); if (!ref.current) { return null; @@ -110,10 +123,18 @@ export const PaymentMethods = withCardStateProvider(() => { const { data: paymentMethods, isLoading, revalidate: revalidatePaymentMethods } = usePaymentMethods(); - const sortedPaymentMethods = useMemo( - () => paymentMethods.sort((a, b) => (a.isDefault && !b.isDefault ? -1 : 1)), - [paymentMethods], - ); + const sortedPaymentMethods = useMemo(() => { + return [...paymentMethods].sort((a, b) => { + const aDefault = a.isDefault ?? false; + const bDefault = b.isDefault ?? false; + + if (aDefault === bDefault) { + return 0; + } + + return aDefault ? -1 : 1; + }); + }, [paymentMethods]); if (!resource) { return null; diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index edc373a6968..e2449cf8057 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -25,14 +25,28 @@ export const billingTotalsFromJSON = @@ -652,15 +668,25 @@ export interface BillingCheckoutTotals { /** * The amount that needs to be immediately paid to complete the checkout. */ - totalDueNow: BillingMoneyAmount; + totalDueNow?: BillingMoneyAmount | null; /** * Any credits (like account balance or promo credits) that are being applied to the checkout. */ - credit: BillingMoneyAmount; + credit?: BillingMoneyAmount | null; /** * Any outstanding amount from previous unpaid invoices that is being collected as part of the checkout. */ - pastDue: BillingMoneyAmount; + pastDue?: BillingMoneyAmount | null; + /** + * The amount that becomes due after a free trial ends. + */ + totalDueAfterFreeTrial?: BillingMoneyAmount | null; + /** + * The proration credit applied when changing plans. + */ + proration?: { + credit: BillingMoneyAmount | null; + } | null; } /** @@ -668,8 +694,7 @@ export interface BillingCheckoutTotals { * * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface BillingStatementTotals extends Omit {} +export interface BillingStatementTotals extends Omit {} /** * The `startCheckout()` method accepts the following parameters. @@ -744,7 +769,7 @@ export interface BillingCheckoutResource extends ClerkResource { /** * The payment method being used for the checkout, such as a credit card or bank account. */ - paymentMethod?: BillingPaymentMethodResource; + paymentMethod?: BillingPaymentMethodResource | null; /** * The subscription plan details for the checkout. */ @@ -800,37 +825,37 @@ export interface BillingPayerResource extends ClerkResource { /** * The date and time when the payer was created. */ - createdAt: Date; + createdAt?: Date | null; /** * The date and time when the payer was last updated. */ - updatedAt: Date; + updatedAt?: Date | null; /** * The URL of the payer's avatar image. */ - imageUrl: string | null; + imageUrl?: string | null; /** * The unique identifier for the payer. */ - userId?: string; + userId?: string | null; /** * The email address of the payer. */ - email?: string; + email?: string | null; /** * The first name of the payer. */ - firstName?: string; + firstName?: string | null; /** * The last name of the payer. */ - lastName?: string; + lastName?: string | null; /** * The unique identifier for the organization that the payer belongs to. */ - organizationId?: string; + organizationId?: string | null; /** * The name of the organization that the payer belongs to. */ - organizationName?: string; + organizationName?: string | null; } diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 9f63617cf7d..9b9a4298a4a 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -621,9 +621,9 @@ export interface FeatureJSON extends ClerkResourceJSON { object: 'feature'; id: string; name: string; - description: string; + description: string | null; slug: string; - avatar_url: string; + avatar_url: string | null; } /** @@ -636,23 +636,23 @@ export interface BillingPlanJSON extends ClerkResourceJSON { fee: BillingMoneyAmountJSON; annual_fee: BillingMoneyAmountJSON | null; annual_monthly_fee: BillingMoneyAmountJSON | null; - amount: number; - amount_formatted: string; - annual_amount: number; - annual_amount_formatted: string; - annual_monthly_amount: number; - annual_monthly_amount_formatted: string; - currency_symbol: string; - currency: string; - description: string; + amount?: number; + amount_formatted?: string; + annual_amount?: number; + annual_amount_formatted?: string; + annual_monthly_amount?: number; + annual_monthly_amount_formatted?: string; + currency_symbol?: string; + currency?: string; + description: string | null; is_default: boolean; is_recurring: boolean; has_base_fee: boolean; for_payer_type: BillingPayerResourceType; publicly_visible: boolean; slug: string; - avatar_url: string; - features: FeatureJSON[]; + avatar_url: string | null; + features?: FeatureJSON[]; free_trial_days?: number | null; free_trial_enabled?: boolean; } @@ -663,13 +663,18 @@ export interface BillingPlanJSON extends ClerkResourceJSON { export interface BillingPaymentMethodJSON extends ClerkResourceJSON { object: 'commerce_payment_method'; id: string; - last4: string; - payment_type: 'card' | 'link'; - card_type: string; - is_default: boolean; - is_removable: boolean; + last4: string | null; + payment_type?: 'card' | 'link'; + payment_method?: string; + card_type: string | null; + is_default?: boolean; + is_removable?: boolean; status: BillingPaymentMethodStatus; - wallet_type: string | null; + wallet_type?: string | null; + expiry_year?: number | null; + expiry_month?: number | null; + created_at?: number | null; + updated_at?: number | null; } /** @@ -713,7 +718,7 @@ export interface BillingPaymentJSON extends ClerkResourceJSON { paid_at?: number; failed_at?: number; updated_at: number; - payment_method: BillingPaymentMethodJSON; + payment_method?: BillingPaymentMethodJSON | null; subscription: BillingSubscriptionItemJSON; subscription_item: BillingSubscriptionItemJSON; charge_type: BillingPaymentChargeType; @@ -787,16 +792,20 @@ export interface BillingCheckoutTotalsJSON { grand_total: BillingMoneyAmountJSON; subtotal: BillingMoneyAmountJSON; tax_total: BillingMoneyAmountJSON; - total_due_now: BillingMoneyAmountJSON; - credit: BillingMoneyAmountJSON; - past_due: BillingMoneyAmountJSON; + total_due_now?: BillingMoneyAmountJSON | null; + credit?: BillingMoneyAmountJSON | null; + past_due?: BillingMoneyAmountJSON | null; + total_due_after_free_trial?: BillingMoneyAmountJSON | null; + proration?: { + credit: BillingMoneyAmountJSON | null; + } | null; } /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface BillingStatementTotalsJSON extends Omit {} +export interface BillingStatementTotalsJSON + extends Omit {} /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. @@ -806,7 +815,7 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { id: string; external_client_secret: string; external_gateway_id: string; - payment_method?: BillingPaymentMethodJSON; + payment_method?: BillingPaymentMethodJSON | null; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; plan_period_start?: number; @@ -825,19 +834,19 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { export interface BillingPayerJSON extends ClerkResourceJSON { object: 'commerce_payer'; id: string; - created_at: number; - updated_at: number; - image_url: string | null; + created_at?: number | null; + updated_at?: number | null; + image_url?: string | null; // User attributes - user_id?: string; - email?: string; - first_name?: string; - last_name?: string; + user_id?: string | null; + email?: string | null; + first_name?: string | null; + last_name?: string | null; // Organization attributes - organization_id?: string; - organization_name?: string; + organization_id?: string | null; + organization_name?: string | null; } export interface ApiKeyJSON extends ClerkResourceJSON { From 6aba342b5ee3aa983c4367aa060b259f93cfb58f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 6 Nov 2025 17:36:14 +0200 Subject: [PATCH 08/19] wip --- .../components/Checkout/CheckoutComplete.tsx | 9 +- packages/shared/src/types/billing.ts | 1 + packages/shared/src/types/json.ts | 87 ++++++++++--------- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index e52c0031b37..1e2ee53c8fc 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -1,4 +1,5 @@ import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; +import type { BillingPaymentMethodResource } from '@clerk/shared/types'; import { useEffect, useId, useRef, useState } from 'react'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; @@ -350,7 +351,7 @@ export const CheckoutComplete = () => { localizationKey={ freeTrialEndsAt ? localizationKeys('billing.checkout.title__trialSuccess') - : totals.totalDueNow.amount > 0 + : totals.totalDueNow ? localizationKeys('billing.checkout.title__paymentSuccessful') : localizationKeys('billing.checkout.title__subscriptionSuccessful') } @@ -405,7 +406,7 @@ export const CheckoutComplete = () => { }), })} localizationKey={ - totals.totalDueNow.amount > 0 + totals.totalDueNow ? localizationKeys('billing.checkout.description__paymentSuccessful') : localizationKeys('billing.checkout.description__subscriptionSuccessful') } @@ -455,14 +456,14 @@ export const CheckoutComplete = () => { 0 || freeTrialEndsAt !== null + totals.totalDueNow || freeTrialEndsAt !== null ? localizationKeys('billing.checkout.lineItems.title__paymentMethod') : localizationKeys('billing.checkout.lineItems.title__subscriptionBegins') } /> 0 || freeTrialEndsAt !== null + totals.totalDueNow || freeTrialEndsAt !== null ? paymentMethod ? formatPaymentMethodLabel(paymentMethod) : '–' diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 26ca058949b..f3a9431ee5a 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -216,6 +216,7 @@ export interface FeatureResource extends ClerkResource { /** * The status of a payment method. + * * @inline */ export type BillingPaymentMethodStatus = 'active' | 'expired' | 'disconnected'; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 958bf695941..bdc039588ee 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -623,9 +623,9 @@ export interface FeatureJSON extends ClerkResourceJSON { object: 'feature'; id: string; name: string; - description: string; + description: string | null; slug: string; - avatar_url: string; + avatar_url: string | null; } /** @@ -636,25 +636,25 @@ export interface BillingPlanJSON extends ClerkResourceJSON { id: string; name: string; fee: BillingMoneyAmountJSON; - annual_fee: BillingMoneyAmountJSON; - annual_monthly_fee: BillingMoneyAmountJSON; - amount: number; - amount_formatted: string; - annual_amount: number; - annual_amount_formatted: string; - annual_monthly_amount: number; - annual_monthly_amount_formatted: string; - currency_symbol: string; - currency: string; - description: string; + annual_fee: BillingMoneyAmountJSON | null; + annual_monthly_fee: BillingMoneyAmountJSON | null; + amount?: number; + amount_formatted?: string; + annual_amount?: number; + annual_amount_formatted?: string; + annual_monthly_amount?: number; + annual_monthly_amount_formatted?: string; + currency_symbol?: string; + currency?: string; + description: string | null; is_default: boolean; is_recurring: boolean; has_base_fee: boolean; for_payer_type: BillingPayerResourceType; publicly_visible: boolean; slug: string; - avatar_url: string; - features: FeatureJSON[]; + avatar_url: string | null; + features?: FeatureJSON[]; free_trial_days?: number | null; free_trial_enabled?: boolean; } @@ -665,13 +665,18 @@ export interface BillingPlanJSON extends ClerkResourceJSON { export interface BillingPaymentMethodJSON extends ClerkResourceJSON { object: 'commerce_payment_method'; id: string; - last4: string; - payment_type: 'card' | 'link'; - card_type: string; - is_default: boolean; - is_removable: boolean; + last4: string | null; + payment_type?: 'card' | 'link'; + payment_method?: string; + card_type: string | null; + is_default?: boolean; + is_removable?: boolean; status: BillingPaymentMethodStatus; - wallet_type: string | null; + wallet_type?: string | null; + expiry_year?: number | null; + expiry_month?: number | null; + created_at?: number | null; + updated_at?: number | null; } /** @@ -715,7 +720,7 @@ export interface BillingPaymentJSON extends ClerkResourceJSON { paid_at?: number; failed_at?: number; updated_at: number; - payment_method: BillingPaymentMethodJSON; + payment_method?: BillingPaymentMethodJSON | null; subscription: BillingSubscriptionItemJSON; subscription_item: BillingSubscriptionItemJSON; charge_type: BillingPaymentChargeType; @@ -732,7 +737,6 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credit?: { amount: BillingMoneyAmountJSON; }; - payment_method_id: string; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; status: BillingSubscriptionStatus; @@ -790,16 +794,21 @@ export interface BillingCheckoutTotalsJSON { grand_total: BillingMoneyAmountJSON; subtotal: BillingMoneyAmountJSON; tax_total: BillingMoneyAmountJSON; - total_due_now: BillingMoneyAmountJSON; - credit: BillingMoneyAmountJSON; - past_due: BillingMoneyAmountJSON; + total_due_now?: BillingMoneyAmountJSON | null; + credit?: BillingMoneyAmountJSON | null; + past_due?: BillingMoneyAmountJSON | null; + total_due_after_free_trial?: BillingMoneyAmountJSON | null; + proration?: { + credit: BillingMoneyAmountJSON | null; + } | null; } /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface BillingStatementTotalsJSON extends Omit {} +export interface BillingStatementTotalsJSON + extends Omit {} /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. @@ -809,7 +818,7 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { id: string; external_client_secret: string; external_gateway_id: string; - payment_method?: BillingPaymentMethodJSON; + payment_method?: BillingPaymentMethodJSON | null; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; plan_period_start?: number; @@ -828,19 +837,19 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { export interface BillingPayerJSON extends ClerkResourceJSON { object: 'commerce_payer'; id: string; - created_at: number; - updated_at: number; - image_url: string | null; + created_at?: number | null; + updated_at?: number | null; + image_url?: string | null; // User attributes - user_id?: string; - email?: string; - first_name?: string; - last_name?: string; + user_id?: string | null; + email?: string | null; + first_name?: string | null; + last_name?: string | null; // Organization attributes - organization_id?: string; - organization_name?: string; + organization_id?: string | null; + organization_name?: string | null; } export interface ApiKeyJSON extends ClerkResourceJSON { @@ -856,10 +865,6 @@ export interface ApiKeyJSON extends ClerkResourceJSON { expiration: number | null; created_by: string | null; description: string | null; - /** - * This property is only present in the response from `create()`. - */ - secret?: string; last_used_at: number | null; created_at: number; updated_at: number; From d68a0424429c9ffa4a2733929532ef35ed60f1c9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 6 Nov 2025 17:52:35 +0200 Subject: [PATCH 09/19] wip --- packages/clerk-js/src/core/modules/billing/namespace.ts | 2 +- packages/clerk-js/src/core/resources/BillingPayment.ts | 4 ++-- .../clerk-js/src/core/resources/BillingSubscription.ts | 2 -- packages/shared/src/types/billing.ts | 8 ++------ packages/shared/src/types/json.ts | 4 ++++ packages/types/src/json.ts | 0 6 files changed, 9 insertions(+), 11 deletions(-) delete mode 100644 packages/types/src/json.ts diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index a9bb9c307de..f055a531d5c 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -29,7 +29,7 @@ import { export class Billing implements BillingNamespace { static readonly #pathRoot = '/billing'; - static path(subPath: string, param?: { orgId?: string }): string { + static path(subPath: string, param?: { orgId?: string | null }): string { const { orgId } = param || {}; const prefix = orgId ? `/organizations/${orgId}` : '/me'; return `${prefix}${Billing.#pathRoot}${subPath}`; diff --git a/packages/clerk-js/src/core/resources/BillingPayment.ts b/packages/clerk-js/src/core/resources/BillingPayment.ts index 23eacd2ac29..1b9758e2d5c 100644 --- a/packages/clerk-js/src/core/resources/BillingPayment.ts +++ b/packages/clerk-js/src/core/resources/BillingPayment.ts @@ -18,7 +18,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour failedAt?: Date; paidAt?: Date; updatedAt!: Date; - paymentMethod!: BillingPaymentMethodResource; + paymentMethod: BillingPaymentMethodResource | null = null; subscriptionItem!: BillingSubscriptionItemResource; chargeType!: BillingPaymentChargeType; status!: BillingPaymentStatus; @@ -38,7 +38,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour 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); - this.paymentMethod = new BillingPaymentMethod(data.payment_method); + this.paymentMethod = data.payment_method ? new BillingPaymentMethod(data.payment_method) : null; this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item); this.chargeType = data.charge_type; this.status = data.status; diff --git a/packages/clerk-js/src/core/resources/BillingSubscription.ts b/packages/clerk-js/src/core/resources/BillingSubscription.ts index 5a8c44e0987..36fe048763a 100644 --- a/packages/clerk-js/src/core/resources/BillingSubscription.ts +++ b/packages/clerk-js/src/core/resources/BillingSubscription.ts @@ -60,7 +60,6 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip export class BillingSubscriptionItem extends BaseResource implements BillingSubscriptionItemResource { id!: string; - paymentMethodId!: string; plan!: BillingPlan; planPeriod!: BillingSubscriptionPlanPeriod; status!: BillingSubscriptionStatus; @@ -87,7 +86,6 @@ export class BillingSubscriptionItem extends BaseResource implements BillingSubs } this.id = data.id; - this.paymentMethodId = data.payment_method_id; this.plan = new BillingPlan(data.plan); this.planPeriod = data.plan_period; this.status = data.status; diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index f3a9431ee5a..ba27b5a5de2 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -407,7 +407,7 @@ export interface BillingPaymentResource extends ClerkResource { /** * The payment method being used for the payment, such as credit card or bank account. */ - paymentMethod: BillingPaymentMethodResource; + paymentMethod: BillingPaymentMethodResource | null; /** * The subscription item being paid for. */ @@ -506,11 +506,6 @@ export interface BillingSubscriptionItemResource extends ClerkResource { * The unique identifier for the subscription item. */ id: string; - /** - * The unique identifier for the payment method being used for the subscription item. - */ - //TODO(@COMMERCE): should this be nullable ? - paymentMethodId: string; /** * The plan associated with the subscription item. */ @@ -700,6 +695,7 @@ export interface BillingCheckoutTotals { * * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface BillingStatementTotals extends Omit {} /** diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index bdc039588ee..f9f76486869 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -865,6 +865,10 @@ export interface ApiKeyJSON extends ClerkResourceJSON { expiration: number | null; created_by: string | null; description: string | null; + /** + * This property is only present in the response from `create()`. + */ + secret?: string; last_used_at: number | null; created_at: number; updated_at: number; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts deleted file mode 100644 index e69de29bb2d..00000000000 From bf84fa678b69ca6c6593c066283297303d782beb Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 7 Nov 2025 21:10:35 +0200 Subject: [PATCH 10/19] complete fapi --- packages/clerk-js/src/core/constants.ts | 2 +- .../src/core/resources/BillingCheckout.ts | 6 +- .../src/core/resources/BillingPayer.ts | 20 +++--- .../src/core/resources/BillingPayment.ts | 8 +-- .../core/resources/BillingPaymentMethod.ts | 25 ++++---- .../src/core/resources/BillingSubscription.ts | 20 +++--- .../src/core/resources/CommerceSettings.ts | 12 ++-- packages/clerk-js/src/utils/billing.ts | 21 ++----- packages/shared/src/types/billing.ts | 61 ++++++++++--------- packages/shared/src/types/commerceSettings.ts | 4 +- packages/shared/src/types/json.ts | 55 +++++++---------- 11 files changed, 109 insertions(+), 125 deletions(-) diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index 85df0ba2914..d7c0b640d73 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -54,4 +54,4 @@ export const SIGN_UP_MODES = { } satisfies Record; // This is the currently supported version of the Frontend API -export const SUPPORTED_FAPI_VERSION = '2025-10-01'; +export const SUPPORTED_FAPI_VERSION = '2025-11-10'; diff --git a/packages/clerk-js/src/core/resources/BillingCheckout.ts b/packages/clerk-js/src/core/resources/BillingCheckout.ts index ccdf5fc7ba5..e3229d3f0cd 100644 --- a/packages/clerk-js/src/core/resources/BillingCheckout.ts +++ b/packages/clerk-js/src/core/resources/BillingCheckout.ts @@ -28,7 +28,7 @@ export class BillingCheckout extends BaseResource implements BillingCheckoutReso status!: 'needs_confirmation' | 'completed'; totals!: BillingCheckoutTotals; isImmediatePlanChange!: boolean; - freeTrialEndsAt!: Date | null; + freeTrialEndsAt?: Date; payer!: BillingPayerResource; needsPaymentMethod!: boolean; @@ -52,7 +52,9 @@ export class BillingCheckout extends BaseResource implements BillingCheckoutReso this.status = data.status; this.totals = billingTotalsFromJSON(data.totals); this.isImmediatePlanChange = data.is_immediate_plan_change; - this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null; + if (data.free_trial_ends_at) { + this.freeTrialEndsAt = unixEpochToDate(data.free_trial_ends_at); + } this.payer = new BillingPayer(data.payer); this.needsPaymentMethod = data.needs_payment_method; return this; diff --git a/packages/clerk-js/src/core/resources/BillingPayer.ts b/packages/clerk-js/src/core/resources/BillingPayer.ts index d64387540b4..75891f699cb 100644 --- a/packages/clerk-js/src/core/resources/BillingPayer.ts +++ b/packages/clerk-js/src/core/resources/BillingPayer.ts @@ -6,14 +6,14 @@ import { BaseResource } from './internal'; export class BillingPayer extends BaseResource implements BillingPayerResource { id!: string; - createdAt?: Date | null; - updatedAt?: Date | null; - imageUrl?: string | null; - userId?: string | null; + createdAt?: Date; + updatedAt?: Date; + imageUrl?: string; + userId: string | null = null; email?: string | null; firstName?: string | null; lastName?: string | null; - organizationId?: string | null; + organizationId: string | null = null; organizationName?: string | null; constructor(data: BillingPayerJSON) { @@ -27,10 +27,12 @@ export class BillingPayer extends BaseResource implements BillingPayerResource { } this.id = data.id; - this.createdAt = - data.created_at === undefined ? undefined : data.created_at === null ? null : unixEpochToDate(data.created_at); - this.updatedAt = - data.updated_at === undefined ? undefined : data.updated_at === null ? null : unixEpochToDate(data.updated_at); + if (data.created_at) { + this.createdAt = unixEpochToDate(data.created_at); + } + if (data.updated_at) { + this.updatedAt = unixEpochToDate(data.updated_at); + } this.imageUrl = data.image_url; this.userId = data.user_id ?? null; this.email = data.email ?? null; diff --git a/packages/clerk-js/src/core/resources/BillingPayment.ts b/packages/clerk-js/src/core/resources/BillingPayment.ts index 1b9758e2d5c..890f6362a65 100644 --- a/packages/clerk-js/src/core/resources/BillingPayment.ts +++ b/packages/clerk-js/src/core/resources/BillingPayment.ts @@ -15,8 +15,8 @@ import { BaseResource, BillingPaymentMethod, BillingSubscriptionItem } from './i export class BillingPayment extends BaseResource implements BillingPaymentResource { id!: string; amount!: BillingMoneyAmount; - failedAt?: Date; - paidAt?: Date; + failedAt: Date | null = null; + paidAt: Date | null = null; updatedAt!: Date; paymentMethod: BillingPaymentMethodResource | null = null; subscriptionItem!: BillingSubscriptionItemResource; @@ -35,8 +35,8 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour this.id = data.id; this.amount = billingMoneyAmountFromJSON(data.amount); - this.paidAt = data.paid_at ? unixEpochToDate(data.paid_at) : undefined; - this.failedAt = data.failed_at ? unixEpochToDate(data.failed_at) : undefined; + this.paidAt = data.paid_at ? unixEpochToDate(data.paid_at) : null; + this.failedAt = data.failed_at ? unixEpochToDate(data.failed_at) : null; this.updatedAt = unixEpochToDate(data.updated_at); this.paymentMethod = data.payment_method ? new BillingPaymentMethod(data.payment_method) : null; this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item); diff --git a/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts b/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts index 06b68e0a1a0..84a6d31febc 100644 --- a/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts +++ b/packages/clerk-js/src/core/resources/BillingPaymentMethod.ts @@ -17,7 +17,7 @@ import { BaseResource, DeletedObject } from './internal'; export class BillingPaymentMethod extends BaseResource implements BillingPaymentMethodResource { id!: string; last4: string | null = null; - paymentType?: 'card' | 'link'; + paymentType?: 'card'; cardType: string | null = null; isDefault?: boolean; isRemovable?: boolean; @@ -39,20 +39,17 @@ export class BillingPaymentMethod extends BaseResource implements BillingPayment } this.id = data.id; - this.last4 = data.last4 ?? null; - const rawPaymentType = data.payment_type ?? data.payment_method; - this.paymentType = rawPaymentType === undefined ? undefined : (rawPaymentType as 'card' | 'link'); - this.cardType = data.card_type ?? null; - this.isDefault = data.is_default ?? undefined; - this.isRemovable = data.is_removable ?? undefined; + this.last4 = data.last4; + this.paymentType = data.payment_type; + this.cardType = data.card_type; + this.isDefault = data.is_default; + this.isRemovable = data.is_removable; this.status = data.status; - this.walletType = data.wallet_type === undefined ? undefined : data.wallet_type; - this.expiryYear = data.expiry_year ?? null; - this.expiryMonth = data.expiry_month ?? null; - this.createdAt = - data.created_at === undefined ? undefined : data.created_at === null ? null : unixEpochToDate(data.created_at); - this.updatedAt = - data.updated_at === undefined ? undefined : data.updated_at === null ? null : unixEpochToDate(data.updated_at); + this.walletType = data.wallet_type; + this.expiryYear = data.expiry_year; + this.expiryMonth = data.expiry_month; + this.createdAt = data.created_at == null ? data.created_at : unixEpochToDate(data.created_at); + this.updatedAt = data.updated_at == null ? data.updated_at : unixEpochToDate(data.updated_at); return this; } diff --git a/packages/clerk-js/src/core/resources/BillingSubscription.ts b/packages/clerk-js/src/core/resources/BillingSubscription.ts index 36fe048763a..3b80c7dbe66 100644 --- a/packages/clerk-js/src/core/resources/BillingSubscription.ts +++ b/packages/clerk-js/src/core/resources/BillingSubscription.ts @@ -23,12 +23,12 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip createdAt!: Date; pastDueAt!: Date | null; updatedAt!: Date | null; - nextPayment: { + nextPayment?: { amount: BillingMoneyAmount; date: Date; - } | null = null; + }; subscriptionItems!: BillingSubscriptionItemResource[]; - eligibleForFreeTrial?: boolean; + eligibleForFreeTrial!: boolean; constructor(data: BillingSubscriptionJSON) { super(); @@ -46,12 +46,14 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip this.updatedAt = data.updated_at ? unixEpochToDate(data.updated_at) : null; this.activeAt = unixEpochToDate(data.active_at); this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null; - this.nextPayment = data.next_payment - ? { - amount: billingMoneyAmountFromJSON(data.next_payment.amount), - date: unixEpochToDate(data.next_payment.date), - } - : null; + + if (data.next_payment) { + this.nextPayment = { + amount: billingMoneyAmountFromJSON(data.next_payment.amount), + date: unixEpochToDate(data.next_payment.date), + }; + } + this.subscriptionItems = (data.subscription_items || []).map(item => new BillingSubscriptionItem(item)); this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false); return this; diff --git a/packages/clerk-js/src/core/resources/CommerceSettings.ts b/packages/clerk-js/src/core/resources/CommerceSettings.ts index 9c95b836bfe..1f97af42579 100644 --- a/packages/clerk-js/src/core/resources/CommerceSettings.ts +++ b/packages/clerk-js/src/core/resources/CommerceSettings.ts @@ -7,7 +7,7 @@ import { BaseResource } from './internal'; */ export class CommerceSettings extends BaseResource implements CommerceSettingsResource { billing: CommerceSettingsResource['billing'] = { - stripePublishableKey: '', + stripePublishableKey: null, organization: { enabled: false, hasPaidPlans: false, @@ -28,11 +28,11 @@ export class CommerceSettings extends BaseResource implements CommerceSettingsRe return this; } - this.billing.stripePublishableKey = data.billing.stripe_publishable_key || ''; - this.billing.organization.enabled = data.billing.organization.enabled || false; - this.billing.organization.hasPaidPlans = data.billing.organization.has_paid_plans || false; - this.billing.user.enabled = data.billing.user.enabled || false; - this.billing.user.hasPaidPlans = data.billing.user.has_paid_plans || false; + this.billing.stripePublishableKey = data.billing.stripe_publishable_key; + this.billing.organization.enabled = data.billing.organization.enabled; + this.billing.organization.hasPaidPlans = data.billing.organization.has_paid_plans; + this.billing.user.enabled = data.billing.user.enabled; + this.billing.user.hasPaidPlans = data.billing.user.has_paid_plans; return this; } diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 71a4b45caba..a72868a859d 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -25,31 +25,22 @@ export const billingTotalsFromJSON = Promise; /** * A function that sets this payment method as the default for the account. Accepts the following parameters: @@ -342,7 +340,6 @@ export interface BillingPaymentMethodResource extends ClerkResource { * @param params - The parameters for the make default operation. * @returns A promise that resolves to `null`. */ - // TODO: orgId should be implied by the payment method makeDefault: (params?: MakeDefaultPaymentMethodParams) => Promise; } @@ -395,11 +392,11 @@ export interface BillingPaymentResource extends ClerkResource { /** * The date and time when the payment was successfully completed. */ - paidAt?: Date; + paidAt: Date | null; /** * The date and time when the payment failed. */ - failedAt?: Date; + failedAt: Date | null; /** * The date and time when the payment was last updated. */ @@ -588,7 +585,7 @@ export interface BillingSubscriptionResource extends ClerkResource { /** * Information about the next payment, including the amount and the date it's due. Returns null if there is no upcoming payment. */ - nextPayment: { + nextPayment?: { /** * The amount of the next payment. */ @@ -597,7 +594,7 @@ export interface BillingSubscriptionResource extends ClerkResource { * The date when the next payment is due. */ date: Date; - } | null; + }; /** * The date when the subscription became past due, or `null` if the subscription is not past due. */ @@ -621,7 +618,7 @@ export interface BillingSubscriptionResource extends ClerkResource { /** * Whether the payer is eligible for a free trial. */ - eligibleForFreeTrial?: boolean; + eligibleForFreeTrial: boolean; } /** @@ -669,25 +666,19 @@ export interface BillingCheckoutTotals { /** * The amount that needs to be immediately paid to complete the checkout. */ - totalDueNow?: BillingMoneyAmount | null; + totalDueNow: BillingMoneyAmount; /** * Any credits (like account balance or promo credits) that are being applied to the checkout. */ - credit?: BillingMoneyAmount | null; + credit: BillingMoneyAmount | null; /** * Any outstanding amount from previous unpaid invoices that is being collected as part of the checkout. */ - pastDue?: BillingMoneyAmount | null; + pastDue: BillingMoneyAmount | null; /** * The amount that becomes due after a free trial ends. */ - totalDueAfterFreeTrial?: BillingMoneyAmount | null; - /** - * The proration credit applied when changing plans. - */ - proration?: { - credit: BillingMoneyAmount | null; - } | null; + totalDueAfterFreeTrial: BillingMoneyAmount | null; } /** @@ -695,8 +686,20 @@ export interface BillingCheckoutTotals { * * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface BillingStatementTotals extends Omit {} +export interface BillingStatementTotals { + /** + * The price of the items or plan before taxes, credits, or discounts are applied. + */ + subtotal: BillingMoneyAmount; + /** + * The total amount for the checkout, including taxes and after credits/discounts are applied. This is the final amount due. + */ + grandTotal: BillingMoneyAmount; + /** + * The amount of tax included in the checkout. + */ + taxTotal: BillingMoneyAmount; +} /** * The `startCheckout()` method accepts the following parameters. @@ -771,7 +774,7 @@ export interface BillingCheckoutResource extends ClerkResource { /** * The payment method being used for the checkout, such as a credit card or bank account. */ - paymentMethod?: BillingPaymentMethodResource | null; + paymentMethod?: BillingPaymentMethodResource; /** * The subscription plan details for the checkout. */ @@ -803,7 +806,7 @@ export interface BillingCheckoutResource extends ClerkResource { /** * Unix timestamp (milliseconds) of when the free trial ends. */ - freeTrialEndsAt: Date | null; + freeTrialEndsAt?: Date; /** * The payer associated with the checkout. */ @@ -827,19 +830,19 @@ export interface BillingPayerResource extends ClerkResource { /** * The date and time when the payer was created. */ - createdAt?: Date | null; + createdAt?: Date; /** * The date and time when the payer was last updated. */ - updatedAt?: Date | null; + updatedAt?: Date; /** * The URL of the payer's avatar image. */ - imageUrl?: string | null; + imageUrl?: string; /** * The unique identifier for the payer. */ - userId?: string | null; + userId: string | null; /** * The email address of the payer. */ @@ -855,7 +858,7 @@ export interface BillingPayerResource extends ClerkResource { /** * The unique identifier for the organization that the payer belongs to. */ - organizationId?: string | null; + organizationId: string | null; /** * The name of the organization that the payer belongs to. */ diff --git a/packages/shared/src/types/commerceSettings.ts b/packages/shared/src/types/commerceSettings.ts index 7e3617bf58f..c216cdf5400 100644 --- a/packages/shared/src/types/commerceSettings.ts +++ b/packages/shared/src/types/commerceSettings.ts @@ -4,7 +4,7 @@ import type { CommerceSettingsJSONSnapshot } from './snapshots'; export interface CommerceSettingsJSON extends ClerkResourceJSON { billing: { - stripe_publishable_key: string; + stripe_publishable_key: string | null; organization: { enabled: boolean; has_paid_plans: boolean; @@ -18,7 +18,7 @@ export interface CommerceSettingsJSON extends ClerkResourceJSON { export interface CommerceSettingsResource extends ClerkResource { billing: { - stripePublishableKey: string; + stripePublishableKey: string | null; organization: { enabled: boolean; hasPaidPlans: boolean; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index f9f76486869..5bcaed20a7f 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -638,14 +638,6 @@ export interface BillingPlanJSON extends ClerkResourceJSON { fee: BillingMoneyAmountJSON; annual_fee: BillingMoneyAmountJSON | null; annual_monthly_fee: BillingMoneyAmountJSON | null; - amount?: number; - amount_formatted?: string; - annual_amount?: number; - annual_amount_formatted?: string; - annual_monthly_amount?: number; - annual_monthly_amount_formatted?: string; - currency_symbol?: string; - currency?: string; description: string | null; is_default: boolean; is_recurring: boolean; @@ -666,8 +658,7 @@ export interface BillingPaymentMethodJSON extends ClerkResourceJSON { object: 'commerce_payment_method'; id: string; last4: string | null; - payment_type?: 'card' | 'link'; - payment_method?: string; + payment_type?: 'card'; card_type: string | null; is_default?: boolean; is_removable?: boolean; @@ -717,11 +708,10 @@ export interface BillingPaymentJSON extends ClerkResourceJSON { object: 'commerce_payment'; id: string; amount: BillingMoneyAmountJSON; - paid_at?: number; - failed_at?: number; + paid_at: number | null; + failed_at: number | null; updated_at: number; payment_method?: BillingPaymentMethodJSON | null; - subscription: BillingSubscriptionItemJSON; subscription_item: BillingSubscriptionItemJSON; charge_type: BillingPaymentChargeType; status: BillingPaymentStatus; @@ -748,8 +738,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { period_end: number | null; canceled_at: number | null; past_due_at: number | null; - // TODO(@COMMERCE): Remove optional after GA. - is_free_trial?: boolean; + is_free_trial: boolean; } /** @@ -774,7 +763,7 @@ export interface BillingSubscriptionJSON extends ClerkResourceJSON { updated_at: number | null; past_due_at: number | null; subscription_items: BillingSubscriptionItemJSON[] | null; - eligible_for_free_trial?: boolean; + eligible_for_free_trial: boolean; } /** @@ -794,21 +783,20 @@ export interface BillingCheckoutTotalsJSON { grand_total: BillingMoneyAmountJSON; subtotal: BillingMoneyAmountJSON; tax_total: BillingMoneyAmountJSON; - total_due_now?: BillingMoneyAmountJSON | null; - credit?: BillingMoneyAmountJSON | null; - past_due?: BillingMoneyAmountJSON | null; - total_due_after_free_trial?: BillingMoneyAmountJSON | null; - proration?: { - credit: BillingMoneyAmountJSON | null; - } | null; + total_due_now: BillingMoneyAmountJSON; + credit: BillingMoneyAmountJSON | null; + past_due: BillingMoneyAmountJSON | null; + total_due_after_free_trial: BillingMoneyAmountJSON | null; } /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface BillingStatementTotalsJSON - extends Omit {} +export interface BillingStatementTotalsJSON { + grand_total: BillingMoneyAmountJSON; + subtotal: BillingMoneyAmountJSON; + tax_total: BillingMoneyAmountJSON; +} /** * @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](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. @@ -818,15 +806,14 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { id: string; external_client_secret: string; external_gateway_id: string; - payment_method?: BillingPaymentMethodJSON | null; + payment_method?: BillingPaymentMethodJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; plan_period_start?: number; status: 'needs_confirmation' | 'completed'; totals: BillingCheckoutTotalsJSON; is_immediate_plan_change: boolean; - // TODO(@COMMERCE): Remove optional after GA. - free_trial_ends_at: number | null; + free_trial_ends_at?: number; payer: BillingPayerJSON; needs_payment_method: boolean; } @@ -837,18 +824,18 @@ export interface BillingCheckoutJSON extends ClerkResourceJSON { export interface BillingPayerJSON extends ClerkResourceJSON { object: 'commerce_payer'; id: string; - created_at?: number | null; - updated_at?: number | null; - image_url?: string | null; + created_at?: number; + updated_at?: number; + image_url?: string; // User attributes - user_id?: string | null; + user_id: string | null; email?: string | null; first_name?: string | null; last_name?: string | null; // Organization attributes - organization_id?: string | null; + organization_id: string | null; organization_name?: string | null; } From 87f687e7c4a1fdb6e2bbea850367e0fd7f172532 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 7 Nov 2025 22:17:20 +0200 Subject: [PATCH 11/19] after review complete clerk-js --- .../src/core/resources/BillingPayer.ts | 12 ++--- .../src/core/resources/BillingPlan.ts | 4 +- .../clerk-js/src/core/resources/Feature.ts | 4 +- .../components/Checkout/CheckoutComplete.tsx | 46 ++++++------------ .../ui/components/Checkout/CheckoutForm.tsx | 48 +++++++------------ .../PaymentMethods/PaymentMethodRow.tsx | 11 ++--- .../PaymentMethods/PaymentMethods.tsx | 35 +++----------- .../shared/src/react/hooks/useCheckout.ts | 1 + 8 files changed, 53 insertions(+), 108 deletions(-) diff --git a/packages/clerk-js/src/core/resources/BillingPayer.ts b/packages/clerk-js/src/core/resources/BillingPayer.ts index 75891f699cb..7b3d5eca56d 100644 --- a/packages/clerk-js/src/core/resources/BillingPayer.ts +++ b/packages/clerk-js/src/core/resources/BillingPayer.ts @@ -34,12 +34,12 @@ export class BillingPayer extends BaseResource implements BillingPayerResource { this.updatedAt = unixEpochToDate(data.updated_at); } this.imageUrl = data.image_url; - this.userId = data.user_id ?? null; - this.email = data.email ?? null; - this.firstName = data.first_name ?? null; - this.lastName = data.last_name ?? null; - this.organizationId = data.organization_id ?? null; - this.organizationName = data.organization_name ?? null; + this.userId = data.user_id; + this.email = data.email; + this.firstName = data.first_name; + this.lastName = data.last_name; + this.organizationId = data.organization_id; + this.organizationName = data.organization_name; return this; } } diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index 831f7267460..6afbdbfe3b9 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -42,14 +42,14 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { this.fee = billingMoneyAmountFromJSON(data.fee); this.annualFee = data.annual_fee ? billingMoneyAmountFromJSON(data.annual_fee) : null; this.annualMonthlyFee = data.annual_monthly_fee ? billingMoneyAmountFromJSON(data.annual_monthly_fee) : null; - this.description = data.description ?? null; + this.description = data.description; this.isDefault = data.is_default; this.isRecurring = data.is_recurring; this.hasBaseFee = data.has_base_fee; this.forPayerType = data.for_payer_type; this.publiclyVisible = data.publicly_visible; this.slug = data.slug; - this.avatarUrl = data.avatar_url ?? null; + this.avatarUrl = data.avatar_url; this.freeTrialDays = this.withDefault(data.free_trial_days, null); this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false); this.features = (data.features || []).map(feature => new Feature(feature)); diff --git a/packages/clerk-js/src/core/resources/Feature.ts b/packages/clerk-js/src/core/resources/Feature.ts index 82698ec3f9e..d5819d3ff96 100644 --- a/packages/clerk-js/src/core/resources/Feature.ts +++ b/packages/clerk-js/src/core/resources/Feature.ts @@ -21,9 +21,9 @@ export class Feature extends BaseResource implements FeatureResource { this.id = data.id; this.name = data.name; - this.description = data.description ?? null; + this.description = data.description; this.slug = data.slug; - this.avatarUrl = data.avatar_url ?? null; + this.avatarUrl = data.avatar_url; return this; } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 1e2ee53c8fc..20c1c907920 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -1,5 +1,4 @@ import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; -import type { BillingPaymentMethodResource } from '@clerk/shared/types'; import { useEffect, useId, useRef, useState } from 'react'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; @@ -12,26 +11,8 @@ import { transitionDurationValues, transitionTiming } from '../../foundations/tr import { usePrefersReducedMotion } from '../../hooks'; import { useRouter } from '../../router'; -const capitalize = (name?: string | null, fallback = '') => { - if (!name) { - return fallback; - } - - return name.charAt(0).toUpperCase() + name.slice(1); -}; - -const formatPaymentMethodLabel = (method: BillingPaymentMethodResource) => { - const paymentType = method.paymentType ?? 'card'; - - if (paymentType !== 'card') { - return capitalize(paymentType, 'Payment'); - } - - const brand = capitalize(method.cardType, 'Card'); - const suffix = method.last4 ? ` ⋯ ${method.last4}` : ''; +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); - return `${brand}${suffix}`; -}; const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; const SuccessRing = ({ positionX, positionY }: { positionX: number; positionY: number }) => { @@ -351,7 +332,7 @@ export const CheckoutComplete = () => { localizationKey={ freeTrialEndsAt ? localizationKeys('billing.checkout.title__trialSuccess') - : totals.totalDueNow + : totals.totalDueNow.amount > 0 ? localizationKeys('billing.checkout.title__paymentSuccessful') : localizationKeys('billing.checkout.title__subscriptionSuccessful') } @@ -406,7 +387,7 @@ export const CheckoutComplete = () => { }), })} localizationKey={ - totals.totalDueNow + totals.totalDueNow.amount > 0 ? localizationKeys('billing.checkout.description__paymentSuccessful') : localizationKeys('billing.checkout.description__subscriptionSuccessful') } @@ -438,13 +419,7 @@ export const CheckoutComplete = () => { - + {freeTrialEndsAt ? ( @@ -456,16 +431,23 @@ export const CheckoutComplete = () => { 0 || freeTrialEndsAt !== null ? localizationKeys('billing.checkout.lineItems.title__paymentMethod') : localizationKeys('billing.checkout.lineItems.title__subscriptionBegins') } /> + 0 || freeTrialEndsAt !== null ? paymentMethod - ? formatPaymentMethodLabel(paymentMethod) + ? paymentMethod.paymentType !== 'card' + ? paymentMethod.paymentType + ? `${capitalize(paymentMethod.paymentType)}` + : '–' + : paymentMethod.cardType + ? `${capitalize(paymentMethod.cardType)} ⋯ ${paymentMethod.last4}` + : '–' : '–' : planPeriodStart ? formatDate(new Date(planPeriodStart)) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index ee7ed526666..26ef4a83a07 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -22,26 +22,7 @@ import { SubscriptionBadge } from '../Subscriptions/badge'; type PaymentMethodSource = 'existing' | 'new'; -const capitalize = (name?: string | null, fallback = '') => { - if (!name) { - return fallback; - } - - return name.charAt(0).toUpperCase() + name.slice(1); -}; - -const formatPaymentMethodLabel = (method: BillingPaymentMethodResource) => { - const paymentType = method.paymentType ?? 'card'; - - if (paymentType !== 'card') { - return capitalize(paymentType, 'Payment'); - } - - const brand = capitalize(method.cardType, 'Card'); - const suffix = method.last4 ? ` ⋯ ${method.last4}` : ''; - - return `${brand}${suffix}`; -}; +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); const HIDDEN_INPUT_NAME = 'payment_method_id'; @@ -64,10 +45,6 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; - const totalDueNowDisplay = totals.totalDueNow - ? `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}` - : `${fee.currencySymbol}0.00`; - return ( { - + @@ -368,7 +345,7 @@ const useSubmitLabel = () => { return localizationKeys('billing.startFreeTrial'); } - if (totals.totalDueNow && totals.totalDueNow.amount > 0) { + if (totals.totalDueNow.amount > 0) { return localizationKeys('billing.pay', { amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`, }); @@ -450,10 +427,21 @@ const ExistingPaymentMethodForm = withCardStateProvider( ); const options = useMemo(() => { - return paymentMethods.map(method => ({ - value: method.id, - label: formatPaymentMethodLabel(method), - })); + return paymentMethods.map(method => { + const label = + method.paymentType !== 'card' + ? method.paymentType + ? `${capitalize(method.paymentType)}` + : '–' + : method.cardType + ? `${capitalize(method.cardType)} ⋯ ${method.last4}` + : '–'; + + return { + value: method.id, + label, + }; + }); }, [paymentMethods]); const showPaymentMethods = isImmediatePlanChange && needsPaymentMethod; diff --git a/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx b/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx index 6e3613dc054..247ec52e18b 100644 --- a/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx +++ b/packages/clerk-js/src/ui/components/PaymentMethods/PaymentMethodRow.tsx @@ -4,10 +4,6 @@ import { Badge, descriptors, Flex, Icon, localizationKeys, Text } from '../../cu import { CreditCard, GenericPayment } from '../../icons'; export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaymentMethodResource }) => { - const paymentType = paymentMethod.paymentType ?? 'card'; - const cardLabel = paymentMethod.cardType ?? 'card'; - const last4 = paymentMethod.last4 ? `⋯ ${paymentMethod.last4}` : null; - return ( ({ alignSelf: 'center', color: t.colors.$colorMutedForeground })} elementDescriptor={descriptors.paymentMethodRowIcon} /> @@ -25,8 +21,7 @@ export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaym truncate elementDescriptor={descriptors.paymentMethodRowType} > - {/* TODO(@COMMERCE): Localize this */} - {paymentType === 'card' ? cardLabel : paymentType} + {paymentMethod.paymentType === 'card' ? paymentMethod.cardType : paymentMethod.paymentType} ({ color: t.colors.$colorMutedForeground })} @@ -34,7 +29,7 @@ export const PaymentMethodRow = ({ paymentMethod }: { paymentMethod: BillingPaym truncate elementDescriptor={descriptors.paymentMethodRowValue} > - {paymentType === 'card' ? last4 : null} + {paymentMethod.paymentType === 'card' ? `⋯ ${paymentMethod.last4}` : null} {paymentMethod.isDefault && ( (value ? value.charAt(0).toUpperCase() + value.slice(1) : ''); - -const formatPaymentMethodIdentifier = (paymentMethod: BillingPaymentMethodResource) => { - const paymentType = paymentMethod.paymentType ?? 'card'; - - if (paymentType !== 'card') { - return capitalize(paymentType) || paymentType; - } - - const brand = capitalize(paymentMethod.cardType) || 'Card'; - const last4 = paymentMethod.last4 ? ` ⋯ ${paymentMethod.last4}` : ''; - - return `${brand}${last4}`; -}; - const AddScreen = withCardStateProvider(({ onSuccess }: { onSuccess: () => void }) => { const { close } = useActionContext(); const clerk = useClerk(); @@ -77,7 +62,9 @@ const RemoveScreen = ({ const subscriberType = useSubscriberTypeContext(); const { organization } = useOrganization(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const ref = useRef(formatPaymentMethodIdentifier(paymentMethod)); + const ref = useRef( + `${paymentMethod.paymentType === 'card' ? paymentMethod.cardType : paymentMethod.paymentType} ${paymentMethod.paymentType === 'card' ? `⋯ ${paymentMethod.last4}` : '-'}`, + ); if (!ref.current) { return null; @@ -123,18 +110,10 @@ export const PaymentMethods = withCardStateProvider(() => { const { data: paymentMethods, isLoading, revalidate: revalidatePaymentMethods } = usePaymentMethods(); - const sortedPaymentMethods = useMemo(() => { - return [...paymentMethods].sort((a, b) => { - const aDefault = a.isDefault ?? false; - const bDefault = b.isDefault ?? false; - - if (aDefault === bDefault) { - return 0; - } - - return aDefault ? -1 : 1; - }); - }, [paymentMethods]); + const sortedPaymentMethods = useMemo( + () => paymentMethods.sort((a, b) => (a.isDefault && !b.isDefault ? -1 : 1)), + [paymentMethods], + ); if (!resource) { return null; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 9715384c46a..dd36a2e553e 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -114,6 +114,7 @@ export const useCheckout = (options?: Params): __experimental_UseCheckoutReturn freeTrialEndsAt: null, payer: null, needsPaymentMethod: null, + planPeriodStart: null, } satisfies ForceNull; } const { From 632d4b43eadaa0189ee55a05f7da99be21db6346 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Sun, 9 Nov 2025 16:17:56 +0200 Subject: [PATCH 12/19] update to use `totalDueAfterFreeTrial` --- integration/tests/pricing-table.test.ts | 54 +++++++++++++++++-- .../ui/components/Checkout/CheckoutForm.tsx | 4 +- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index ee3ff03c024..50933c74d21 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -1,3 +1,4 @@ +import type { Locator } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { appConfigs } from '../presets'; @@ -278,7 +279,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl // Verify checkout shows trial details await expect(u.po.checkout.root.getByText('Checkout')).toBeVisible(); await expect(u.po.checkout.root.getByText('Free trial')).toBeVisible(); - await expect(u.po.checkout.root.getByText('Total Due after')).toBeVisible(); + const title = /^Total Due after trial ends in \d+ days$/i; + await expect(matchLineItem(u.po.checkout.root, title, '$999.00')).toBeVisible(); await u.po.checkout.fillTestCard(); await u.po.checkout.clickPayOrSubscribe(); @@ -286,6 +288,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl await expect(u.po.checkout.root.getByText(/Trial.*successfully.*started/i)).toBeVisible({ timeout: 15_000, }); + + const footer = u.po.checkout.root.locator('.cl-drawerFooter'); + await expect(matchLineItem(footer, 'Total paid', '$0.00')).toBeVisible(); + await expect(matchLineItem(footer, 'Trial ends on')).toBeVisible(); + await expect(matchLineItem(footer, 'Payment method', 'Visa ⋯ 4242')).toBeVisible(); + expect(await countLineItems(footer)).toBe(3); + await u.po.checkout.confirmAndContinue(); await u.po.page.goToRelative('/pricing-table'); @@ -344,12 +353,18 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl // Verify checkout shows trial details await expect(u.po.checkout.root.getByText('Checkout')).toBeVisible(); await expect(u.po.checkout.root.getByText('Free trial')).toBeHidden(); - await expect(u.po.checkout.root.getByText('Total Due after')).toBeHidden(); - await expect(u.po.checkout.root.getByText('Total Due Today')).toBeVisible(); + + await expect(matchLineItem(u.po.checkout.root, 'Total Due after')).toBeHidden(); + await expect(matchLineItem(u.po.checkout.root, 'Subtotal', '$999.00')).toBeVisible(); + await expect(matchLineItem(u.po.checkout.root, 'Total Due Today', '$999.00')).toBeVisible(); + expect(await countLineItems(u.po.checkout.root)).toBe(3); await u.po.checkout.root.getByRole('button', { name: /^pay\s\$/i }).waitFor({ state: 'visible' }); await u.po.checkout.clickPayOrSubscribe(); - await expect(u.po.page.getByText('Payment was successful!')).toBeVisible(); + await expect(u.po.checkout.root.getByText('Payment was successful!')).toBeVisible(); + await expect(matchLineItem(footer, 'Total paid', '$999.00')).toBeVisible(); + await expect(matchLineItem(footer, 'Payment method', 'Visa ⋯ 4242')).toBeVisible(); + expect(await countLineItems(footer)).toBe(2); await u.po.checkout.confirmAndContinue(); await u.po.page.goToRelative('/user'); @@ -722,3 +737,34 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl }); }); }); + +/** + * Helper to match a line item by its title and optionally its description. + * Line items are rendered as Clerk LineItems components with element descriptors: + * - .cl-lineItemsTitle contains the title + * - .cl-lineItemsDescription contains the description (immediately following the title) + */ +function matchLineItem(root: Locator, title: string | RegExp, description?: string | RegExp): Locator { + // Find the title element using the Clerk-generated class + const titleElement = root.locator('.cl-lineItemsTitle').filter({ hasText: title }); + + // If no description is provided, return the title element + if (description === undefined) { + return titleElement; + } + + // Get the next sibling description element using the Clerk-generated class + const descriptionElement = titleElement + .locator('xpath=following-sibling::*[1][contains(@class, "cl-lineItemsDescription")]') + .filter({ hasText: description }); + + return descriptionElement; +} + +/** + * Helper to count the number of line items within a given root element. + * Line items are rendered as Clerk LineItems components where each .cl-lineItemsTitle represents a line item. + */ +async function countLineItems(root: Locator): Promise { + return await root.locator('.cl-lineItemsTitle').count(); +} diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 26ef4a83a07..7d7c7a4f2b1 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -101,7 +101,7 @@ export const CheckoutForm = withCardStateProvider(() => { )} - {!!freeTrialEndsAt && !!plan.freeTrialDays && ( + {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial && ( { })} /> )} From 820be73697d1c41beb5d79a77f0c96c5154ed2a7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Sun, 9 Nov 2025 18:06:46 +0200 Subject: [PATCH 13/19] tests for statements and payment attempts --- integration/tests/pricing-table.test.ts | 104 ++++++++++++++++++ .../playwright/unstable/page-objects/index.ts | 4 + .../unstable/page-objects/paymentAttempt.ts | 35 ++++++ .../unstable/page-objects/statement.ts | 38 +++++++ .../unstable/page-objects/userProfile.ts | 34 ++++++ 5 files changed, 215 insertions(+) create mode 100644 packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts create mode 100644 packages/testing/src/playwright/unstable/page-objects/statement.ts diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index 50933c74d21..75e9e5abc09 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -670,6 +670,110 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl await fakeUser.deleteIfExists(); }); + test('displays billing history and navigates through statement and payment attempt details', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + const fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + + try { + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + + await u.po.page.goToRelative('/user'); + await u.po.userProfile.waitForMounted(); + await u.po.userProfile.switchToBillingTab(); + + await u.po.userProfile.openBillingStatementsTab(); + await u.po.userProfile.waitForBillingTableRows(); + await expect(u.po.userProfile.getBillingEmptyStateMessage('No statements to display')).toBeVisible(); + + await u.po.page.goToRelative('/user'); + await u.po.userProfile.waitForMounted(); + await u.po.userProfile.switchToBillingTab(); + await u.po.page.getByRole('button', { name: 'Switch plans' }).click(); + + await u.po.pricingTable.waitForMounted(); + await u.po.pricingTable.startCheckout({ planSlug: 'plus' }); + await u.po.checkout.waitForMounted(); + await u.po.checkout.fillTestCard(); + await u.po.checkout.clickPayOrSubscribe(); + await expect(u.po.page.getByText('Payment was successful!')).toBeVisible({ + timeout: 15000, + }); + await u.po.checkout.confirmAndContinue(); + + await u.po.pricingTable.startCheckout({ planSlug: 'pro', shouldSwitch: true }); + await u.po.checkout.waitForMounted(); + await u.po.checkout.root.getByText('Add payment method').click(); + await u.po.checkout.fillCard({ + number: '4100000000000019', + expiration: '1234', + cvc: '123', + country: 'United States', + zip: '12345', + }); + await u.po.checkout.clickPayOrSubscribe(); + await expect(u.po.checkout.root.getByText('The card was declined.').first()).toBeVisible({ + timeout: 15000, + }); + await u.po.checkout.closeDrawer(); + + await u.po.page.goToRelative('/user'); + await u.po.userProfile.waitForMounted(); + await u.po.userProfile.switchToBillingTab(); + + await u.po.userProfile.openBillingStatementsTab(); + const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); + await u.po.userProfile.waitForBillingTableRows({ hasText: new RegExp(date, 'i') }); + // await u.po.userProfile.waitForBillingTableRows(); + await expect(u.po.userProfile.getBillingEmptyStateMessage('No statements to display')).toBeHidden(); + + const firstStatementRow = u.po.userProfile.getActiveBillingTableRows().first(); + await firstStatementRow.click(); + + await u.po.statement.waitForMounted(); + await expect(u.po.statement.getPlanLineItems().filter({ hasText: /Plus/i }).first()).toBeVisible(); + + const statementTotalText = (await u.po.statement.getTotalPaidValue().textContent())?.trim(); + expect(statementTotalText).toBeTruthy(); + + await u.po.statement.clickViewPaymentButton(); + await u.po.paymentAttempt.waitForMounted(); + await expect(u.po.paymentAttempt.getStatusBadge()).toHaveText(/paid/i); + + const paymentTotalText = (await u.po.paymentAttempt.getTotalAmount().textContent())?.trim(); + expect(paymentTotalText).toBe(statementTotalText); + + await expect(u.po.paymentAttempt.getLineItemTitles().filter({ hasText: /Plus/i }).first()).toBeVisible(); + + await u.po.paymentAttempt.goBackToPaymentsList(); + await u.po.userProfile.waitForMounted(); + await u.po.userProfile.waitForBillingTableRows({ hasText: /paid/i }); + + await u.po.userProfile.openBillingPaymentsTab(); + await u.po.userProfile.waitForBillingTableRows({ hasText: /Failed/i }); + await expect(u.po.userProfile.getBillingEmptyStateMessage('No payment history')).toBeHidden(); + + const failedPaymentRow = u.po.userProfile + .getActiveBillingTableRows() + .filter({ hasText: /Failed/i }) + .first(); + await failedPaymentRow.click(); + + await u.po.paymentAttempt.waitForMounted(); + await expect(u.po.paymentAttempt.getStatusBadge()).toHaveText(/failed/i); + await expect(u.po.paymentAttempt.getLineItemTitles().filter({ hasText: /Pro/i }).first()).toBeVisible(); + + await u.po.paymentAttempt.goBackToPaymentsList(); + } finally { + await fakeUser.deleteIfExists(); + } + }); + test('adds two payment methods and sets the last as default', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index 01c5836e8ad..832ea333d20 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -9,6 +9,8 @@ import { createImpersonationPageObject } from './impersonation'; import { createKeylessPopoverPageObject } from './keylessPopover'; import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcher'; import { createPlanDetailsPageObject } from './planDetails'; +import { createStatementPageObject } from './statement'; +import { createPaymentAttemptPageObject } from './paymentAttempt'; import { createPricingTablePageObject } from './pricingTable'; import { createSessionTaskComponentPageObject } from './sessionTask'; import { createSignInComponentPageObject } from './signIn'; @@ -42,6 +44,8 @@ export const createPageObjects = ({ keylessPopover: createKeylessPopoverPageObject(testArgs), organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs), pricingTable: createPricingTablePageObject(testArgs), + statement: createStatementPageObject(testArgs), + paymentAttempt: createPaymentAttemptPageObject(testArgs), sessionTask: createSessionTaskComponentPageObject(testArgs), signIn: createSignInComponentPageObject(testArgs), signUp: createSignUpComponentPageObject(testArgs), diff --git a/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts b/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts new file mode 100644 index 00000000000..dbc06829402 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts @@ -0,0 +1,35 @@ +import type { EnhancedPage } from './app'; +import { common } from './common'; + +export const createPaymentAttemptPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + const root = page.locator('.cl-paymentAttemptRoot'); + const self = { + ...common(testArgs), + waitForMounted: () => { + return root.waitFor({ state: 'visible', timeout: 15000 }); + }, + waitForUnmounted: () => { + return root.waitFor({ state: 'detached', timeout: 15000 }); + }, + goBackToPaymentsList: async () => { + await Promise.all([ + page.waitForURL(/tab=payments/, { timeout: 15000 }), + page.getByRole('link', { name: /Payments/i }).click(), + ]); + await root.waitFor({ state: 'detached', timeout: 15000 }).catch(() => {}); + }, + getStatusBadge: () => { + return self.root.locator('.cl-paymentAttemptHeaderBadge'); + }, + getTotalAmount: () => { + return self.root.locator('.cl-paymentAttemptFooterValue'); + }, + getLineItemTitles: () => { + return self.root.locator('.cl-lineItemsTitle'); + }, + root, + }; + + return self; +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/statement.ts b/packages/testing/src/playwright/unstable/page-objects/statement.ts new file mode 100644 index 00000000000..2d3deb5059d --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/statement.ts @@ -0,0 +1,38 @@ +import type { EnhancedPage } from './app'; +import { common } from './common'; + +export const createStatementPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + const root = page.locator('.cl-statementRoot'); + const self = { + ...common(testArgs), + waitForMounted: () => { + return root.waitFor({ state: 'visible', timeout: 15000 }); + }, + waitForUnmounted: () => { + return root.waitFor({ state: 'detached', timeout: 15000 }); + }, + goBackToStatementsList: async () => { + await Promise.all([ + page.waitForURL(/tab=statements/, { timeout: 15000 }), + page.getByRole('link', { name: /Statements/i }).click(), + ]); + await root.waitFor({ state: 'detached', timeout: 15000 }).catch(() => {}); + }, + clickViewPaymentButton: async () => { + await self.root + .getByRole('button', { name: /View payment/i }) + .first() + .click(); + }, + getPlanLineItems: () => { + return self.root.locator('.cl-statementSectionContentDetailsHeaderTitle'); + }, + getTotalPaidValue: () => { + return self.root.locator('.cl-statementFooterValue'); + }, + root, + }; + + return self; +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/userProfile.ts b/packages/testing/src/playwright/unstable/page-objects/userProfile.ts index f80b591e7c3..adf2d38dc43 100644 --- a/packages/testing/src/playwright/unstable/page-objects/userProfile.ts +++ b/packages/testing/src/playwright/unstable/page-objects/userProfile.ts @@ -17,6 +17,40 @@ export const createUserProfileComponentPageObject = (testArgs: { page: EnhancedP switchToBillingTab: async () => { await page.getByText(/Billing/i).click(); }, + openBillingStatementsTab: async () => { + await page.getByRole('tab', { name: /Statements/i }).click(); + await self.waitForActiveBillingTabPanel(); + }, + openBillingPaymentsTab: async () => { + await page.getByRole('tab', { name: /Payments/i }).click(); + await self.waitForActiveBillingTabPanel(); + }, + waitForActiveBillingTabPanel: () => { + return page.locator('.cl-userProfile-root .cl-profilePage .cl-table').waitFor({ + state: 'visible', + timeout: 15000, + }); + }, + getActiveBillingTableRows: () => { + return page.locator('.cl-userProfile-root .cl-profilePage .cl-tableBody .cl-tableRow'); + }, + waitForBillingTableRows: async (options?: { hasText?: string | RegExp }) => { + const rows = self.getActiveBillingTableRows(); + if (options?.hasText) { + await rows + .filter({ + hasText: options.hasText, + }) + .first() + .waitFor({ state: 'visible', timeout: 15000 }); + } else { + await rows.first().waitFor({ state: 'visible', timeout: 15000 }); + } + return rows; + }, + getBillingEmptyStateMessage: (text: string | RegExp) => { + return page.locator('.cl-userProfile-root .cl-table').getByText(text); + }, waitForMounted: () => { return page.waitForSelector('.cl-userProfile-root', { state: 'attached' }); }, From fcdc83f27740423d3f9f17828b281748516e3886 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 10 Nov 2025 11:59:22 +0200 Subject: [PATCH 14/19] Revert "tests for statements and payment attempts" This reverts commit 820be73697d1c41beb5d79a77f0c96c5154ed2a7. --- integration/tests/pricing-table.test.ts | 104 ------------------ .../playwright/unstable/page-objects/index.ts | 4 - .../unstable/page-objects/paymentAttempt.ts | 35 ------ .../unstable/page-objects/statement.ts | 38 ------- .../unstable/page-objects/userProfile.ts | 34 ------ 5 files changed, 215 deletions(-) delete mode 100644 packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts delete mode 100644 packages/testing/src/playwright/unstable/page-objects/statement.ts diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index 75e9e5abc09..50933c74d21 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -670,110 +670,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl await fakeUser.deleteIfExists(); }); - test('displays billing history and navigates through statement and payment attempt details', async ({ - page, - context, - }) => { - const u = createTestUtils({ app, page, context }); - - const fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - - try { - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - - await u.po.page.goToRelative('/user'); - await u.po.userProfile.waitForMounted(); - await u.po.userProfile.switchToBillingTab(); - - await u.po.userProfile.openBillingStatementsTab(); - await u.po.userProfile.waitForBillingTableRows(); - await expect(u.po.userProfile.getBillingEmptyStateMessage('No statements to display')).toBeVisible(); - - await u.po.page.goToRelative('/user'); - await u.po.userProfile.waitForMounted(); - await u.po.userProfile.switchToBillingTab(); - await u.po.page.getByRole('button', { name: 'Switch plans' }).click(); - - await u.po.pricingTable.waitForMounted(); - await u.po.pricingTable.startCheckout({ planSlug: 'plus' }); - await u.po.checkout.waitForMounted(); - await u.po.checkout.fillTestCard(); - await u.po.checkout.clickPayOrSubscribe(); - await expect(u.po.page.getByText('Payment was successful!')).toBeVisible({ - timeout: 15000, - }); - await u.po.checkout.confirmAndContinue(); - - await u.po.pricingTable.startCheckout({ planSlug: 'pro', shouldSwitch: true }); - await u.po.checkout.waitForMounted(); - await u.po.checkout.root.getByText('Add payment method').click(); - await u.po.checkout.fillCard({ - number: '4100000000000019', - expiration: '1234', - cvc: '123', - country: 'United States', - zip: '12345', - }); - await u.po.checkout.clickPayOrSubscribe(); - await expect(u.po.checkout.root.getByText('The card was declined.').first()).toBeVisible({ - timeout: 15000, - }); - await u.po.checkout.closeDrawer(); - - await u.po.page.goToRelative('/user'); - await u.po.userProfile.waitForMounted(); - await u.po.userProfile.switchToBillingTab(); - - await u.po.userProfile.openBillingStatementsTab(); - const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); - await u.po.userProfile.waitForBillingTableRows({ hasText: new RegExp(date, 'i') }); - // await u.po.userProfile.waitForBillingTableRows(); - await expect(u.po.userProfile.getBillingEmptyStateMessage('No statements to display')).toBeHidden(); - - const firstStatementRow = u.po.userProfile.getActiveBillingTableRows().first(); - await firstStatementRow.click(); - - await u.po.statement.waitForMounted(); - await expect(u.po.statement.getPlanLineItems().filter({ hasText: /Plus/i }).first()).toBeVisible(); - - const statementTotalText = (await u.po.statement.getTotalPaidValue().textContent())?.trim(); - expect(statementTotalText).toBeTruthy(); - - await u.po.statement.clickViewPaymentButton(); - await u.po.paymentAttempt.waitForMounted(); - await expect(u.po.paymentAttempt.getStatusBadge()).toHaveText(/paid/i); - - const paymentTotalText = (await u.po.paymentAttempt.getTotalAmount().textContent())?.trim(); - expect(paymentTotalText).toBe(statementTotalText); - - await expect(u.po.paymentAttempt.getLineItemTitles().filter({ hasText: /Plus/i }).first()).toBeVisible(); - - await u.po.paymentAttempt.goBackToPaymentsList(); - await u.po.userProfile.waitForMounted(); - await u.po.userProfile.waitForBillingTableRows({ hasText: /paid/i }); - - await u.po.userProfile.openBillingPaymentsTab(); - await u.po.userProfile.waitForBillingTableRows({ hasText: /Failed/i }); - await expect(u.po.userProfile.getBillingEmptyStateMessage('No payment history')).toBeHidden(); - - const failedPaymentRow = u.po.userProfile - .getActiveBillingTableRows() - .filter({ hasText: /Failed/i }) - .first(); - await failedPaymentRow.click(); - - await u.po.paymentAttempt.waitForMounted(); - await expect(u.po.paymentAttempt.getStatusBadge()).toHaveText(/failed/i); - await expect(u.po.paymentAttempt.getLineItemTitles().filter({ hasText: /Pro/i }).first()).toBeVisible(); - - await u.po.paymentAttempt.goBackToPaymentsList(); - } finally { - await fakeUser.deleteIfExists(); - } - }); - test('adds two payment methods and sets the last as default', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index 832ea333d20..01c5836e8ad 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -9,8 +9,6 @@ import { createImpersonationPageObject } from './impersonation'; import { createKeylessPopoverPageObject } from './keylessPopover'; import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcher'; import { createPlanDetailsPageObject } from './planDetails'; -import { createStatementPageObject } from './statement'; -import { createPaymentAttemptPageObject } from './paymentAttempt'; import { createPricingTablePageObject } from './pricingTable'; import { createSessionTaskComponentPageObject } from './sessionTask'; import { createSignInComponentPageObject } from './signIn'; @@ -44,8 +42,6 @@ export const createPageObjects = ({ keylessPopover: createKeylessPopoverPageObject(testArgs), organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs), pricingTable: createPricingTablePageObject(testArgs), - statement: createStatementPageObject(testArgs), - paymentAttempt: createPaymentAttemptPageObject(testArgs), sessionTask: createSessionTaskComponentPageObject(testArgs), signIn: createSignInComponentPageObject(testArgs), signUp: createSignUpComponentPageObject(testArgs), diff --git a/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts b/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts deleted file mode 100644 index dbc06829402..00000000000 --- a/packages/testing/src/playwright/unstable/page-objects/paymentAttempt.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { EnhancedPage } from './app'; -import { common } from './common'; - -export const createPaymentAttemptPageObject = (testArgs: { page: EnhancedPage }) => { - const { page } = testArgs; - const root = page.locator('.cl-paymentAttemptRoot'); - const self = { - ...common(testArgs), - waitForMounted: () => { - return root.waitFor({ state: 'visible', timeout: 15000 }); - }, - waitForUnmounted: () => { - return root.waitFor({ state: 'detached', timeout: 15000 }); - }, - goBackToPaymentsList: async () => { - await Promise.all([ - page.waitForURL(/tab=payments/, { timeout: 15000 }), - page.getByRole('link', { name: /Payments/i }).click(), - ]); - await root.waitFor({ state: 'detached', timeout: 15000 }).catch(() => {}); - }, - getStatusBadge: () => { - return self.root.locator('.cl-paymentAttemptHeaderBadge'); - }, - getTotalAmount: () => { - return self.root.locator('.cl-paymentAttemptFooterValue'); - }, - getLineItemTitles: () => { - return self.root.locator('.cl-lineItemsTitle'); - }, - root, - }; - - return self; -}; diff --git a/packages/testing/src/playwright/unstable/page-objects/statement.ts b/packages/testing/src/playwright/unstable/page-objects/statement.ts deleted file mode 100644 index 2d3deb5059d..00000000000 --- a/packages/testing/src/playwright/unstable/page-objects/statement.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { EnhancedPage } from './app'; -import { common } from './common'; - -export const createStatementPageObject = (testArgs: { page: EnhancedPage }) => { - const { page } = testArgs; - const root = page.locator('.cl-statementRoot'); - const self = { - ...common(testArgs), - waitForMounted: () => { - return root.waitFor({ state: 'visible', timeout: 15000 }); - }, - waitForUnmounted: () => { - return root.waitFor({ state: 'detached', timeout: 15000 }); - }, - goBackToStatementsList: async () => { - await Promise.all([ - page.waitForURL(/tab=statements/, { timeout: 15000 }), - page.getByRole('link', { name: /Statements/i }).click(), - ]); - await root.waitFor({ state: 'detached', timeout: 15000 }).catch(() => {}); - }, - clickViewPaymentButton: async () => { - await self.root - .getByRole('button', { name: /View payment/i }) - .first() - .click(); - }, - getPlanLineItems: () => { - return self.root.locator('.cl-statementSectionContentDetailsHeaderTitle'); - }, - getTotalPaidValue: () => { - return self.root.locator('.cl-statementFooterValue'); - }, - root, - }; - - return self; -}; diff --git a/packages/testing/src/playwright/unstable/page-objects/userProfile.ts b/packages/testing/src/playwright/unstable/page-objects/userProfile.ts index adf2d38dc43..f80b591e7c3 100644 --- a/packages/testing/src/playwright/unstable/page-objects/userProfile.ts +++ b/packages/testing/src/playwright/unstable/page-objects/userProfile.ts @@ -17,40 +17,6 @@ export const createUserProfileComponentPageObject = (testArgs: { page: EnhancedP switchToBillingTab: async () => { await page.getByText(/Billing/i).click(); }, - openBillingStatementsTab: async () => { - await page.getByRole('tab', { name: /Statements/i }).click(); - await self.waitForActiveBillingTabPanel(); - }, - openBillingPaymentsTab: async () => { - await page.getByRole('tab', { name: /Payments/i }).click(); - await self.waitForActiveBillingTabPanel(); - }, - waitForActiveBillingTabPanel: () => { - return page.locator('.cl-userProfile-root .cl-profilePage .cl-table').waitFor({ - state: 'visible', - timeout: 15000, - }); - }, - getActiveBillingTableRows: () => { - return page.locator('.cl-userProfile-root .cl-profilePage .cl-tableBody .cl-tableRow'); - }, - waitForBillingTableRows: async (options?: { hasText?: string | RegExp }) => { - const rows = self.getActiveBillingTableRows(); - if (options?.hasText) { - await rows - .filter({ - hasText: options.hasText, - }) - .first() - .waitFor({ state: 'visible', timeout: 15000 }); - } else { - await rows.first().waitFor({ state: 'visible', timeout: 15000 }); - } - return rows; - }, - getBillingEmptyStateMessage: (text: string | RegExp) => { - return page.locator('.cl-userProfile-root .cl-table').getByText(text); - }, waitForMounted: () => { return page.waitForSelector('.cl-userProfile-root', { state: 'attached' }); }, From da2e8b613f9f90727655df4eb3eb685bbf942738 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 10 Nov 2025 12:55:01 +0200 Subject: [PATCH 15/19] prev bapi --- .../backend/src/api/resources/CommercePlan.ts | 35 ++++++++----------- .../api/resources/CommerceSubscriptionItem.ts | 33 +++++++++-------- packages/backend/src/api/resources/JSON.ts | 13 ++++--- packages/backend/src/constants.ts | 2 +- 4 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/backend/src/api/resources/CommercePlan.ts b/packages/backend/src/api/resources/CommercePlan.ts index 9b1348e7c0e..64bd9b5fd73 100644 --- a/packages/backend/src/api/resources/CommercePlan.ts +++ b/packages/backend/src/api/resources/CommercePlan.ts @@ -1,4 +1,4 @@ -import type { BillingMoneyAmount } from '@clerk/types'; +import type { BillingMoneyAmount, BillingMoneyAmountJSON } from '@clerk/shared/types'; import { Feature } from './Feature'; import type { BillingPlanJSON } from './JSON'; @@ -14,10 +14,6 @@ export class BillingPlan { * The unique identifier for the plan. */ readonly id: string, - /** - * The ID of the product the plan belongs to. - */ - readonly productId: string, /** * The name of the plan. */ @@ -69,21 +65,22 @@ export class BillingPlan { ) {} static fromJSON(data: BillingPlanJSON): BillingPlan { - const formatAmountJSON = (fee: BillingPlanJSON['fee'] | null | undefined): BillingMoneyAmount | null => { - if (!fee) { - return null; - } - - return { - amount: fee.amount, - amountFormatted: fee.amount_formatted, - currency: fee.currency, - currencySymbol: fee.currency_symbol, - }; + const formatAmountJSON = ( + fee: T, + ): T extends null ? null : BillingMoneyAmount => { + return ( + fee + ? { + amount: fee.amount, + amountFormatted: fee.amount_formatted, + currency: fee.currency, + currencySymbol: fee.currency_symbol, + } + : null + ) as T extends null ? null : BillingMoneyAmount; }; return new BillingPlan( data.id, - data.product_id, data.name, data.slug, data.description ?? null, @@ -91,9 +88,7 @@ export class BillingPlan { data.is_recurring, data.has_base_fee, data.publicly_visible, - // fee is required and should not be null in API responses - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - formatAmountJSON(data.fee)!, + formatAmountJSON(data.fee), formatAmountJSON(data.annual_fee), formatAmountJSON(data.annual_monthly_fee), data.for_payer_type, diff --git a/packages/backend/src/api/resources/CommerceSubscriptionItem.ts b/packages/backend/src/api/resources/CommerceSubscriptionItem.ts index 7a281e84b1c..e1e477e0605 100644 --- a/packages/backend/src/api/resources/CommerceSubscriptionItem.ts +++ b/packages/backend/src/api/resources/CommerceSubscriptionItem.ts @@ -29,20 +29,23 @@ export class BillingSubscriptionItem { /** * The next payment information. */ - readonly nextPayment: { - /** - * The amount of the next payment. - */ - amount: number; - /** - * Unix timestamp (milliseconds) of when the next payment is scheduled. - */ - date: number; - } | null, + readonly nextPayment: + | { + /** + * The amount of the next payment. + */ + amount: number; + /** + * Unix timestamp (milliseconds) of when the next payment is scheduled. + */ + date: number; + } + | null + | undefined, /** * The current amount for the subscription item. */ - readonly amount: BillingMoneyAmount | null | undefined, + readonly amount: BillingMoneyAmount | undefined, /** * The plan associated with this subscription item. */ @@ -78,7 +81,7 @@ export class BillingSubscriptionItem { /** * The payer ID. */ - readonly payerId: string, + readonly payerId: string | undefined, /** * Whether this subscription item is currently in a free trial period. */ @@ -86,7 +89,7 @@ export class BillingSubscriptionItem { /** * The lifetime amount paid for this subscription item. */ - readonly lifetimePaid?: BillingMoneyAmount | null, + readonly lifetimePaid?: BillingMoneyAmount, ) {} static fromJSON(data: BillingSubscriptionItemJSON): BillingSubscriptionItem { @@ -111,7 +114,7 @@ export class BillingSubscriptionItem { data.plan_period, data.period_start, data.next_payment, - formatAmountJSON(data.amount), + formatAmountJSON(data.amount) ?? undefined, data.plan ? BillingPlan.fromJSON(data.plan) : null, data.plan_id ?? null, data.created_at, @@ -122,7 +125,7 @@ export class BillingSubscriptionItem { data.ended_at, data.payer_id, data.is_free_trial, - formatAmountJSON(data.lifetime_paid), + formatAmountJSON(data.lifetime_paid) ?? undefined, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index f43835ae5ea..e0eab086a12 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -845,17 +845,16 @@ export interface FeatureJSON extends ClerkResourceJSON { export interface BillingPlanJSON extends ClerkResourceJSON { object: typeof ObjectType.BillingPlan; id: string; - product_id: string; name: string; slug: string; - description?: string | null; + description: string | null; is_default: boolean; is_recurring: boolean; has_base_fee: boolean; publicly_visible: boolean; fee: BillingMoneyAmountJSON; - annual_fee?: BillingMoneyAmountJSON | null; - annual_monthly_fee?: BillingMoneyAmountJSON | null; + annual_fee: BillingMoneyAmountJSON | null; + annual_monthly_fee: BillingMoneyAmountJSON | null; for_payer_type: 'org' | 'user'; features?: FeatureJSON[]; } @@ -877,7 +876,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { object: typeof ObjectType.BillingSubscriptionItem; status: BillingSubscriptionItemStatus; plan_period: 'month' | 'annual'; - payer_id: string; + payer_id?: string; period_start: number; period_end: number | null; is_free_trial?: boolean; @@ -887,11 +886,11 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { canceled_at: number | null; past_due_at: number | null; lifetime_paid: BillingMoneyAmountJSON | null; - next_payment: { + next_payment?: { amount: number; date: number; } | null; - amount: BillingMoneyAmountJSON | null; + amount: BillingMoneyAmountJSON; plan?: BillingPlanJSON | null; plan_id?: string | null; } diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 0e5d7acb7f3..c79d0b340d3 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -3,7 +3,7 @@ export const API_VERSION = 'v1'; export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60; -export const SUPPORTED_BAPI_VERSION = '2025-10-01'; +export const SUPPORTED_BAPI_VERSION = '2025-11-10'; const Attributes = { AuthToken: '__clerkAuthToken', From edd1bd97311c4a642586e8389ddf07c4b98d95e7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 10 Nov 2025 13:21:44 +0200 Subject: [PATCH 16/19] bump bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3ccf919ae19..c78aa41243d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -24,7 +24,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.8KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.82KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, From 882639cf1d5293601e87534192888f098270483f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 10 Nov 2025 20:17:09 +0200 Subject: [PATCH 17/19] fix --- packages/shared/src/react/__tests__/commerce.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/react/__tests__/commerce.test.tsx b/packages/shared/src/react/__tests__/commerce.test.tsx index 29cc56c5ac4..53d7540af7e 100644 --- a/packages/shared/src/react/__tests__/commerce.test.tsx +++ b/packages/shared/src/react/__tests__/commerce.test.tsx @@ -122,6 +122,7 @@ describe('PaymentElement Localization', () => { grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, + totalDueAfterFreeTrial: null, credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, }, @@ -140,17 +141,17 @@ describe('PaymentElement Localization', () => { externalGatewayId: 'acct_123', isImmediatePlanChange: false, paymentMethodOrder: ['card'], - freeTrialEndsAt: null, + freeTrialEndsAt: undefined, payer: { id: 'payer_123', createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), - imageUrl: null, + imageUrl: undefined, userId: 'user_123', email: 'test@example.com', firstName: 'Test', lastName: 'User', - organizationId: undefined, + organizationId: null, organizationName: undefined, pathRoot: '/', reload: vi.fn(), From 3355242740c47bb5420f8876987be67d70641153 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 10 Nov 2025 22:31:15 +0200 Subject: [PATCH 18/19] prep --- .changeset/hot-jars-smell.md | 19 ++++++++++++++++--- .../src/tokens/__tests__/handshake.test.ts | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.changeset/hot-jars-smell.md b/.changeset/hot-jars-smell.md index e193a730ace..a62bdcc5d66 100644 --- a/.changeset/hot-jars-smell.md +++ b/.changeset/hot-jars-smell.md @@ -1,6 +1,19 @@ --- -'@clerk/clerk-js': patch -'@clerk/backend': patch +'@clerk/shared': minor +'@clerk/agent-toolkit': minor +'@clerk/astro': minor +'@clerk/backend': minor +'@clerk/chrome-extension': minor +'@clerk/clerk-expo': minor +'@clerk/clerk-js': minor +'@clerk/fastify': minor +'@clerk/nextjs': minor +'@clerk/nuxt': minor +'@clerk/clerk-react': minor +'@clerk/react-router': minor +'@clerk/tanstack-react-start': minor +'@clerk/types': minor +'@clerk/vue': minor --- -Update the supported API version to `2025-10-01`. +Update the supported API version to `2025-11-10`. diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts index 16444a964db..f570867edba 100644 --- a/packages/backend/src/tokens/__tests__/handshake.test.ts +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -427,7 +427,7 @@ describe('HandshakeService', () => { // Verify all required parameters are present expect(url.searchParams.get('redirect_url')).toBeDefined(); - expect(url.searchParams.get('__clerk_api_version')).toBe('2025-10-01'); + expect(url.searchParams.get('__clerk_api_version')).toBe('2025-11-10'); expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/); expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason'); }); From f7b943c51bdf0d14e6129ee67bc30fa44e464355 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 11 Nov 2025 01:31:05 +0200 Subject: [PATCH 19/19] fix test --- .../src/ui/components/Checkout/__tests__/Checkout.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx index bef2787d561..4d9ef547590 100644 --- a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx @@ -327,6 +327,7 @@ describe('Checkout', () => { taxTotal: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDueAfterFreeTrial: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true,