diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index f454e9ff848..065cd9535d0 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -9,12 +9,15 @@ import type { CommerceProductJSON, CommerceStatementJSON, CommerceStatementResource, + CommerceSubscriptionItemJSON, + CommerceSubscriptionItemResource, CommerceSubscriptionJSON, CommerceSubscriptionResource, CreateCheckoutParams, GetPaymentAttemptsParams, GetPlansParams, GetStatementsParams, + GetSubscriptionParams, GetSubscriptionsParams, } from '@clerk/types'; @@ -26,6 +29,7 @@ import { CommercePlan, CommerceStatement, CommerceSubscription, + CommerceSubscriptionItem, } from '../../resources/internal'; export class CommerceBilling implements CommerceBillingNamespace { @@ -40,6 +44,7 @@ export class CommerceBilling implements CommerceBillingNamespace { return defaultProduct?.plans.map(plan => new CommercePlan(plan)) || []; }; + // Inconsistent API getPlan = async (params: { id: string }): Promise => { const plan = (await BaseResource._fetch({ path: `/commerce/plans/${params.id}`, @@ -48,9 +53,16 @@ export class CommerceBilling implements CommerceBillingNamespace { return new CommercePlan(plan); }; + getSubscription = async (params: GetSubscriptionParams): Promise => { + return await BaseResource._fetch({ + path: params.orgId ? `/organizations/${params.orgId}/commerce/subscription` : `/me/commerce/subscription`, + method: 'GET', + }).then(res => new CommerceSubscription(res?.response as CommerceSubscriptionJSON)); + }; + getSubscriptions = async ( params: GetSubscriptionsParams, - ): Promise> => { + ): Promise> => { const { orgId, ...rest } = params; return await BaseResource._fetch({ @@ -59,11 +71,11 @@ export class CommerceBilling implements CommerceBillingNamespace { search: convertPageToOffsetSearchParams(rest), }).then(res => { const { data: subscriptions, total_count } = - res?.response as unknown as ClerkPaginatedResponse; + res?.response as unknown as ClerkPaginatedResponse; return { total_count, - data: subscriptions.map(subscription => new CommerceSubscription(subscription)), + data: subscriptions.map(subscription => new CommerceSubscriptionItem(subscription)), }; }); }; diff --git a/packages/clerk-js/src/core/resources/CommercePayment.ts b/packages/clerk-js/src/core/resources/CommercePayment.ts index d299130799a..8127acb5b23 100644 --- a/packages/clerk-js/src/core/resources/CommercePayment.ts +++ b/packages/clerk-js/src/core/resources/CommercePayment.ts @@ -3,12 +3,14 @@ import type { CommercePaymentChargeType, CommercePaymentJSON, CommercePaymentResource, + CommercePaymentSourceResource, CommercePaymentStatus, + CommerceSubscriptionItemResource, } from '@clerk/types'; import { commerceMoneyFromJSON } from '../../utils'; import { unixEpochToDate } from '../../utils/date'; -import { BaseResource, CommercePaymentSource, CommerceSubscription } from './internal'; +import { BaseResource, CommercePaymentSource, CommerceSubscriptionItem } from './internal'; export class CommercePayment extends BaseResource implements CommercePaymentResource { id!: string; @@ -16,9 +18,12 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso failedAt?: Date; paidAt?: Date; updatedAt!: Date; - paymentSource!: CommercePaymentSource; - subscription!: CommerceSubscription; - subscriptionItem!: CommerceSubscription; + paymentSource!: CommercePaymentSourceResource; + /** + * @deprecated + */ + subscription!: CommerceSubscriptionItemResource; + subscriptionItem!: CommerceSubscriptionItemResource; chargeType!: CommercePaymentChargeType; status!: CommercePaymentStatus; @@ -38,8 +43,8 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso this.failedAt = data.failed_at ? unixEpochToDate(data.failed_at) : undefined; this.updatedAt = unixEpochToDate(data.updated_at); this.paymentSource = new CommercePaymentSource(data.payment_source); - this.subscription = new CommerceSubscription(data.subscription); - this.subscriptionItem = new CommerceSubscription(data.subscription_item); + this.subscription = new CommerceSubscriptionItem(data.subscription); + this.subscriptionItem = new CommerceSubscriptionItem(data.subscription_item); this.chargeType = data.charge_type; this.status = data.status; return this; diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index d2f8ae66219..a344ccce1ee 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -1,6 +1,8 @@ import type { CancelSubscriptionParams, CommerceMoney, + CommerceSubscriptionItemJSON, + CommerceSubscriptionItemResource, CommerceSubscriptionJSON, CommerceSubscriptionPlanPeriod, CommerceSubscriptionResource, @@ -14,6 +16,50 @@ import { commerceMoneyFromJSON } from '../../utils'; import { BaseResource, CommercePlan, DeletedObject } from './internal'; export class CommerceSubscription extends BaseResource implements CommerceSubscriptionResource { + id!: string; + status!: CommerceSubscriptionStatus; + activeAt!: Date; + createdAt!: Date; + pastDueAt!: Date | null; + updatedAt!: Date | null; + //TODO(@COMMERCE): Why can this be undefined ? + nextPayment: { + //TODO(@COMMERCE): Why can this be undefined ? + amount: CommerceMoney; + //TODO(@COMMERCE): This need to change to `date` probably + //TODO(@COMMERCE): Why can this be undefined ? + time: Date; + } | null = null; + subscriptionItems!: CommerceSubscriptionItemResource[]; + + constructor(data: CommerceSubscriptionJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: CommerceSubscriptionJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.status = data.status; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = data.update_at ? unixEpochToDate(data.update_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: commerceMoneyFromJSON(data.next_payment.amount), + time: unixEpochToDate(data.next_payment.time), + } + : null; + this.subscriptionItems = data.subscription_items.map(item => new CommerceSubscriptionItem(item)); + return this; + } +} + +export class CommerceSubscriptionItem extends BaseResource implements CommerceSubscriptionItemResource { id!: string; paymentSourceId!: string; plan!: CommercePlan; @@ -27,17 +73,18 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr periodStart!: number; periodEnd!: number; canceledAt!: number | null; + //TODO(@COMMERCE): Why can this be undefined ? amount?: CommerceMoney; credit?: { amount: CommerceMoney; }; - constructor(data: CommerceSubscriptionJSON) { + constructor(data: CommerceSubscriptionItemJSON) { super(); this.fromJSON(data); } - protected fromJSON(data: CommerceSubscriptionJSON | null): this { + protected fromJSON(data: CommerceSubscriptionItemJSON | null): this { if (!data) { return this; } @@ -63,6 +110,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr return this; } + //TODO(@COMMERCE): shouldn't this change to `subscriptions_items` ? public async cancel(params: CancelSubscriptionParams) { const { orgId } = params; const json = ( diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 424b373b89a..34edb647ad6 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -2,8 +2,8 @@ import type { AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, - CommerceSubscriptionJSON, - CommerceSubscriptionResource, + CommerceSubscriptionItemJSON, + CommerceSubscriptionItemResource, CreateOrganizationParams, GetDomainsParams, GetInvitationsParams, @@ -32,7 +32,7 @@ import type { import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; import { addPaymentSource, getPaymentSources, initializePaymentSource } from '../modules/commerce'; -import { BaseResource, CommerceSubscription, OrganizationInvitation, OrganizationMembership } from './internal'; +import { BaseResource, CommerceSubscriptionItem, OrganizationInvitation, OrganizationMembership } from './internal'; import { OrganizationDomain } from './OrganizationDomain'; import { OrganizationMembershipRequest } from './OrganizationMembershipRequest'; import { Role } from './Role'; @@ -235,18 +235,18 @@ export class Organization extends BaseResource implements OrganizationResource { getSubscriptions = async ( getSubscriptionsParams?: GetSubscriptionsParams, - ): Promise> => { + ): Promise> => { return await BaseResource._fetch({ path: `/organizations/${this.id}/commerce/subscriptions`, method: 'GET', search: convertPageToOffsetSearchParams(getSubscriptionsParams), }).then(res => { const { data: subscriptions, total_count } = - res?.response as unknown as ClerkPaginatedResponse; + res?.response as unknown as ClerkPaginatedResponse; return { total_count, - data: subscriptions.map(subscription => new CommerceSubscription(subscription)), + data: subscriptions.map(subscription => new CommerceSubscriptionItem(subscription)), }; }); }; diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx index 6151c14e488..eae1fcd381c 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx @@ -2,8 +2,9 @@ import { useClerk } from '@clerk/shared/react'; import type { CommercePlanResource, CommerceSubscriptionPlanPeriod, PricingTableProps } from '@clerk/types'; import { useEffect, useMemo, useState } from 'react'; -import { usePaymentMethods, usePlans, usePlansContext, usePricingTableContext, useSubscriptions } from '../../contexts'; -import { Flow } from '../../customizables'; +import { Flow } from '@/ui/customizables/Flow'; + +import { usePaymentMethods, usePlans, usePlansContext, usePricingTableContext, useSubscription } from '../../contexts'; import { PricingTableDefault } from './PricingTableDefault'; import { PricingTableMatrix } from './PricingTableMatrix'; @@ -11,19 +12,19 @@ const PricingTableRoot = (props: PricingTableProps) => { const clerk = useClerk(); const { mode = 'mounted', signInMode = 'redirect' } = usePricingTableContext(); const isCompact = mode === 'modal'; - const { data: subscriptions } = useSubscriptions(); + const { subscriptionItems } = useSubscription(); const { data: plans } = usePlans(); const { handleSelectPlan } = usePlansContext(); const defaultPlanPeriod = useMemo(() => { if (isCompact) { - const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming'); + const upcomingSubscription = subscriptionItems?.find(sub => sub.status === 'upcoming'); if (upcomingSubscription) { return upcomingSubscription.planPeriod; } // don't pay attention to the default plan - const activeSubscription = subscriptions?.find( + const activeSubscription = subscriptionItems?.find( sub => !sub.canceledAtDate && sub.status === 'active' && !sub.plan.isDefault, ); if (activeSubscription) { @@ -32,7 +33,7 @@ const PricingTableRoot = (props: PricingTableProps) => { } return 'annual'; - }, [isCompact, subscriptions]); + }, [isCompact, subscriptionItems]); const [planPeriod, setPlanPeriod] = useState(defaultPlanPeriod); 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 3696e273c33..c6278895d87 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 @@ -33,8 +33,22 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-02-01'), + }, + status: 'active', + subscriptionItems: [ { id: 'sub_123', plan: { @@ -62,7 +76,6 @@ describe('SubscriptionDetails', () => { status: 'active', }, ], - total_count: 1, }); const { getByRole, getByText, queryByText, getAllByText, userEvent } = render( @@ -113,8 +126,22 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 10000, + amountFormatted: '100.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2022-01-01'), + }, + status: 'active', + subscriptionItems: [ { id: 'sub_123', plan: { @@ -142,7 +169,6 @@ describe('SubscriptionDetails', () => { status: 'active' as const, }, ], - total_count: 1, }); const { getByRole, getByText, queryByText, getAllByText, userEvent } = render( @@ -193,8 +219,22 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + status: 'active', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + subscriptionItems: [ { id: 'sub_123', plan: { @@ -222,7 +262,6 @@ describe('SubscriptionDetails', () => { status: 'active' as const, }, ], - total_count: 1, }); const { getByRole, getByText, queryByText, queryByRole } = render( @@ -303,8 +342,22 @@ describe('SubscriptionDetails', () => { features: [], }; - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + status: 'active', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-02-01'), + }, + subscriptionItems: [ { id: 'sub_annual', plan: planAnnual, @@ -328,7 +381,6 @@ describe('SubscriptionDetails', () => { status: 'upcoming' as const, }, ], - total_count: 2, }); const { getByRole, getByText, getAllByText, queryByText, getAllByRole, userEvent } = render( @@ -430,8 +482,22 @@ describe('SubscriptionDetails', () => { features: [], }; - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + status: 'active', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + subscriptionItems: [ { id: 'test_active', plan: planMonthly, @@ -454,7 +520,6 @@ describe('SubscriptionDetails', () => { status: 'upcoming' as const, }, ], - total_count: 2, }); const { getByRole, getByText, queryByText, getAllByText } = render( @@ -498,8 +563,22 @@ describe('SubscriptionDetails', () => { const cancelSubscriptionMock = jest.fn().mockResolvedValue({}); - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + status: 'active', + subscriptionItems: [ { id: 'sub_123', plan: { @@ -528,7 +607,6 @@ describe('SubscriptionDetails', () => { cancel: cancelSubscriptionMock, }, ], - total_count: 1, }); const { getByRole, getByText, userEvent } = render( @@ -618,9 +696,21 @@ describe('SubscriptionDetails', () => { }; // Mock getSubscriptions to return the canceled subscription - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [subscription], - total_count: 1, + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + subscriptionItems: [subscription], }); const { getByRole, getByText, userEvent } = render( @@ -693,9 +783,21 @@ describe('SubscriptionDetails', () => { }; // Mock getSubscriptions to return the annual subscription - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [subscription], - total_count: 1, + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + subscriptionItems: [subscription], }); const { getByRole, getByText, userEvent } = render( @@ -757,8 +859,21 @@ describe('SubscriptionDetails', () => { features: [], }; - fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ - data: [ + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + activeAt: new Date('2021-01-01'), + createdAt: new Date('2021-01-01'), + pastDueAt: null, + id: 'sub_123', + nextPayment: { + amount: { + amount: 1000, + amountFormatted: '10.00', + currency: 'USD', + currencySymbol: '$', + }, + time: new Date('2021-01-01'), + }, + subscriptionItems: [ { id: 'sub_past_due', plan, @@ -772,7 +887,6 @@ describe('SubscriptionDetails', () => { pastDueAt: new Date('2021-01-15'), }, ], - total_count: 1, }); const { getByRole, getByText, queryByText, queryByRole } = render( diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 669001e7058..e649cad4e2a 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -3,7 +3,7 @@ import type { __internal_CheckoutProps, __internal_SubscriptionDetailsProps, CommercePlanResource, - CommerceSubscriptionResource, + CommerceSubscriptionItemResource, } from '@clerk/types'; import * as React from 'react'; import { useCallback, useContext, useState } from 'react'; @@ -17,15 +17,12 @@ import { Avatar } from '@/ui/elements/Avatar'; import { CardAlert } from '@/ui/elements/Card/CardAlert'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; +import { LineItems } from '@/ui/elements/LineItems'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; import { formatDate } from '@/ui/utils/formatDate'; -const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; - -import { LineItems } from '@/ui/elements/LineItems'; - -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscription } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Button, @@ -40,10 +37,12 @@ import { } from '../../customizables'; import { SubscriptionBadge } from '../Subscriptions/badge'; +const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; + // We cannot derive the state of confrimation modal from the existance subscription, as it will make the animation laggy when the confimation closes. const SubscriptionForCancellationContext = React.createContext<{ - subscription: CommerceSubscriptionResource | null; - setSubscription: (subscription: CommerceSubscriptionResource | null) => void; + subscription: CommerceSubscriptionItemResource | null; + setSubscription: (subscription: CommerceSubscriptionItemResource | null) => void; confirmationOpen: boolean; setConfirmationOpen: (confirmationOpen: boolean) => void; }>({ @@ -67,27 +66,27 @@ export const SubscriptionDetails = (props: __internal_SubscriptionDetailsProps) type UseGuessableSubscriptionResult = Or extends 'throw' ? { - upcomingSubscription?: CommerceSubscriptionResource; - pastDueSubscription?: CommerceSubscriptionResource; - activeSubscription?: CommerceSubscriptionResource; - anySubscription: CommerceSubscriptionResource; + upcomingSubscription?: CommerceSubscriptionItemResource; + pastDueSubscription?: CommerceSubscriptionItemResource; + activeSubscription?: CommerceSubscriptionItemResource; + anySubscription: CommerceSubscriptionItemResource; isLoading: boolean; } : { - upcomingSubscription?: CommerceSubscriptionResource; - pastDueSubscription?: CommerceSubscriptionResource; - activeSubscription?: CommerceSubscriptionResource; - anySubscription?: CommerceSubscriptionResource; + upcomingSubscription?: CommerceSubscriptionItemResource; + pastDueSubscription?: CommerceSubscriptionItemResource; + activeSubscription?: CommerceSubscriptionItemResource; + anySubscription?: CommerceSubscriptionItemResource; isLoading: boolean; }; function useGuessableSubscription(options?: { or?: Or; }): UseGuessableSubscriptionResult { - const { data: subscriptions, isLoading } = useSubscriptions(); - const activeSubscription = subscriptions?.find(sub => sub.status === 'active'); - const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming'); - const pastDueSubscription = subscriptions?.find(sub => sub.status === 'past_due'); + const { subscriptionItems, isLoading } = useSubscription(); + const activeSubscription = subscriptionItems?.find(sub => sub.status === 'active'); + const upcomingSubscription = subscriptionItems?.find(sub => sub.status === 'upcoming'); + const pastDueSubscription = subscriptionItems?.find(sub => sub.status === 'past_due'); if (options?.or === 'throw' && !activeSubscription && !pastDueSubscription) { throw new Error('No active or past due subscription found'); @@ -103,18 +102,11 @@ function useGuessableSubscription(op } const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps) => { - const { organization: _organization } = useOrganization(); - const [subscriptionForCancellation, setSubscriptionForCancellation] = useState( - null, - ); + const [subscriptionForCancellation, setSubscriptionForCancellation] = + useState(null); const [confirmationOpen, setConfirmationOpen] = useState(false); - const { - buttonPropsForPlan: _buttonPropsForPlan, - isDefaultPlanImplicitlyActiveOrUpcoming: _isDefaultPlanImplicitlyActiveOrUpcoming, - } = usePlansContext(); - - const { data: subscriptions, isLoading } = useSubscriptions(); + const { subscriptionItems, isLoading } = useSubscription(); const { activeSubscription, pastDueSubscription } = useGuessableSubscription(); if (isLoading) { @@ -154,7 +146,7 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps) })} > {/* Subscription Cards */} - {subscriptions?.map(subscriptionItem => ( + {subscriptionItems?.map(subscriptionItem => ( { const subscriberType = useSubscriberTypeContext(); const { organization } = useOrganization(); const { isLoading, error, setError, setLoading, setIdle } = useCardState(); - const { subscription, confirmationOpen, setConfirmationOpen } = useContext(SubscriptionForCancellationContext); - const { anySubscription } = useGuessableSubscription({ or: 'throw' }); + const { + subscription: selectedSubscription, + confirmationOpen, + setConfirmationOpen, + } = useContext(SubscriptionForCancellationContext); + const { data: subscription } = useSubscription(); const { setIsOpen } = useDrawerContext(); const { onSubscriptionCancel } = useSubscriptionDetailsContext(); const onOpenChange = useCallback((open: boolean) => setConfirmationOpen(open), [setConfirmationOpen]); const cancelSubscription = useCallback(async () => { - if (!subscription) { + if (!selectedSubscription) { return; } setError(undefined); setLoading(); - await subscription + await selectedSubscription .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) .then(() => { onSubscriptionCancel?.(); @@ -201,10 +197,19 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { .finally(() => { setIdle(); }); - }, [subscription, setError, setLoading, subscriberType, organization?.id, onSubscriptionCancel, setIsOpen, setIdle]); + }, [ + selectedSubscription, + setError, + setLoading, + subscriberType, + organization?.id, + onSubscriptionCancel, + setIsOpen, + setIdle, + ]); - // If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan - if (isFreePlan(anySubscription.plan) || anySubscription.status === 'past_due') { + // Missing nextPayment means that an upcoming subscription is for the free plan + if (!subscription?.nextPayment) { return null; } @@ -242,24 +247,24 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { } > - {subscription ? ( + {selectedSubscription ? ( <> { }); function SubscriptionDetailsSummary() { - const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ + const { activeSubscription } = useGuessableSubscription({ or: 'throw', }); + const { data: subscription } = useSubscription(); - if (!activeSubscription) { + if ( + // Missing nextPayment means that an upcoming subscription is for the free plan + !subscription?.nextPayment || + !activeSubscription + ) { return null; } @@ -296,32 +306,20 @@ function SubscriptionDetailsSummary() { - + ); } -const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { +const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionItemResource }) => { const { portalRoot } = useSubscriptionDetailsContext(); const { __internal_openCheckout } = useClerk(); const subscriberType = useSubscriberTypeContext(); @@ -431,7 +429,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc }; // New component for individual subscription cards -const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { +const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionItemResource }) => { const { t } = useLocalizations(); return ( diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index 9d6dcd6ee33..7fabd58cae0 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { ProfileSection } from '@/ui/elements/Section'; import { useProtect } from '../../common'; @@ -5,7 +7,7 @@ import { usePlansContext, useSubscriberTypeContext, useSubscriberTypeLocalizationRoot, - useSubscriptions, + useSubscription, } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { @@ -39,24 +41,28 @@ export function SubscriptionsList({ const { captionForSubscription, openSubscriptionDetails } = usePlansContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const subscriberType = useSubscriberTypeContext(); - const { data: subscriptions } = useSubscriptions(); + const { subscriptionItems } = useSubscription(); const canManageBilling = useProtect( has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', ); const { navigate } = useRouter(); - const sortedSubscriptions = subscriptions.sort((a, b) => { - // alway put active subscriptions first - if (a.status === 'active' && b.status !== 'active') { - return -1; - } + const sortedSubscriptions = useMemo( + () => + subscriptionItems.sort((a, b) => { + // always put active subscriptions first + if (a.status === 'active' && b.status !== 'active') { + return -1; + } - if (b.status === 'active' && a.status !== 'active') { - return 1; - } + if (b.status === 'active' && a.status !== 'active') { + return 1; + } - return 1; - }); + return 1; + }), + [subscriptionItems], + ); return ( - {subscriptions.length > 0 && ( + {subscriptionItems.length > 0 && ( @@ -190,14 +196,14 @@ export function SubscriptionsList({ 0 ? arrowButtonText : arrowButtonEmptyText} + textLocalizationKey={subscriptionItems.length > 0 ? arrowButtonText : arrowButtonEmptyText} sx={[ t => ({ justifyContent: 'start', height: t.sizes.$8, }), ]} - leftIcon={subscriptions.length > 0 ? ArrowsUpDown : Plus} + leftIcon={subscriptionItems.length > 0 ? ArrowsUpDown : Plus} leftIconSx={t => ({ width: t.sizes.$4, height: t.sizes.$4, diff --git a/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx b/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx index 95eb142858a..64524cc7f7a 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx @@ -1,4 +1,4 @@ -import type { CommerceSubscriptionResource } from '@clerk/types'; +import type { CommerceSubscriptionItemResource } from '@clerk/types'; import { Badge, localizationKeys } from '@/ui/customizables'; import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors'; @@ -19,7 +19,7 @@ export const SubscriptionBadge = ({ subscription, elementDescriptor, }: { - subscription: CommerceSubscriptionResource; + subscription: CommerceSubscriptionItemResource; elementDescriptor?: ElementDescriptor; }) => { return ( diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index cdda0d9d8d7..8fc9d9d9c91 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -2,17 +2,15 @@ import { __experimental_usePaymentAttempts, __experimental_usePaymentMethods, __experimental_useStatements, - __experimental_useSubscriptionItems, + __experimental_useSubscription, useClerk, - useOrganization, useSession, - useUser, } from '@clerk/shared/react'; import type { Appearance, CommercePlanResource, + CommerceSubscriptionItemResource, CommerceSubscriptionPlanPeriod, - CommerceSubscriptionResource, } from '@clerk/types'; import { useCallback, useMemo } from 'react'; import useSWR from 'swr'; @@ -28,17 +26,6 @@ const dedupeOptions = { keepPreviousData: true, }; -export const usePaymentSourcesCacheKey = () => { - const { organization } = useOrganization(); - const { user } = useUser(); - const subscriberType = useSubscriberTypeContext(); - - return { - key: `commerce-payment-sources`, - resourceId: subscriberType === 'org' ? organization?.id : user?.id, - }; -}; - // TODO(@COMMERCE): Rename payment sources to payment methods at the API level export const usePaymentMethods = () => { const subscriberType = useSubscriberTypeContext(); @@ -71,15 +58,18 @@ export const useStatements = (params?: { mode: 'cache' }) => { }); }; -export const useSubscriptions = () => { +export const useSubscription = () => { const subscriberType = useSubscriberTypeContext(); - - return __experimental_useSubscriptionItems({ + const subscription = __experimental_useSubscription({ for: subscriberType === 'org' ? 'organization' : 'user', - initialPage: 1, - pageSize: 10, keepPreviousData: true, }); + const subscriptionItems = useMemo(() => subscription.data?.subscriptionItems || [], [subscription.data]); + + return { + ...subscription, + subscriptionItems, + }; }; export const usePlans = () => { @@ -122,7 +112,7 @@ export const usePlansContext = () => { return false; }, [clerk, subscriberType]); - const { data: subscriptions, revalidate: revalidateSubscriptions } = useSubscriptions(); + const { subscriptionItems, revalidate: revalidateSubscriptions } = useSubscription(); // Invalidates cache but does not fetch immediately const { data: plans, mutate: mutatePlans } = useSWR>>({ @@ -146,23 +136,23 @@ 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.canceledAtDate); - }, [subscriptions]); + return subscriptionItems.length === 0 || !subscriptionItems.some(subscription => !subscription.canceledAtDate); + }, [subscriptionItems]); // return the active or upcoming subscription for a plan if it exists const activeOrUpcomingSubscription = useCallback( (plan: CommercePlanResource) => { - return subscriptions.find(subscription => subscription.plan.id === plan.id); + return subscriptionItems.find(subscription => subscription.plan.id === plan.id); }, - [subscriptions], + [subscriptionItems], ); // returns all subscriptions for a plan that are active or upcoming const activeAndUpcomingSubscriptions = useCallback( (plan: CommercePlanResource) => { - return subscriptions.filter(subscription => subscription.plan.id === plan.id); + return subscriptionItems.filter(subscription => subscription.plan.id === plan.id); }, - [subscriptions], + [subscriptionItems], ); // return the active or upcoming subscription for a plan based on the plan period, if there is no subscription for the plan period, return the first subscription @@ -192,7 +182,7 @@ export const usePlansContext = () => { ); const canManageSubscription = useCallback( - ({ plan, subscription: sub }: { plan?: CommercePlanResource; subscription?: CommerceSubscriptionResource }) => { + ({ plan, subscription: sub }: { plan?: CommercePlanResource; subscription?: CommerceSubscriptionItemResource }) => { const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined); return !subscription || !subscription.canceledAtDate; @@ -209,7 +199,7 @@ export const usePlansContext = () => { selectedPlanPeriod = 'annual', }: { plan?: CommercePlanResource; - subscription?: CommerceSubscriptionResource; + subscription?: CommerceSubscriptionItemResource; isCompact?: boolean; selectedPlanPeriod?: CommerceSubscriptionPlanPeriod; }): { @@ -262,7 +252,7 @@ export const usePlansContext = () => { // Handle non-subscription cases const hasNonDefaultSubscriptions = - subscriptions.filter(subscription => !subscription.plan.isDefault).length > 0; + subscriptionItems.filter(subscription => !subscription.plan.isDefault).length > 0; return hasNonDefaultSubscriptions ? localizationKeys('commerce.switchPlan') : localizationKeys('commerce.subscribe'); @@ -276,10 +266,10 @@ export const usePlansContext = () => { disabled: !canManageBilling, }; }, - [activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptions], + [activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems], ); - const captionForSubscription = useCallback((subscription: CommerceSubscriptionResource) => { + const captionForSubscription = useCallback((subscription: CommerceSubscriptionItemResource) => { if (subscription.pastDueAt) { return localizationKeys('badge__pastDueAt', { date: subscription.pastDueAt }); } diff --git a/packages/shared/src/react/hooks/createCommerceHook.tsx b/packages/shared/src/react/hooks/createCommerceHook.tsx index b562568db7f..84a8543b80b 100644 --- a/packages/shared/src/react/hooks/createCommerceHook.tsx +++ b/packages/shared/src/react/hooks/createCommerceHook.tsx @@ -35,7 +35,7 @@ type CommerceHookConfig({ +export function createCommercePaginatedHook({ hookName, resourceType, useFetcher, diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 666b60a2f57..827814cdf1a 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -11,5 +11,5 @@ export { useReverification } from './useReverification'; export { useStatements as __experimental_useStatements } from './useStatements'; export { usePaymentAttempts as __experimental_usePaymentAttempts } from './usePaymentAttempts'; export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaymentMethods'; -export { useSubscriptionItems as __experimental_useSubscriptionItems } from './useSubscriptionItems'; +export { useSubscription as __experimental_useSubscription } from './useSubscriptionItems'; export { useCheckout as __experimental_useCheckout } from './useCheckout'; diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx index ad45b6a867c..ded4b0dda9e 100644 --- a/packages/shared/src/react/hooks/useOrganization.tsx +++ b/packages/shared/src/react/hooks/useOrganization.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-description-complete-sentence */ import type { ClerkPaginatedResponse, - CommerceSubscriptionResource, + CommerceSubscriptionItemResource, GetDomainsParams, GetInvitationsParams, GetMembershipRequestParams, @@ -115,7 +115,7 @@ export type UseOrganizationReturn = * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * Includes a paginated list of the organization's subscriptions. */ - subscriptions: PaginatedResourcesWithDefault; + subscriptions: PaginatedResourcesWithDefault; } | { isLoaded: true; @@ -125,7 +125,7 @@ export type UseOrganizationReturn = membershipRequests: PaginatedResourcesWithDefault; memberships: PaginatedResourcesWithDefault; invitations: PaginatedResourcesWithDefault; - subscriptions: PaginatedResourcesWithDefault; + subscriptions: PaginatedResourcesWithDefault; } | { isLoaded: boolean; @@ -148,7 +148,7 @@ export type UseOrganizationReturn = T['invitations'] extends { infinite: true } ? true : false > | null; subscriptions: PaginatedResources< - CommerceSubscriptionResource, + CommerceSubscriptionItemResource, T['subscriptions'] extends { infinite: true } ? true : false > | null; }; @@ -465,7 +465,7 @@ export function useOrganization(params?: T): Us const subscriptions = usePagesOrInfinite< GetSubscriptionsParams, - ClerkPaginatedResponse + ClerkPaginatedResponse >( { ...subscriptionsParams, diff --git a/packages/shared/src/react/hooks/usePaymentAttempts.tsx b/packages/shared/src/react/hooks/usePaymentAttempts.tsx index 56cf4135b05..de3297cc109 100644 --- a/packages/shared/src/react/hooks/usePaymentAttempts.tsx +++ b/packages/shared/src/react/hooks/usePaymentAttempts.tsx @@ -1,12 +1,12 @@ import type { CommercePaymentResource, GetPaymentAttemptsParams } from '@clerk/types'; import { useClerkInstanceContext } from '../contexts'; -import { createCommerceHook } from './createCommerceHook'; +import { createCommercePaginatedHook } from './createCommerceHook'; /** * @internal */ -export const usePaymentAttempts = createCommerceHook({ +export const usePaymentAttempts = createCommercePaginatedHook({ hookName: 'usePaymentAttempts', resourceType: 'commerce-payment-attempts', useFetcher: () => { diff --git a/packages/shared/src/react/hooks/usePaymentMethods.tsx b/packages/shared/src/react/hooks/usePaymentMethods.tsx index 3524c8ca139..62ceaad9c91 100644 --- a/packages/shared/src/react/hooks/usePaymentMethods.tsx +++ b/packages/shared/src/react/hooks/usePaymentMethods.tsx @@ -1,12 +1,12 @@ import type { CommercePaymentSourceResource, GetPaymentSourcesParams } from '@clerk/types'; import { useOrganizationContext, useUserContext } from '../contexts'; -import { createCommerceHook } from './createCommerceHook'; +import { createCommercePaginatedHook } from './createCommerceHook'; /** * @internal */ -export const usePaymentMethods = createCommerceHook({ +export const usePaymentMethods = createCommercePaginatedHook({ hookName: 'usePaymentMethods', resourceType: 'commerce-payment-methods', useFetcher: resource => { diff --git a/packages/shared/src/react/hooks/useStatements.tsx b/packages/shared/src/react/hooks/useStatements.tsx index 1ef1f9c0ff9..43d1aa4485c 100644 --- a/packages/shared/src/react/hooks/useStatements.tsx +++ b/packages/shared/src/react/hooks/useStatements.tsx @@ -1,12 +1,12 @@ import type { CommerceStatementResource, GetStatementsParams } from '@clerk/types'; import { useClerkInstanceContext } from '../contexts'; -import { createCommerceHook } from './createCommerceHook'; +import { createCommercePaginatedHook } from './createCommerceHook'; /** * @internal */ -export const useStatements = createCommerceHook({ +export const useStatements = createCommercePaginatedHook({ hookName: 'useStatements', resourceType: 'commerce-statements', useFetcher: () => { diff --git a/packages/shared/src/react/hooks/useSubscriptionItems.tsx b/packages/shared/src/react/hooks/useSubscriptionItems.tsx index db2db7eb889..a533a709280 100644 --- a/packages/shared/src/react/hooks/useSubscriptionItems.tsx +++ b/packages/shared/src/react/hooks/useSubscriptionItems.tsx @@ -1,16 +1,50 @@ -import type { CommerceSubscriptionResource, GetSubscriptionsParams } from '@clerk/types'; +import { useCallback } from 'react'; -import { useClerkInstanceContext } from '../contexts'; -import { createCommerceHook } from './createCommerceHook'; +import { eventMethodCalled } from '../../telemetry/events'; +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; + +type UseSubscriptionParams = { + for?: 'organization' | 'user'; + /** + * If `true`, the previous data will be kept in the cache until new data is fetched. + * + * @default false + */ + keepPreviousData?: boolean; +}; /** * @internal */ -export const useSubscriptionItems = createCommerceHook({ - hookName: 'useSubscriptionItems', - resourceType: 'commerce-subscription-items', - useFetcher: () => { - const clerk = useClerkInstanceContext(); - return clerk.billing.getSubscriptions; - }, -}); +export const useSubscription = (params?: UseSubscriptionParams) => { + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + clerk.telemetry?.record(eventMethodCalled('useSubscription')); + + const swr = useSWR( + user?.id + ? { + type: 'commerce-subscription', + userId: user?.id, + args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, + } + : null, + ({ args }) => clerk.billing.getSubscription(args), + { + dedupingInterval: 1_000 * 60, + keepPreviousData: params?.keepPreviousData, + }, + ); + + const revalidate = useCallback(() => swr.mutate(), [swr.mutate]); + + return { + data: swr.data, + error: swr.error, + isLoading: swr.isLoading, + isFetching: swr.isValidating, + revalidate, + }; +}; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 6d4cba1744e..ed317c8a052 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -54,7 +54,20 @@ export interface CommerceBillingNamespace { * * ``` */ - getSubscriptions: (params: GetSubscriptionsParams) => Promise>; + getSubscription: (params: GetSubscriptionsParams) => Promise; + + /** + * @deprecated + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + getSubscriptions: ( + params: GetSubscriptionsParams, + ) => Promise>; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. @@ -754,6 +767,7 @@ export interface CommercePaymentResource extends ClerkResource { */ paymentSource: CommercePaymentSourceResource; /** + * @deprecated * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. * @example @@ -761,7 +775,7 @@ export interface CommercePaymentResource extends ClerkResource { * * ``` */ - subscription: CommerceSubscriptionResource; + subscription: CommerceSubscriptionItemResource; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -770,7 +784,7 @@ export interface CommercePaymentResource extends ClerkResource { * * ``` */ - subscriptionItem: CommerceSubscriptionResource; + subscriptionItem: CommerceSubscriptionItemResource; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -909,6 +923,16 @@ export interface CommerceStatementGroup { */ export type GetSubscriptionsParams = WithOptionalOrgType; +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export type GetSubscriptionParams = WithOptionalOrgType; + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. @@ -927,7 +951,7 @@ export type CancelSubscriptionParams = WithOptionalOrgType; * * ``` */ -export interface CommerceSubscriptionResource extends ClerkResource { +export interface CommerceSubscriptionItemResource extends ClerkResource { id: string; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. @@ -937,6 +961,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { * * ``` */ + //TODO(@COMMERCE): should this be nullable ? paymentSourceId: string; /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. @@ -1061,6 +1086,118 @@ export interface CommerceSubscriptionResource extends ClerkResource { cancel: (params: CancelSubscriptionParams) => Promise; } +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export interface CommerceSubscriptionResource extends ClerkResource { + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + id: string; + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + activeAt: Date; + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + createdAt: Date; + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + nextPayment: { + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + amount: CommerceMoney; + // This need to change to `date` probably + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + time: Date; + } | null; + // Is this for all items, or for at least one ? + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + pastDueAt: Date | null; + + // When does this change ? what if an item is active and one is upcoming ? + // What if one is active and one is past due ? + // What if one is past due and one upcoming ? + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + status: CommerceSubscriptionStatus; + + // Can this ever be 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 the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + subscriptionItems: CommerceSubscriptionItemResource[]; + + // When does this get updated ? + /** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ + updatedAt: Date | 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 the SDK version and the clerk-js version to a specific version to avoid breaking changes. diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index ec37cef7cf0..8dc07c2515a 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -764,8 +764,8 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { failed_at?: number; updated_at: number; payment_source: CommercePaymentSourceJSON; - subscription: CommerceSubscriptionJSON; - subscription_item: CommerceSubscriptionJSON; + subscription: CommerceSubscriptionItemJSON; + subscription_item: CommerceSubscriptionItemJSON; charge_type: CommercePaymentChargeType; status: CommercePaymentStatus; } @@ -778,8 +778,8 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { * * ``` */ -export interface CommerceSubscriptionJSON extends ClerkResourceJSON { - object: 'commerce_subscription'; +export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON { + object: 'commerce_subscription_item'; id: string; amount?: CommerceMoneyJSON; credit?: { @@ -796,6 +796,30 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { past_due_at: number | 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 the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export interface CommerceSubscriptionJSON extends ClerkResourceJSON { + object: 'commerce_subscription'; + id: string; + next_payment?: { + amount: CommerceMoneyJSON; + // This need to change to `date` probably + time: number; + }; + status: CommerceSubscriptionStatus; + created_at: number; + active_at: number; + update_at: number | null; + past_due_at: number | null; + subscription_items: CommerceSubscriptionItemJSON[]; +} + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index bfd113152d4..176651f4fc9 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -1,4 +1,8 @@ -import type { CommercePaymentSourceMethods, CommerceSubscriptionResource, GetSubscriptionsParams } from './commerce'; +import type { + CommercePaymentSourceMethods, + CommerceSubscriptionItemResource, + GetSubscriptionsParams, +} from './commerce'; import type { OrganizationDomainResource, OrganizationEnrollmentMode } from './organizationDomain'; import type { OrganizationInvitationResource, OrganizationInvitationStatus } from './organizationInvitation'; import type { OrganizationCustomRoleKey, OrganizationMembershipResource } from './organizationMembership'; @@ -59,7 +63,9 @@ export interface OrganizationResource extends ClerkResource, CommercePaymentSour /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. */ - getSubscriptions: (params?: GetSubscriptionsParams) => Promise>; + getSubscriptions: ( + params?: GetSubscriptionsParams, + ) => Promise>; addMember: (params: AddMemberParams) => Promise; inviteMember: (params: InviteMemberParams) => Promise; inviteMembers: (params: InviteMembersParams) => Promise;