43 add billing portal link (#4318)
* Add create billing portal session endpoint * Rename checkout to checkoutSession * Add billig portal query in twenty-front * Add billing menu item * WIP: add menu page * Code review returns * Rename request files * Unwip: add menu page * Add billing cover image * Fix icon imports * Rename parameter * Add feature flag soon pill
This commit is contained in:
@ -40,6 +40,7 @@ import { SettingsDevelopersWebhooksDetail } from '~/pages/settings/developers/we
|
|||||||
import { SettingsDevelopersWebhooksNew } from '~/pages/settings/developers/webhooks/SettingsDevelopersWebhooksNew';
|
import { SettingsDevelopersWebhooksNew } from '~/pages/settings/developers/webhooks/SettingsDevelopersWebhooksNew';
|
||||||
import { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations';
|
import { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations';
|
||||||
import { SettingsAppearance } from '~/pages/settings/SettingsAppearance';
|
import { SettingsAppearance } from '~/pages/settings/SettingsAppearance';
|
||||||
|
import { SettingsBilling } from '~/pages/settings/SettingsBilling.tsx';
|
||||||
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
|
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
|
||||||
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
|
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
|
||||||
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
|
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
|
||||||
@ -114,6 +115,10 @@ export const App = () => {
|
|||||||
path={SettingsPath.AccountsEmailsInboxSettings}
|
path={SettingsPath.AccountsEmailsInboxSettings}
|
||||||
element={<SettingsAccountsEmailsInboxSettings />}
|
element={<SettingsAccountsEmailsInboxSettings />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path={SettingsPath.Billing}
|
||||||
|
element={<SettingsBilling />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={SettingsPath.WorkspaceMembersPage}
|
path={SettingsPath.WorkspaceMembersPage}
|
||||||
element={<SettingsWorkspaceMembers />}
|
element={<SettingsWorkspaceMembers />}
|
||||||
|
|||||||
@ -889,6 +889,13 @@ export type ValidatePasswordResetTokenQueryVariables = Exact<{
|
|||||||
|
|
||||||
export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
|
export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
|
||||||
|
|
||||||
|
export type BillingPortalSessionQueryVariables = Exact<{
|
||||||
|
returnUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'SessionEntity', url: string } };
|
||||||
|
|
||||||
export type CheckoutSessionMutationVariables = Exact<{
|
export type CheckoutSessionMutationVariables = Exact<{
|
||||||
recurringInterval: Scalars['String'];
|
recurringInterval: Scalars['String'];
|
||||||
successUrlPath?: InputMaybe<Scalars['String']>;
|
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
@ -1588,6 +1595,41 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
|
|||||||
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
|
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
|
||||||
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
|
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
|
||||||
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
|
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
|
||||||
|
export const BillingPortalSessionDocument = gql`
|
||||||
|
query BillingPortalSession($returnUrlPath: String) {
|
||||||
|
billingPortalSession(returnUrlPath: $returnUrlPath) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useBillingPortalSessionQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useBillingPortalSessionQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useBillingPortalSessionQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useBillingPortalSessionQuery({
|
||||||
|
* variables: {
|
||||||
|
* returnUrlPath: // value for 'returnUrlPath'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useBillingPortalSessionQuery(baseOptions?: Apollo.QueryHookOptions<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>(BillingPortalSessionDocument, options);
|
||||||
|
}
|
||||||
|
export function useBillingPortalSessionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>(BillingPortalSessionDocument, options);
|
||||||
|
}
|
||||||
|
export type BillingPortalSessionQueryHookResult = ReturnType<typeof useBillingPortalSessionQuery>;
|
||||||
|
export type BillingPortalSessionLazyQueryHookResult = ReturnType<typeof useBillingPortalSessionLazyQuery>;
|
||||||
|
export type BillingPortalSessionQueryResult = Apollo.QueryResult<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>;
|
||||||
export const CheckoutSessionDocument = gql`
|
export const CheckoutSessionDocument = gql`
|
||||||
mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) {
|
mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) {
|
||||||
checkoutSession(
|
checkoutSession(
|
||||||
|
|||||||
BIN
packages/twenty-front/src/modules/billing/assets/cover-dark.png
Normal file
BIN
packages/twenty-front/src/modules/billing/assets/cover-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 KiB |
BIN
packages/twenty-front/src/modules/billing/assets/cover-light.png
Normal file
BIN
packages/twenty-front/src/modules/billing/assets/cover-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
@ -0,0 +1,24 @@
|
|||||||
|
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,22 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import DarkCoverImage from '@/billing/assets/cover-dark.png';
|
||||||
|
import LightCoverImage from '@/billing/assets/cover-light.png';
|
||||||
|
|
||||||
|
const StyledCoverImageContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
background-image: ${({ theme }) =>
|
||||||
|
theme.name === 'light'
|
||||||
|
? `url('${LightCoverImage.toString()}')`
|
||||||
|
: `url('${DarkCoverImage.toString()}')`};
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: 162px;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
export const SettingsBillingCoverImage = () => {
|
||||||
|
return <StyledCoverImageContainer />;
|
||||||
|
};
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const BILLING_PORTAL_SESSION = gql`
|
||||||
|
query BillingPortalSession($returnUrlPath: String) {
|
||||||
|
billingPortalSession(returnUrlPath: $returnUrlPath) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
IconCalendarEvent,
|
IconCalendarEvent,
|
||||||
IconCode,
|
IconCode,
|
||||||
IconColorSwatch,
|
IconColorSwatch,
|
||||||
|
IconCurrencyDollar,
|
||||||
IconDoorEnter,
|
IconDoorEnter,
|
||||||
IconHierarchy2,
|
IconHierarchy2,
|
||||||
IconMail,
|
IconMail,
|
||||||
@ -34,6 +35,7 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
}, [signOut, navigate]);
|
}, [signOut, navigate]);
|
||||||
|
|
||||||
const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED');
|
const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED');
|
||||||
|
const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -86,6 +88,12 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
path={SettingsPath.WorkspaceMembersPage}
|
path={SettingsPath.WorkspaceMembersPage}
|
||||||
Icon={IconUsers}
|
Icon={IconUsers}
|
||||||
/>
|
/>
|
||||||
|
<SettingsNavigationDrawerItem
|
||||||
|
label="Billing"
|
||||||
|
path={SettingsPath.Billing}
|
||||||
|
Icon={IconCurrencyDollar}
|
||||||
|
soon={!isSelfBillingEnabled}
|
||||||
|
/>
|
||||||
<SettingsNavigationDrawerItem
|
<SettingsNavigationDrawerItem
|
||||||
label="Data model"
|
label="Data model"
|
||||||
path={SettingsPath.Objects}
|
path={SettingsPath.Objects}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export enum SettingsPath {
|
|||||||
AccountsCalendarsSettings = 'accounts/calendars/:accountUuid',
|
AccountsCalendarsSettings = 'accounts/calendars/:accountUuid',
|
||||||
AccountsEmails = 'accounts/emails',
|
AccountsEmails = 'accounts/emails',
|
||||||
AccountsEmailsInboxSettings = 'accounts/emails/:accountUuid',
|
AccountsEmailsInboxSettings = 'accounts/emails/:accountUuid',
|
||||||
|
Billing = 'billing',
|
||||||
Objects = 'objects',
|
Objects = 'objects',
|
||||||
ObjectDetail = 'objects/:objectSlug',
|
ObjectDetail = 'objects/:objectSlug',
|
||||||
ObjectEdit = 'objects/:objectSlug/edit',
|
ObjectEdit = 'objects/:objectSlug/edit',
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export {
|
|||||||
IconColorSwatch,
|
IconColorSwatch,
|
||||||
IconMessageCircle as IconComment,
|
IconMessageCircle as IconComment,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconCreditCard,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconCurrencyEuro,
|
IconCurrencyEuro,
|
||||||
IconCurrencyFrank,
|
IconCurrencyFrank,
|
||||||
|
|||||||
30
packages/twenty-front/src/pages/settings/SettingsBilling.tsx
Normal file
30
packages/twenty-front/src/pages/settings/SettingsBilling.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { ManageYourSubscription } from '@/billing/components/ManageYourSubscription.tsx';
|
||||||
|
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
|
||||||
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { IconCurrencyDollar } from '@/ui/display/icon';
|
||||||
|
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
|
||||||
|
import { H2Title } from '@/ui/display/typography/components/H2Title.tsx';
|
||||||
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||||
|
import { Section } from '@/ui/layout/section/components/Section.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>
|
||||||
|
);
|
||||||
@ -111,9 +111,14 @@ export class BillingService {
|
|||||||
where: { workspaceId },
|
where: { workspaceId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
|
||||||
|
const returnUrl = returnUrlPath
|
||||||
|
? frontBaseUrl + returnUrlPath
|
||||||
|
: frontBaseUrl;
|
||||||
|
|
||||||
const session = await this.stripeService.createBillingPortalSession(
|
const session = await this.stripeService.createBillingPortalSession(
|
||||||
billingSubscription.stripeCustomerId,
|
billingSubscription.stripeCustomerId,
|
||||||
returnUrlPath,
|
returnUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(session.url, 'Error: missing billingPortal.session.url');
|
assert(session.url, 'Error: missing billingPortal.session.url');
|
||||||
|
|||||||
@ -44,11 +44,11 @@ export class StripeService {
|
|||||||
|
|
||||||
async createBillingPortalSession(
|
async createBillingPortalSession(
|
||||||
stripeCustomerId: string,
|
stripeCustomerId: string,
|
||||||
returnUrlPath?: string,
|
returnUrl?: string,
|
||||||
): Promise<Stripe.BillingPortal.Session> {
|
): Promise<Stripe.BillingPortal.Session> {
|
||||||
return await this.stripe.billingPortal.sessions.create({
|
return await this.stripe.billingPortal.sessions.create({
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
return_url: returnUrlPath ?? this.environmentService.getFrontBaseUrl(),
|
return_url: returnUrl ?? this.environmentService.getFrontBaseUrl(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user