Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/rich-drinks-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

[Billing Beta] Replace usage of top level amounts in plan with fees for displaying prices.
8 changes: 4 additions & 4 deletions packages/clerk-js/src/core/resources/CommercePayment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {
CommerceMoney,
CommerceFee,
CommercePaymentChargeType,
CommercePaymentJSON,
CommercePaymentResource,
Expand All @@ -8,13 +8,13 @@ import type {
CommerceSubscriptionItemResource,
} from '@clerk/types';

import { commerceMoneyFromJSON } from '../../utils';
import { commerceFeeFromJSON } from '../../utils';
import { unixEpochToDate } from '../../utils/date';
import { BaseResource, CommercePaymentSource, CommerceSubscriptionItem } from './internal';

export class CommercePayment extends BaseResource implements CommercePaymentResource {
id!: string;
amount!: CommerceMoney;
amount!: CommerceFee;
failedAt?: Date;
paidAt?: Date;
updatedAt!: Date;
Expand All @@ -38,7 +38,7 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso
}

this.id = data.id;
this.amount = commerceMoneyFromJSON(data.amount);
this.amount = commerceFeeFromJSON(data.amount);
this.paidAt = data.paid_at ? unixEpochToDate(data.paid_at) : undefined;
this.failedAt = data.failed_at ? unixEpochToDate(data.failed_at) : undefined;
this.updatedAt = unixEpochToDate(data.updated_at);
Expand Down
56 changes: 9 additions & 47 deletions packages/clerk-js/src/core/resources/CommercePlan.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import type {
CommercePayerResourceType,
CommercePlanJSON,
CommercePlanJSONSnapshot,
CommercePlanResource,
} from '@clerk/types';
import type { CommerceFee, CommercePayerResourceType, CommercePlanJSON, CommercePlanResource } from '@clerk/types';

import { commerceFeeFromJSON } from '@/utils/commerce';

import { BaseResource, CommerceFeature } from './internal';

export class CommercePlan extends BaseResource implements CommercePlanResource {
id!: string;
name!: string;
amount!: number;
amountFormatted!: string;
annualAmount!: number;
annualAmountFormatted!: string;
annualMonthlyAmount!: number;
annualMonthlyAmountFormatted!: string;
currencySymbol!: string;
currency!: string;
fee!: CommerceFee;
annualFee!: CommerceFee;
annualMonthlyFee!: CommerceFee;
description!: string;
isDefault!: boolean;
isRecurring!: boolean;
Expand All @@ -40,14 +32,9 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {

this.id = data.id;
this.name = data.name;
this.amount = data.amount;
this.amountFormatted = data.amount_formatted;
this.annualAmount = data.annual_amount;
this.annualAmountFormatted = data.annual_amount_formatted;
this.annualMonthlyAmount = data.annual_monthly_amount;
this.annualMonthlyAmountFormatted = data.annual_monthly_amount_formatted;
this.currencySymbol = data.currency_symbol;
this.currency = data.currency;
this.fee = commerceFeeFromJSON(data.fee);
this.annualFee = commerceFeeFromJSON(data.annual_fee);
this.annualMonthlyFee = commerceFeeFromJSON(data.annual_monthly_fee);
this.description = data.description;
this.isDefault = data.is_default;
this.isRecurring = data.is_recurring;
Expand All @@ -60,29 +47,4 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {

return this;
}

public __internal_toSnapshot(): CommercePlanJSONSnapshot {
return {
object: 'commerce_plan',
id: this.id,
name: this.name,
amount: this.amount,
amount_formatted: this.amountFormatted,
annual_amount: this.annualAmount,
annual_amount_formatted: this.annualAmountFormatted,
annual_monthly_amount: this.annualMonthlyAmount,
annual_monthly_amount_formatted: this.annualMonthlyAmountFormatted,
currency: this.currency,
currency_symbol: this.currencySymbol,
description: this.description,
is_default: this.isDefault,
is_recurring: this.isRecurring,
has_base_fee: this.hasBaseFee,
for_payer_type: this.forPayerType,
publicly_visible: this.publiclyVisible,
slug: this.slug,
avatar_url: this.avatarUrl,
features: this.features.map(feature => feature.__internal_toSnapshot()),
};
}
}
16 changes: 8 additions & 8 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {
CancelSubscriptionParams,
CommerceMoney,
CommerceFee,
CommerceSubscriptionItemJSON,
CommerceSubscriptionItemResource,
CommerceSubscriptionJSON,
Expand All @@ -12,7 +12,7 @@ import type {

import { unixEpochToDate } from '@/utils/date';

import { commerceMoneyFromJSON } from '../../utils';
import { commerceFeeFromJSON } from '../../utils';
import { BaseResource, CommercePlan, DeletedObject } from './internal';

export class CommerceSubscription extends BaseResource implements CommerceSubscriptionResource {
Expand All @@ -23,7 +23,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
pastDueAt!: Date | null;
updatedAt!: Date | null;
nextPayment: {
amount: CommerceMoney;
amount: CommerceFee;
date: Date;
} | null = null;
subscriptionItems!: CommerceSubscriptionItemResource[];
Expand All @@ -46,7 +46,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null;
this.nextPayment = data.next_payment
? {
amount: commerceMoneyFromJSON(data.next_payment.amount),
amount: commerceFeeFromJSON(data.next_payment.amount),
date: unixEpochToDate(data.next_payment.date),
}
: null;
Expand All @@ -70,9 +70,9 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu
periodEnd!: number;
canceledAt!: number | null;
//TODO(@COMMERCE): Why can this be undefined ?
amount?: CommerceMoney;
amount?: CommerceFee;
credit?: {
amount: CommerceMoney;
amount: CommerceFee;
};

constructor(data: CommerceSubscriptionItemJSON) {
Expand Down Expand Up @@ -101,8 +101,8 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu
this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null;
this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null;

this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined;
this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined;
this.amount = data.amount ? commerceFeeFromJSON(data.amount) : undefined;
this.credit = data.credit && data.credit.amount ? { amount: commerceFeeFromJSON(data.credit.amount) } : undefined;
return this;
}

Expand Down
14 changes: 5 additions & 9 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react';
import type { CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types';
import type { CommerceFee, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types';
import { useMemo, useState } from 'react';

import { Card } from '@/ui/elements/Card';
Expand Down Expand Up @@ -35,6 +35,8 @@ export const CheckoutForm = withCardStateProvider(() => {
const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0;
const showDowngradeInfo = !isImmediatePlanChange;

const fee = planPeriod === 'month' ? plan.fee : plan.annualMonthlyFee;

Comment thread
panteliselef marked this conversation as resolved.
return (
<Drawer.Body>
<Box
Expand All @@ -54,7 +56,7 @@ export const CheckoutForm = withCardStateProvider(() => {
/>
<LineItems.Description
prefix={planPeriod === 'annual' ? 'x12' : undefined}
text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
text={`${fee.currencySymbol}${fee.amountFormatted}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
Comment on lines 58 to 61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Localize hard-coded 'x12' multiplier

All user-facing strings must be localized. Replace 'x12' with a localization key (e.g., commerce.checkout.x12) or a parameterized key.

-              prefix={planPeriod === 'annual' ? 'x12' : undefined}
+              prefix={planPeriod === 'annual' ? localizationKeys('commerce.checkout.x12') : undefined}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
prefix={planPeriod === 'annual' ? 'x12' : undefined}
text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
text={`${fee.currencySymbol}${fee.amountFormatted}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
prefix={planPeriod === 'annual' ? localizationKeys('commerce.checkout.x12') : undefined}
text={`${fee.currencySymbol}${fee.amountFormatted}`}
suffix={localizationKeys('commerce.checkout.perMonth')}
/>
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx around lines 58
to 61, the hard-coded string 'x12' used as a prefix should be replaced with a
localized string. Update the code to use a localization key such as
'commerce.checkout.x12' or a parameterized localization key instead of the
literal 'x12' to ensure all user-facing strings are properly localized.

</LineItems.Group>
Expand Down Expand Up @@ -308,13 +310,7 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => {
});

const ExistingPaymentSourceForm = withCardStateProvider(
({
totalDueNow,
paymentSources,
}: {
totalDueNow: CommerceMoney;
paymentSources: CommercePaymentSourceResource[];
}) => {
({ totalDueNow, paymentSources }: { totalDueNow: CommerceFee; paymentSources: CommercePaymentSourceResource[] }) => {
const { checkout } = useCheckout();
const { paymentSource } = checkout;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useClerk, useOrganization } from '@clerk/shared/react';
import type { CommerceSubscriptionItemResource } from '@clerk/types';
import useSWR from 'swr';

import { Alert } from '@/ui/elements/Alert';
Expand Down Expand Up @@ -157,41 +158,7 @@ export const PaymentAttemptPage = () => {
{paymentAttempt.status}
</Badge>
</Box>
<Box
elementDescriptor={descriptors.paymentAttemptBody}
sx={t => ({
padding: t.space.$4,
})}
>
{subscriptionItem && (
<LineItems.Root>
<LineItems.Group>
<LineItems.Title title={subscriptionItem.plan.name} />
<LineItems.Description
prefix={subscriptionItem.planPeriod === 'annual' ? 'x12' : undefined}
text={`${subscriptionItem.plan.currencySymbol}${subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.amountFormatted : subscriptionItem.plan.annualMonthlyAmountFormatted}`}
/>
</LineItems.Group>
<LineItems.Group
borderTop
variant='tertiary'
>
<LineItems.Title title={localizationKeys('commerce.subtotal')} />
<LineItems.Description
text={`${subscriptionItem.amount?.currencySymbol}${subscriptionItem.amount?.amountFormatted}`}
/>
</LineItems.Group>
{subscriptionItem.credit && subscriptionItem.credit.amount.amount > 0 && (
<LineItems.Group variant='tertiary'>
<LineItems.Title title={localizationKeys('commerce.credit')} />
<LineItems.Description
text={`- ${subscriptionItem.credit.amount.currencySymbol}${subscriptionItem.credit.amount.amountFormatted}`}
/>
</LineItems.Group>
)}
</LineItems.Root>
)}
</Box>
<PaymentAttemptBody subscriptionItem={subscriptionItem} />
<Box
elementDescriptor={descriptors.paymentAttemptFooter}
as='footer'
Expand Down Expand Up @@ -242,6 +209,51 @@ export const PaymentAttemptPage = () => {
);
};

function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: CommerceSubscriptionItemResource | undefined }) {
if (!subscriptionItem) {
return null;
}

const fee =
subscriptionItem.planPeriod === 'month' ? subscriptionItem.plan.fee : subscriptionItem.plan.annualMonthlyFee;

return (
<Box
elementDescriptor={descriptors.paymentAttemptBody}
sx={t => ({
padding: t.space.$4,
})}
>
<LineItems.Root>
<LineItems.Group>
<LineItems.Title title={subscriptionItem.plan.name} />
<LineItems.Description
prefix={subscriptionItem.planPeriod === 'annual' ? 'x12' : undefined}
text={`${fee.currencySymbol}${fee.amountFormatted}`}
/>
Comment thread
panteliselef marked this conversation as resolved.
</LineItems.Group>
<LineItems.Group
borderTop
variant='tertiary'
>
<LineItems.Title title={localizationKeys('commerce.subtotal')} />
<LineItems.Description
text={`${subscriptionItem.amount?.currencySymbol}${subscriptionItem.amount?.amountFormatted}`}
/>
</LineItems.Group>
{subscriptionItem.credit && subscriptionItem.credit.amount.amount > 0 && (
<LineItems.Group variant='tertiary'>
<LineItems.Title title={localizationKeys('commerce.credit')} />
<LineItems.Description
text={`- ${subscriptionItem.credit.amount.currencySymbol}${subscriptionItem.credit.amount.amountFormatted}`}
/>
</LineItems.Group>
)}
</LineItems.Root>
</Box>
);
}

function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) {
const { onCopy, hasCopied } = useClipboard(text);

Expand Down
25 changes: 18 additions & 7 deletions packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,27 @@ interface HeaderProps {
closeSlot?: React.ReactNode;
}

/**
* Only remove decimal places if they are '00', to match previous behavior.
*/
function normalizeFormatted(formatted: string) {
return formatted.endsWith('.00') ? formatted.slice(0, -3) : formatted;
}

const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => {
const { plan, closeSlot, planPeriod, setPlanPeriod } = props;

const getPlanFee = useMemo(() => {
if (plan.annualMonthlyAmount <= 0) {
return plan.amountFormatted;
const fee = useMemo(() => {
if (plan.annualMonthlyFee.amount <= 0) {
return plan.fee;
}
return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted;
return planPeriod === 'annual' ? plan.annualMonthlyFee : plan.fee;
}, [plan, planPeriod]);

const feeFormatted = React.useMemo(() => {
return normalizeFormatted(fee.amountFormatted);
}, [fee.amountFormatted]);

return (
<Box
ref={ref}
Expand Down Expand Up @@ -305,8 +316,8 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => {
variant='h1'
colorScheme='body'
>
{plan.currencySymbol}
{getPlanFee}
{fee.currencySymbol}
{feeFormatted}
</Text>
<Text
elementDescriptor={descriptors.planDetailFeePeriod}
Expand All @@ -324,7 +335,7 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => {
</>
</Flex>

{plan.annualMonthlyAmount > 0 ? (
{plan.annualMonthlyFee.amount > 0 ? (
<Box
elementDescriptor={descriptors.planDetailPeriodToggle}
sx={t => ({
Expand Down
Loading
Loading