diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
index b86a15c6d..54c052c7a 100644
--- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
@@ -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) &&
diff --git a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
index ab47fa3af..0758ed0d7 100644
--- a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
@@ -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');
+ });
});
diff --git a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
index 4479e730e..0ed331041 100644
--- a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
+++ b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
@@ -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;
};
diff --git a/packages/twenty-front/src/modules/billing/components/ManageYourSubscription.tsx b/packages/twenty-front/src/modules/billing/components/ManageYourSubscription.tsx
deleted file mode 100644
index e95067d18..000000000
--- a/packages/twenty-front/src/modules/billing/components/ManageYourSubscription.tsx
+++ /dev/null
@@ -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 (
-
- );
-};
diff --git a/packages/twenty-front/src/modules/ui/display/info/components/Info.tsx b/packages/twenty-front/src/modules/ui/display/info/components/Info.tsx
new file mode 100644
index 000000000..bb674eabd
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/display/info/components/Info.tsx
@@ -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) => void;
+};
+
+const StyledTextContainer = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledInfo = styled.div>`
+ 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 (
+
+
+
+ {text}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/display/info/components/__stories__/Info.stories.tsx b/packages/twenty-front/src/modules/ui/display/info/components/__stories__/Info.stories.tsx
new file mode 100644
index 000000000..98ac1e317
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/display/info/components/__stories__/Info.stories.tsx
@@ -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 = {
+ title: 'UI/Display/Info/Info',
+ component: Info,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ accent: 'blue',
+ text: 'An info component',
+ buttonTitle: 'Update',
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: CatalogStory = {
+ 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],
+};
diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
index 3c872dd17..18efeb419 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
@@ -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]);
diff --git a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
index 29989ccfb..650788fb9 100644
--- a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
@@ -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 = () => (
-
-
-
-
-
-
-
-);
+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 (
+
+
+
+
+ {displaySubscriptionCanceledInfo && (
+
+ )}
+ {displayPaymentFailInfo && (
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-server/src/core/billing/billing.controller.ts b/packages/twenty-server/src/core/billing/billing.controller.ts
index e49533b84..c0cb2dd97 100644
--- a/packages/twenty-server/src/core/billing/billing.controller.ts
+++ b/packages/twenty-server/src/core/billing/billing.controller.ts
@@ -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;
diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts
index ee8406d08..f17256c2f 100644
--- a/packages/twenty-server/src/core/billing/billing.service.ts
+++ b/packages/twenty-server/src/core/billing/billing.service.ts
@@ -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,
},
);
diff --git a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
index 2c68c7a6c..f57515797 100644
--- a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
+++ b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
@@ -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);
+ }
}