Skip to content

chore(clerk-js,types): Convert date properties from number to Date in CommerceSubscriptionResource #6233

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 15 additions & 0 deletions .changeset/funny-ducks-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Convert date properties from number to Date in CommerceSubscriptionResource
Deprecates fields of type `number`
- subscription.periodStart
- subscription.periodEnd
- subscription.canceledAt
Introduces fields of type `Date`
- subscription.periodStartDate
- subscription.periodEndDate
- subscription.canceledAtDate
- subscription.createdAt
13 changes: 13 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
DeletedObjectJSON,
} from '@clerk/types';

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

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

Expand All @@ -17,13 +19,18 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
plan!: CommercePlan;
planPeriod!: CommerceSubscriptionPlanPeriod;
status!: CommerceSubscriptionStatus;
createdAt!: Date;
periodStartDate!: Date;
periodEndDate!: Date | null;
canceledAtDate!: Date | null;
periodStart!: number;
periodEnd!: number;
canceledAt!: number | null;
amount?: CommerceMoney;
credit?: {
amount: CommerceMoney;
};

constructor(data: CommerceSubscriptionJSON) {
super();
this.fromJSON(data);
Expand All @@ -42,6 +49,12 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
this.periodStart = data.period_start;
this.periodEnd = data.period_end;
this.canceledAt = data.canceled_at;

this.createdAt = unixEpochToDate(data.created_at);
this.periodStartDate = unixEpochToDate(data.period_start);
this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null;
this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null;
Comment on lines +53 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the unixEpochToDate utility function

The conversion logic looks correct, but ensure the unixEpochToDate utility function properly handles edge cases.


🏁 Script executed:

#!/bin/bash
# Description: Verify the unixEpochToDate utility function exists and examine its implementation

# Search for the utility function definition
ast-grep --pattern 'function unixEpochToDate($_) {
  $$$
}'

# Also search for arrow function or const declaration
rg -A 10 "unixEpochToDate.*=" src/utils/date

Length of output: 163


I couldn’t locate unixEpochToDate in the initial paths—let’s do a global search to find its definition and usages.


🏁 Script executed:

#!/bin/bash
# Search for definition and all usages of unixEpochToDate across the repo
rg -n "unixEpochToDate"

Length of output: 10950


Let’s inspect the implementation and its tests:


🏁 Script executed:

#!/bin/bash
# Show the unixEpochToDate implementation
sed -n '1,200p' packages/clerk-js/src/utils/date.ts

# Show tests covering edge cases
sed -n '1,200p' packages/clerk-js/src/utils/__tests__/date.spec.ts

Length of output: 1264


Improve zero-timestamp handling in unixEpochToDate

The current implementation

export function unixEpochToDate(epochInSeconds?: number): Date {
  const date = new Date(epochInSeconds || new Date());
  return isValidDate(date) ? date : new Date();
}

uses a falsy check (epochInSeconds || …), so passing 0 (the Unix epoch) falls back to Date.now(). To correctly support 0, please:

  • In packages/clerk-js/src/utils/date.ts, update the guard:
    export function unixEpochToDate(epochInSeconds?: number): Date {
  • const date = new Date(epochInSeconds || new Date());
  • const timestamp = epochInSeconds != null ? epochInSeconds : Date.now();
  • const date = new Date(timestamp);
    return isValidDate(date) ? date : new Date();
    }
