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:
martmull
2024-03-07 17:22:58 +01:00
committed by GitHub
parent af6ffbcc68
commit 4a7a629824
11 changed files with 354 additions and 58 deletions

View File

@ -64,7 +64,8 @@ export const PageChangeEffect = () => {
isMatchingOngoingUserCreationRoute ||
isMatchingLocation(AppPath.CreateWorkspace) ||
isMatchingLocation(AppPath.CreateProfile) ||
isMatchingLocation(AppPath.PlanRequired);
isMatchingLocation(AppPath.PlanRequired) ||
isMatchingLocation(AppPath.PlanRequiredSuccess);
const navigateToSignUp = () => {
enqueueSnackBar('workspace does not exist', {
@ -81,12 +82,20 @@ export const PageChangeEffect = () => {
navigate(AppPath.SignIn);
} else if (
onboardingStatus &&
[OnboardingStatus.Canceled, OnboardingStatus.Incomplete].includes(
onboardingStatus,
) &&
onboardingStatus === OnboardingStatus.Incomplete &&
!isMatchingLocation(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 (
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
!isMatchingLocation(AppPath.CreateWorkspace) &&

View File

@ -104,7 +104,13 @@ describe('useOnboardingStatus', () => {
...currentWorkspace,
subscriptionStatus: 'canceled',
});
setCurrentWorkspaceMember(currentWorkspaceMember);
setCurrentWorkspaceMember({
...currentWorkspaceMember,
name: {
firstName: 'John',
lastName: 'Doe',
},
});
});
expect(result.current.onboardingStatus).toBe('canceled');
@ -178,4 +184,60 @@ describe('useOnboardingStatus', () => {
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');
});
});

View File

@ -4,6 +4,8 @@ import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export enum OnboardingStatus {
Incomplete = 'incomplete',
Canceled = 'canceled',
Unpaid = 'unpaid',
PastDue = 'past_due',
OngoingUserCreation = 'ongoing_user_creation',
OngoingWorkspaceActivation = 'ongoing_workspace_activation',
OngoingProfileCreation = 'ongoing_profile_creation',
@ -41,10 +43,6 @@ export const getOnboardingStatus = ({
return OnboardingStatus.Incomplete;
}
if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') {
return OnboardingStatus.Canceled;
}
if (currentWorkspace.activationStatus !== 'active') {
return OnboardingStatus.OngoingWorkspaceActivation;
}
@ -56,5 +54,17 @@ export const getOnboardingStatus = ({
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;
};

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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],
};

View File

@ -76,7 +76,13 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
const isMatchingLocation = useIsMatchingLocation();
const showAuthModal = useMemo(() => {
return (
(onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) ||
(onboardingStatus &&
[
OnboardingStatus.Incomplete,
OnboardingStatus.OngoingUserCreation,
OnboardingStatus.OngoingProfileCreation,
OnboardingStatus.OngoingWorkspaceActivation,
].includes(onboardingStatus)) ||
isMatchingLocation(AppPath.ResetPassword)
);
}, [isMatchingLocation, onboardingStatus]);

View File

@ -1,30 +1,104 @@
import React from 'react';
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 { supportChatState } from '@/client-config/states/supportChatState.ts';
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 { 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 { Section } from '@/ui/layout/section/components/Section.tsx';
import { useBillingPortalSessionQuery } from '~/generated/graphql.tsx';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
`;
export const SettingsBilling = () => (
<SubMenuTopBarContainer Icon={IconCurrencyDollar} title="Billing">
<SettingsPageContainer>
<StyledH1Title title="Billing" />
<SettingsBillingCoverImage />
<Section>
<H2Title
title="Manage your subscription"
description="Edit payment method, see your invoices and more"
/>
<ManageYourSubscription />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
const StyledInvisibleChat = styled.div`
display: none;
`;
export const SettingsBilling = () => {
const onboardingStatus = useOnboardingStatus();
const supportChat = useRecoilValue(supportChatState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: '/settings/billing',
},
});
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>
);
};

View File

@ -38,9 +38,14 @@ export class BillingController {
req.rawBody,
);
if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) {
await this.billingService.handleUnpaidInvoices(event.data);
}
if (
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;

View File

@ -20,6 +20,8 @@ export enum AvailableProduct {
export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded',
}
@Injectable()
@ -74,9 +76,12 @@ export class BillingService {
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({
where: { workspaceId },
where: criteria,
relations: ['billingSubscriptionItems'],
});
}
@ -85,7 +90,9 @@ export class BillingService {
workspaceId: string,
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) {
const billingSubscription = await this.getBillingSubscription(workspaceId);
const billingSubscription = await this.getBillingSubscription({
workspaceId,
});
const billingSubscriptionItem =
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(
workspaceId: string,
data:
@ -176,7 +199,7 @@ export class BillingService {
status: data.object.status,
},
{
conflictPaths: ['stripeSubscriptionId'],
conflictPaths: ['workspaceId'],
skipUpdateIfNoValuesChanged: true,
},
);
@ -185,7 +208,9 @@ export class BillingService {
subscriptionStatus: data.object.status,
});
const billingSubscription = await this.getBillingSubscription(workspaceId);
const billingSubscription = await this.getBillingSubscription({
workspaceId,
});
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
@ -198,7 +223,7 @@ export class BillingService {
};
}),
{
conflictPaths: ['stripeSubscriptionItemId', 'billingSubscriptionId'],
conflictPaths: ['billingSubscriptionId', 'stripeProductId'],
skipUpdateIfNoValuesChanged: true,
},
);

View File

@ -80,4 +80,23 @@ export class StripeService {
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);
}
}