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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations';
import { SettingsAppearance } from '~/pages/settings/SettingsAppearance';
import { SettingsBilling } from '~/pages/settings/SettingsBilling.tsx';
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
@ -114,6 +115,10 @@ export const App = () => {
path={SettingsPath.AccountsEmailsInboxSettings}
element={<SettingsAccountsEmailsInboxSettings />}
/>
<Route
path={SettingsPath.Billing}
element={<SettingsBilling />}
/>
<Route
path={SettingsPath.WorkspaceMembersPage}
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 BillingPortalSessionQueryVariables = Exact<{
returnUrlPath?: InputMaybe<Scalars['String']>;
}>;
export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSession: { __typename?: 'SessionEntity', url: string } };
export type CheckoutSessionMutationVariables = Exact<{
recurringInterval: Scalars['String'];
successUrlPath?: InputMaybe<Scalars['String']>;
@ -1588,6 +1595,41 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
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`
mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) {
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,
IconCode,
IconColorSwatch,
IconCurrencyDollar,
IconDoorEnter,
IconHierarchy2,
IconMail,
@ -34,6 +35,7 @@ export const SettingsNavigationDrawerItems = () => {
}, [signOut, navigate]);
const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED');
const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
return (
<>
@ -86,6 +88,12 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.WorkspaceMembersPage}
Icon={IconUsers}
/>
<SettingsNavigationDrawerItem
label="Billing"
path={SettingsPath.Billing}
Icon={IconCurrencyDollar}
soon={!isSelfBillingEnabled}
/>
<SettingsNavigationDrawerItem
label="Data model"
path={SettingsPath.Objects}

View File

@ -7,6 +7,7 @@ export enum SettingsPath {
AccountsCalendarsSettings = 'accounts/calendars/:accountUuid',
AccountsEmails = 'accounts/emails',
AccountsEmailsInboxSettings = 'accounts/emails/:accountUuid',
Billing = 'billing',
Objects = 'objects',
ObjectDetail = 'objects/:objectSlug',
ObjectEdit = 'objects/:objectSlug/edit',

View File

@ -42,6 +42,7 @@ export {
IconColorSwatch,
IconMessageCircle as IconComment,
IconCopy,
IconCreditCard,
IconCurrencyDollar,
IconCurrencyEuro,
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 },
});
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
const returnUrl = returnUrlPath
? frontBaseUrl + returnUrlPath
: frontBaseUrl;
const session = await this.stripeService.createBillingPortalSession(
billingSubscription.stripeCustomerId,
returnUrlPath,
returnUrl,
);
assert(session.url, 'Error: missing billingPortal.session.url');

View File

@ -44,11 +44,11 @@ export class StripeService {
async createBillingPortalSession(
stripeCustomerId: string,
returnUrlPath?: string,
returnUrl?: string,
): Promise<Stripe.BillingPortal.Session> {
return await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrlPath ?? this.environmentService.getFrontBaseUrl(),
return_url: returnUrl ?? this.environmentService.getFrontBaseUrl(),
});
}