- In `packages/clerk-js/src/utils/__tests__/date.spec.ts`, add a zero-timestamp test:
```ts
it('correctly handles zero', () => {
  const date = unixEpochToDate(0);
  expect(date.getTime()).toBe(0);
});

This change ensures unixEpochToDate(0) returns the Unix epoch rather than “now.”

🤖 Prompt for AI Agents
In packages/clerk-js/src/utils/date.ts, update the unixEpochToDate function to
explicitly check for undefined or null instead of using a falsy check, so that
passing 0 returns the correct Unix epoch date. Then, in
packages/clerk-js/src/utils/__tests__/date.spec.ts, add a test case that calls
unixEpochToDate(0) and asserts that the returned date's time is 0 to verify
correct handling of the zero timestamp.


this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined;
this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined;
return this;
Expand Down
5 changes: 3 additions & 2 deletions packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ const PlanDetailsInternal = ({
{(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? (
<Drawer.Footer>
{subscription ? (
subscription.canceledAt ? (
subscription.canceledAtDate ? (
<Button
block
textVariant='buttonLarge'
Expand Down Expand Up @@ -318,7 +318,8 @@ const PlanDetailsInternal = ({
? localizationKeys('commerce.cancelSubscriptionNoCharge')
: localizationKeys('commerce.cancelSubscriptionAccessUntil', {
plan: subscription.plan.name,
date: subscription.periodEnd,
// @ts-expect-error this will always be defined in this case.
date: subscription.periodEndDate,
Comment on lines +321 to +322
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the TypeScript suppression is justified.

The migration from periodEnd to periodEndDate is consistent with the pattern. However, the @ts-expect-error comment suggests a type issue. Please ensure that the property is indeed always defined in this context or consider updating the type definitions.


🏁 Script executed:

#!/bin/bash
# Description: Check the type definition of periodEndDate in CommerceSubscriptionResource
# Expected: Verify if periodEndDate is optional or required in the type definition

ast-grep --pattern 'interface CommerceSubscriptionResource {
  $$$
  periodEndDate$_$$$
  $$$
}'

# Also search for any type definitions related to subscription period dates
rg -A 3 -B 3 "periodEndDate.*[?:]"

Length of output: 2751


Fix the TypeScript suppression for periodEndDate
The CommerceSubscriptionResource model declares periodEndDate as Date | null, so subscription.periodEndDate may legitimately be null. Suppressing the error hides a potential runtime issue. Please remove the @ts-expect-error and either:

  • Add an explicit null check before using it, e.g.
    if (!subscription.periodEndDate) {
      // handle missing date…
    } else {
      date: subscription.periodEndDate,
    }
  • Or use a non-null assertion if you’re certain it can’t be null in this context:
    - // @ts-expect-error this will always be defined in this case.
    - date: subscription.periodEndDate,
    + date: subscription.periodEndDate!,

Locations to update:

  • packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx (lines 321–322)
  • Note: the type in packages/types/src/commerce.ts is periodEndDate: Date | null;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx at lines 321-322,
remove the @ts-expect-error comment on subscription.periodEndDate and address
its nullable type properly. Add an explicit null check before using
subscription.periodEndDate to handle the case when it is null, or if you are
certain it cannot be null here, replace the suppression with a non-null
assertion operator to ensure type safety without hiding potential runtime
issues.

})
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const PricingTableRoot = (props: PricingTableProps) => {

// don't pay attention to the default plan
const activeSubscription = subscriptions?.find(
sub => !sub.canceledAt && sub.status === 'active' && !sub.plan.isDefault,
sub => !sub.canceledAtDate && sub.status === 'active' && !sub.plan.isDefault,
);
if (activeSubscription) {
return activeSubscription.planPeriod;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ function Card(props: CardProps) {
shouldShowFooter = true;
shouldShowFooterNotice = true;
} else if (subscription.status === 'active') {
if (subscription.canceledAt) {
if (subscription.canceledAtDate) {
shouldShowFooter = true;
shouldShowFooterNotice = false;
} else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyAmount > 0) {
Expand Down Expand Up @@ -253,7 +253,7 @@ function Card(props: CardProps) {
elementDescriptor={descriptors.pricingTableCardFooterNotice}
variant={isCompact ? 'buttonSmall' : 'buttonLarge'}
localizationKey={localizationKeys('badge__startsAt', {
date: subscription?.periodStart,
date: subscription?.periodStartDate,
})}
colorScheme='secondary'
sx={t => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function SubscriptionsList({
>
{subscription.plan.name}
</Text>
{sortedSubscriptions.length > 1 || !!subscription.canceledAt ? (
{sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? (
<Badge
colorScheme={subscription.status === 'active' ? 'secondary' : 'primary'}
localizationKey={
Expand Down
31 changes: 18 additions & 13 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ export const useSubscriptions = () => {
if (
isSignedIn &&
defaultFreePlan &&
(_subscriptions.data.length === 0 || !_subscriptions.data.some(subscription => !subscription.canceledAt))
(_subscriptions.data.length === 0 || !_subscriptions.data.some(subscription => !subscription.canceledAtDate))
) {
const canceledSubscription = _subscriptions.data.find(subscription => subscription.canceledAt);
const canceledSubscription = _subscriptions.data.find(subscription => subscription.canceledAtDate);
return [
..._subscriptions.data,
new CommerceSubscription({
Expand All @@ -111,7 +111,8 @@ export const useSubscriptions = () => {
plan_period: 'month',
canceled_at: null,
status: _subscriptions.data.length === 0 ? 'active' : 'upcoming',
period_start: canceledSubscription?.periodEnd || 0,
created_at: canceledSubscription?.periodEndDate?.getTime() || 0,
period_start: canceledSubscription?.periodEndDate?.getTime() || 0,
period_end: 0,
}),
];
Expand Down Expand Up @@ -190,7 +191,7 @@ export const usePlansContext = () => {
// should the default plan be shown as active
const isDefaultPlanImplicitlyActiveOrUpcoming = useMemo(() => {
// are there no subscriptions or are all subscriptions canceled
return subscriptions.length === 0 || !subscriptions.some(subscription => !subscription.canceledAt);
return subscriptions.length === 0 || !subscriptions.some(subscription => !subscription.canceledAtDate);
}, [subscriptions]);

// return the active or upcoming subscription for a plan if it exists
Expand Down Expand Up @@ -239,7 +240,7 @@ export const usePlansContext = () => {
({ plan, subscription: sub }: { plan?: CommercePlanResource; subscription?: CommerceSubscriptionResource }) => {
const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined);

return !subscription || !subscription.canceledAt;
return !subscription || !subscription.canceledAtDate;
},
[activeOrUpcomingSubscription],
);
Expand Down Expand Up @@ -282,7 +283,7 @@ export const usePlansContext = () => {
const getLocalizationKey = () => {
// Handle subscription cases
if (subscription) {
if (_selectedPlanPeriod !== subscription.planPeriod && subscription.canceledAt) {
if (_selectedPlanPeriod !== subscription.planPeriod && subscription.canceledAtDate) {
if (_selectedPlanPeriod === 'month') {
return localizationKeys('commerce.switchToMonthly');
}
Expand All @@ -292,7 +293,7 @@ export const usePlansContext = () => {
}
}

if (subscription.canceledAt) {
if (subscription.canceledAtDate) {
return localizationKeys('commerce.reSubscribe');
}

Expand Down Expand Up @@ -332,12 +333,16 @@ export const usePlansContext = () => {

const captionForSubscription = useCallback((subscription: CommerceSubscriptionResource) => {
if (subscription.status === 'upcoming') {
return localizationKeys('badge__startsAt', { date: subscription.periodStart });
} else if (subscription.canceledAt) {
return localizationKeys('badge__canceledEndsAt', { date: subscription.periodEnd });
} else {
return localizationKeys('badge__renewsAt', { date: subscription.periodEnd });
return localizationKeys('badge__startsAt', { date: subscription.periodStartDate });
}
if (subscription.canceledAtDate) {
// @ts-expect-error `periodEndDate` is always defined when `canceledAtDate` exists
return localizationKeys('badge__canceledEndsAt', { date: subscription.periodEndDate });
}
if (subscription.periodEndDate) {
return localizationKeys('badge__renewsAt', { date: subscription.periodEndDate });
}
return;
}, []);

// handle the selection of a plan, either by opening the subscription details or checkout
Expand All @@ -355,7 +360,7 @@ export const usePlansContext = () => {

const portalRoot = getClosestProfileScrollBox(mode, event);

if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAt) {
if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAtDate) {
clerk.__internal_openPlanDetails({
plan,
initialPlanPeriod: planPeriod,
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,21 @@ export interface CommerceSubscriptionResource extends ClerkResource {
plan: CommercePlanResource;
planPeriod: CommerceSubscriptionPlanPeriod;
status: CommerceSubscriptionStatus;
createdAt: Date;
periodStartDate: Date;
periodEndDate: Date | null;
canceledAtDate: Date | null;
/**
* @deprecated Use `periodStartDate` instead
*/
periodStart: number;
/**
* @deprecated Use `periodEndDate` instead
*/
periodEnd: number;
/**
* @deprecated Use `canceledAtDate` instead
*/
canceledAt: number | null;
amount?: CommerceMoney;
credit?: {
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
plan: CommercePlanJSON;
plan_period: CommerceSubscriptionPlanPeriod;
status: CommerceSubscriptionStatus;
created_at: number;
period_start: number;
period_end: number;
canceled_at: number | null;
Expand Down