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:
martmull
2024-03-05 17:40:58 +01:00
committed by GitHub
parent 9fc421876f
commit 0b889ef089
14 changed files with 150 additions and 3 deletions

View File

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

View File

@ -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(

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const BILLING_PORTAL_SESSION = gql`
query BillingPortalSession($returnUrlPath: String) {
billingPortalSession(returnUrlPath: $returnUrlPath) {
url
}
}
`;

View File

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

View File

@ -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',

View File

@ -42,6 +42,7 @@ export {
IconColorSwatch, IconColorSwatch,
IconMessageCircle as IconComment, IconMessageCircle as IconComment,
IconCopy, IconCopy,
IconCreditCard,
IconCurrencyDollar, IconCurrencyDollar,
IconCurrencyEuro, IconCurrencyEuro,
IconCurrencyFrank, IconCurrencyFrank,

View 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>
);

View File

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

View File

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