44 add blocking middleware payment failed (#4339)
* Add info ui component * Add info in billing settings * Add billing middleware * Handle subscription canceled webhook event * Stop deleting billingSubscription when subscription canceled * Handle subscription unpaid recovery * Handle subscription canceled status * Fix test * Add test * Fix test chatSupport display * Fix design
This commit is contained in:
@ -64,7 +64,8 @@ export const PageChangeEffect = () => {
|
|||||||
isMatchingOngoingUserCreationRoute ||
|
isMatchingOngoingUserCreationRoute ||
|
||||||
isMatchingLocation(AppPath.CreateWorkspace) ||
|
isMatchingLocation(AppPath.CreateWorkspace) ||
|
||||||
isMatchingLocation(AppPath.CreateProfile) ||
|
isMatchingLocation(AppPath.CreateProfile) ||
|
||||||
isMatchingLocation(AppPath.PlanRequired);
|
isMatchingLocation(AppPath.PlanRequired) ||
|
||||||
|
isMatchingLocation(AppPath.PlanRequiredSuccess);
|
||||||
|
|
||||||
const navigateToSignUp = () => {
|
const navigateToSignUp = () => {
|
||||||
enqueueSnackBar('workspace does not exist', {
|
enqueueSnackBar('workspace does not exist', {
|
||||||
@ -81,12 +82,20 @@ export const PageChangeEffect = () => {
|
|||||||
navigate(AppPath.SignIn);
|
navigate(AppPath.SignIn);
|
||||||
} else if (
|
} else if (
|
||||||
onboardingStatus &&
|
onboardingStatus &&
|
||||||
[OnboardingStatus.Canceled, OnboardingStatus.Incomplete].includes(
|
onboardingStatus === OnboardingStatus.Incomplete &&
|
||||||
onboardingStatus,
|
|
||||||
) &&
|
|
||||||
!isMatchingLocation(AppPath.PlanRequired)
|
!isMatchingLocation(AppPath.PlanRequired)
|
||||||
) {
|
) {
|
||||||
navigate(AppPath.PlanRequired);
|
navigate(AppPath.PlanRequired);
|
||||||
|
} else if (
|
||||||
|
onboardingStatus &&
|
||||||
|
[OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes(
|
||||||
|
onboardingStatus,
|
||||||
|
) &&
|
||||||
|
!isMatchingLocation(SettingsPath.Billing)
|
||||||
|
) {
|
||||||
|
navigate(
|
||||||
|
`${AppPath.SettingsCatchAll.replace('/*', '')}/${SettingsPath.Billing}`,
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
|
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
|
||||||
!isMatchingLocation(AppPath.CreateWorkspace) &&
|
!isMatchingLocation(AppPath.CreateWorkspace) &&
|
||||||
|
|||||||
@ -104,7 +104,13 @@ describe('useOnboardingStatus', () => {
|
|||||||
...currentWorkspace,
|
...currentWorkspace,
|
||||||
subscriptionStatus: 'canceled',
|
subscriptionStatus: 'canceled',
|
||||||
});
|
});
|
||||||
setCurrentWorkspaceMember(currentWorkspaceMember);
|
setCurrentWorkspaceMember({
|
||||||
|
...currentWorkspaceMember,
|
||||||
|
name: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.onboardingStatus).toBe('canceled');
|
expect(result.current.onboardingStatus).toBe('canceled');
|
||||||
@ -178,4 +184,60 @@ describe('useOnboardingStatus', () => {
|
|||||||
|
|
||||||
expect(result.current.onboardingStatus).toBe('completed');
|
expect(result.current.onboardingStatus).toBe('completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return "past_due"', async () => {
|
||||||
|
const { result } = renderHooks();
|
||||||
|
const {
|
||||||
|
setTokenPair,
|
||||||
|
setBilling,
|
||||||
|
setCurrentWorkspace,
|
||||||
|
setCurrentWorkspaceMember,
|
||||||
|
} = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setTokenPair(tokenPair);
|
||||||
|
setBilling(billing);
|
||||||
|
setCurrentWorkspace({
|
||||||
|
...currentWorkspace,
|
||||||
|
subscriptionStatus: 'past_due',
|
||||||
|
});
|
||||||
|
setCurrentWorkspaceMember({
|
||||||
|
...currentWorkspaceMember,
|
||||||
|
name: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.onboardingStatus).toBe('past_due');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "unpaid"', async () => {
|
||||||
|
const { result } = renderHooks();
|
||||||
|
const {
|
||||||
|
setTokenPair,
|
||||||
|
setBilling,
|
||||||
|
setCurrentWorkspace,
|
||||||
|
setCurrentWorkspaceMember,
|
||||||
|
} = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setTokenPair(tokenPair);
|
||||||
|
setBilling(billing);
|
||||||
|
setCurrentWorkspace({
|
||||||
|
...currentWorkspace,
|
||||||
|
subscriptionStatus: 'unpaid',
|
||||||
|
});
|
||||||
|
setCurrentWorkspaceMember({
|
||||||
|
...currentWorkspaceMember,
|
||||||
|
name: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.onboardingStatus).toBe('unpaid');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
|||||||
export enum OnboardingStatus {
|
export enum OnboardingStatus {
|
||||||
Incomplete = 'incomplete',
|
Incomplete = 'incomplete',
|
||||||
Canceled = 'canceled',
|
Canceled = 'canceled',
|
||||||
|
Unpaid = 'unpaid',
|
||||||
|
PastDue = 'past_due',
|
||||||
OngoingUserCreation = 'ongoing_user_creation',
|
OngoingUserCreation = 'ongoing_user_creation',
|
||||||
OngoingWorkspaceActivation = 'ongoing_workspace_activation',
|
OngoingWorkspaceActivation = 'ongoing_workspace_activation',
|
||||||
OngoingProfileCreation = 'ongoing_profile_creation',
|
OngoingProfileCreation = 'ongoing_profile_creation',
|
||||||
@ -41,10 +43,6 @@ export const getOnboardingStatus = ({
|
|||||||
return OnboardingStatus.Incomplete;
|
return OnboardingStatus.Incomplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') {
|
|
||||||
return OnboardingStatus.Canceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentWorkspace.activationStatus !== 'active') {
|
if (currentWorkspace.activationStatus !== 'active') {
|
||||||
return OnboardingStatus.OngoingWorkspaceActivation;
|
return OnboardingStatus.OngoingWorkspaceActivation;
|
||||||
}
|
}
|
||||||
@ -56,5 +54,17 @@ export const getOnboardingStatus = ({
|
|||||||
return OnboardingStatus.OngoingProfileCreation;
|
return OnboardingStatus.OngoingProfileCreation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') {
|
||||||
|
return OnboardingStatus.Canceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'past_due') {
|
||||||
|
return OnboardingStatus.PastDue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'unpaid') {
|
||||||
|
return OnboardingStatus.Unpaid;
|
||||||
|
}
|
||||||
|
|
||||||
return OnboardingStatus.Completed;
|
return OnboardingStatus.Completed;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import { IconCreditCard } from '@/ui/display/icon';
|
|
||||||
import { Button } from '@/ui/input/button/components/Button';
|
|
||||||
import { useBillingPortalSessionQuery } from '~/generated/graphql.tsx';
|
|
||||||
export const ManageYourSubscription = () => {
|
|
||||||
const { data, loading } = useBillingPortalSessionQuery({
|
|
||||||
variables: {
|
|
||||||
returnUrlPath: '/settings/billing',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const handleButtonClick = () => {
|
|
||||||
if (data) {
|
|
||||||
window.location.replace(data.billingPortalSession.url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
Icon={IconCreditCard}
|
|
||||||
title="View billing details"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleButtonClick}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css, useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { IconInfoCircle } from '@/ui/display/icon';
|
||||||
|
import { Button } from '@/ui/input/button/components/Button.tsx';
|
||||||
|
|
||||||
|
export type InfoAccent = 'blue' | 'danger';
|
||||||
|
export type InfoProps = {
|
||||||
|
accent?: InfoAccent;
|
||||||
|
text: string;
|
||||||
|
buttonTitle: string;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledTextContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledInfo = styled.div<Pick<InfoProps, 'accent'>>`
|
||||||
|
align-items: center;
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
display: flex;
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 512px;
|
||||||
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
|
${({ theme, accent }) => {
|
||||||
|
switch (accent) {
|
||||||
|
case 'blue':
|
||||||
|
return css`
|
||||||
|
background: ${theme.color.blueAccent20};
|
||||||
|
color: ${theme.color.blue50};
|
||||||
|
`;
|
||||||
|
case 'danger':
|
||||||
|
return css`
|
||||||
|
background: ${theme.color.red10};
|
||||||
|
color: ${theme.color.red};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
export const Info = ({
|
||||||
|
accent = 'blue',
|
||||||
|
text,
|
||||||
|
buttonTitle,
|
||||||
|
onClick,
|
||||||
|
}: InfoProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<StyledInfo accent={accent}>
|
||||||
|
<StyledTextContainer>
|
||||||
|
<IconInfoCircle size={theme.icon.size.md} />
|
||||||
|
{text}
|
||||||
|
</StyledTextContainer>
|
||||||
|
<Button
|
||||||
|
title={buttonTitle}
|
||||||
|
onClick={onClick}
|
||||||
|
size={'small'}
|
||||||
|
variant={'secondary'}
|
||||||
|
/>
|
||||||
|
</StyledInfo>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Info, InfoAccent } from '@/ui/display/info/components/Info.tsx';
|
||||||
|
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator.tsx';
|
||||||
|
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator.tsx';
|
||||||
|
import { CatalogStory } from '~/testing/types.ts';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Info> = {
|
||||||
|
title: 'UI/Display/Info/Info',
|
||||||
|
component: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Info>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
accent: 'blue',
|
||||||
|
text: 'An info component',
|
||||||
|
buttonTitle: 'Update',
|
||||||
|
},
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Catalog: CatalogStory<Story, typeof Info> = {
|
||||||
|
args: {
|
||||||
|
text: 'An info component',
|
||||||
|
buttonTitle: 'Update',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
accent: { control: false },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
catalog: {
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
name: 'accents',
|
||||||
|
values: ['blue', 'danger'] satisfies InfoAccent[],
|
||||||
|
props: (accent: InfoAccent) => ({ accent }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [CatalogDecorator],
|
||||||
|
};
|
||||||
@ -76,7 +76,13 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
|
|||||||
const isMatchingLocation = useIsMatchingLocation();
|
const isMatchingLocation = useIsMatchingLocation();
|
||||||
const showAuthModal = useMemo(() => {
|
const showAuthModal = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
(onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) ||
|
(onboardingStatus &&
|
||||||
|
[
|
||||||
|
OnboardingStatus.Incomplete,
|
||||||
|
OnboardingStatus.OngoingUserCreation,
|
||||||
|
OnboardingStatus.OngoingProfileCreation,
|
||||||
|
OnboardingStatus.OngoingWorkspaceActivation,
|
||||||
|
].includes(onboardingStatus)) ||
|
||||||
isMatchingLocation(AppPath.ResetPassword)
|
isMatchingLocation(AppPath.ResetPassword)
|
||||||
);
|
);
|
||||||
}, [isMatchingLocation, onboardingStatus]);
|
}, [isMatchingLocation, onboardingStatus]);
|
||||||
|
|||||||
@ -1,30 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
import { ManageYourSubscription } from '@/billing/components/ManageYourSubscription.tsx';
|
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts';
|
||||||
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts';
|
||||||
|
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts';
|
||||||
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
|
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
|
||||||
|
import { supportChatState } from '@/client-config/states/supportChatState.ts';
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { IconCurrencyDollar } from '@/ui/display/icon';
|
import { SupportChat } from '@/support/components/SupportChat.tsx';
|
||||||
|
import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon';
|
||||||
|
import { Info } from '@/ui/display/info/components/Info.tsx';
|
||||||
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
|
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
|
||||||
import { H2Title } from '@/ui/display/typography/components/H2Title.tsx';
|
import { H2Title } from '@/ui/display/typography/components/H2Title.tsx';
|
||||||
|
import { Button } from '@/ui/input/button/components/Button.tsx';
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||||
import { Section } from '@/ui/layout/section/components/Section.tsx';
|
import { Section } from '@/ui/layout/section/components/Section.tsx';
|
||||||
|
import { useBillingPortalSessionQuery } from '~/generated/graphql.tsx';
|
||||||
|
|
||||||
const StyledH1Title = styled(H1Title)`
|
const StyledH1Title = styled(H1Title)`
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SettingsBilling = () => (
|
const StyledInvisibleChat = styled.div`
|
||||||
<SubMenuTopBarContainer Icon={IconCurrencyDollar} title="Billing">
|
display: none;
|
||||||
<SettingsPageContainer>
|
`;
|
||||||
<StyledH1Title title="Billing" />
|
|
||||||
<SettingsBillingCoverImage />
|
export const SettingsBilling = () => {
|
||||||
<Section>
|
const onboardingStatus = useOnboardingStatus();
|
||||||
<H2Title
|
const supportChat = useRecoilValue(supportChatState);
|
||||||
title="Manage your subscription"
|
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||||
description="Edit payment method, see your invoices and more"
|
const { data, loading } = useBillingPortalSessionQuery({
|
||||||
/>
|
variables: {
|
||||||
<ManageYourSubscription />
|
returnUrlPath: '/settings/billing',
|
||||||
</Section>
|
},
|
||||||
</SettingsPageContainer>
|
});
|
||||||
</SubMenuTopBarContainer>
|
|
||||||
);
|
const displayPaymentFailInfo =
|
||||||
|
onboardingStatus === OnboardingStatus.PastDue ||
|
||||||
|
onboardingStatus === OnboardingStatus.Unpaid;
|
||||||
|
|
||||||
|
const displaySubscriptionCanceledInfo =
|
||||||
|
onboardingStatus === OnboardingStatus.Canceled;
|
||||||
|
|
||||||
|
const openBillingPortal = () => {
|
||||||
|
if (data) {
|
||||||
|
window.location.replace(data.billingPortalSession.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChat = () => {
|
||||||
|
if (supportChat.supportDriver) {
|
||||||
|
window.FrontChat?.('show');
|
||||||
|
} else {
|
||||||
|
window.location.href =
|
||||||
|
'mailto:felix@twenty.com?' +
|
||||||
|
`subject=Subscription Recovery for workspace ${currentWorkspace?.id}&` +
|
||||||
|
'body=Hey,%0D%0A%0D%0AMy subscription is canceled and I would like to subscribe a new one.' +
|
||||||
|
'Can you help me?%0D%0A%0D%0ACheers';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubMenuTopBarContainer Icon={IconCurrencyDollar} title="Billing">
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<StyledH1Title title="Billing" />
|
||||||
|
<SettingsBillingCoverImage />
|
||||||
|
{displaySubscriptionCanceledInfo && (
|
||||||
|
<Info
|
||||||
|
text={'Subscription canceled. Please contact us to start a new one'}
|
||||||
|
buttonTitle={'Contact Us'}
|
||||||
|
accent={'danger'}
|
||||||
|
onClick={openChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayPaymentFailInfo && (
|
||||||
|
<Info
|
||||||
|
text={'Last payment failed. Please update your billing details.'}
|
||||||
|
buttonTitle={'Update'}
|
||||||
|
accent={'danger'}
|
||||||
|
onClick={openBillingPortal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Manage your subscription"
|
||||||
|
description="Edit payment method, see your invoices and more"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
Icon={IconCreditCard}
|
||||||
|
title="View billing details"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={openBillingPortal}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</SettingsPageContainer>
|
||||||
|
<StyledInvisibleChat>
|
||||||
|
<SupportChat />
|
||||||
|
</StyledInvisibleChat>
|
||||||
|
</SubMenuTopBarContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -38,9 +38,14 @@ export class BillingController {
|
|||||||
req.rawBody,
|
req.rawBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) {
|
||||||
|
await this.billingService.handleUnpaidInvoices(event.data);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
|
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
|
||||||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED
|
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED ||
|
||||||
|
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED
|
||||||
) {
|
) {
|
||||||
const workspaceId = event.data.object.metadata?.workspaceId;
|
const workspaceId = event.data.object.metadata?.workspaceId;
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export enum AvailableProduct {
|
|||||||
export enum WebhookEvent {
|
export enum WebhookEvent {
|
||||||
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
||||||
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||||
|
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||||
|
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -74,9 +76,12 @@ export class BillingService {
|
|||||||
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
|
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBillingSubscription(workspaceId: string) {
|
async getBillingSubscription(criteria: {
|
||||||
|
workspaceId?: string;
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
}) {
|
||||||
return await this.billingSubscriptionRepository.findOneOrFail({
|
return await this.billingSubscriptionRepository.findOneOrFail({
|
||||||
where: { workspaceId },
|
where: criteria,
|
||||||
relations: ['billingSubscriptionItems'],
|
relations: ['billingSubscriptionItems'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -85,7 +90,9 @@ export class BillingService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
|
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
|
||||||
) {
|
) {
|
||||||
const billingSubscription = await this.getBillingSubscription(workspaceId);
|
const billingSubscription = await this.getBillingSubscription({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
const billingSubscriptionItem =
|
const billingSubscriptionItem =
|
||||||
billingSubscription.billingSubscriptionItems.filter(
|
billingSubscription.billingSubscriptionItems.filter(
|
||||||
@ -162,6 +169,22 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
|
||||||
|
try {
|
||||||
|
const billingSubscription = await this.getBillingSubscription({
|
||||||
|
stripeCustomerId: data.object.customer as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (billingSubscription.status === 'unpaid') {
|
||||||
|
await this.stripeService.collectLastInvoice(
|
||||||
|
billingSubscription.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async upsertBillingSubscription(
|
async upsertBillingSubscription(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data:
|
data:
|
||||||
@ -176,7 +199,7 @@ export class BillingService {
|
|||||||
status: data.object.status,
|
status: data.object.status,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
conflictPaths: ['stripeSubscriptionId'],
|
conflictPaths: ['workspaceId'],
|
||||||
skipUpdateIfNoValuesChanged: true,
|
skipUpdateIfNoValuesChanged: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -185,7 +208,9 @@ export class BillingService {
|
|||||||
subscriptionStatus: data.object.status,
|
subscriptionStatus: data.object.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
const billingSubscription = await this.getBillingSubscription(workspaceId);
|
const billingSubscription = await this.getBillingSubscription({
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
await this.billingSubscriptionItemRepository.upsert(
|
await this.billingSubscriptionItemRepository.upsert(
|
||||||
data.object.items.data.map((item) => {
|
data.object.items.data.map((item) => {
|
||||||
@ -198,7 +223,7 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
conflictPaths: ['stripeSubscriptionItemId', 'billingSubscriptionId'],
|
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
|
||||||
skipUpdateIfNoValuesChanged: true,
|
skipUpdateIfNoValuesChanged: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -80,4 +80,23 @@ export class StripeService {
|
|||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async collectLastInvoice(stripeSubscriptionId: string) {
|
||||||
|
const subscription = await this.stripe.subscriptions.retrieve(
|
||||||
|
stripeSubscriptionId,
|
||||||
|
{ expand: ['latest_invoice'] },
|
||||||
|
);
|
||||||
|
const latestInvoice = subscription.latest_invoice;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
latestInvoice &&
|
||||||
|
typeof latestInvoice !== 'string' &&
|
||||||
|
latestInvoice.status === 'draft'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.stripe.invoices.pay(latestInvoice.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user