deprocate getProductPrices query in front end (#10397)
**TLDR:** Deprecate getProductPrices in the frontEnd and replace it with BillingBaseProductPrices. **In order to test:** - Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work - Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) - Run the command: npx nx run twenty-server:command billing:sync-plans-data (if you don't do that the products and prices will not be present in the database) - Run the server , the frontend, the worker, and the stripe listen command (stripe listen --forward-to http://localhost:3000/billing/webhooks) - Buy a subscription for acme workspace the choose your plan should be using the new front end endpoint
This commit is contained in:
committed by
GitHub
parent
e838dfc68b
commit
4d7e52ef25
@ -1,5 +1,5 @@
|
|||||||
import { gql } from '@apollo/client';
|
|
||||||
import * as Apollo from '@apollo/client';
|
import * as Apollo from '@apollo/client';
|
||||||
|
import { gql } from '@apollo/client';
|
||||||
export type Maybe<T> = T | null;
|
export type Maybe<T> = T | null;
|
||||||
export type InputMaybe<T> = Maybe<T>;
|
export type InputMaybe<T> = Maybe<T>;
|
||||||
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||||
@ -161,6 +161,7 @@ export type BillingPlanOutput = {
|
|||||||
|
|
||||||
export type BillingPriceLicensedDto = {
|
export type BillingPriceLicensedDto = {
|
||||||
__typename?: 'BillingPriceLicensedDTO';
|
__typename?: 'BillingPriceLicensedDTO';
|
||||||
|
priceUsageType: BillingUsageType;
|
||||||
recurringInterval: SubscriptionInterval;
|
recurringInterval: SubscriptionInterval;
|
||||||
stripePriceId: Scalars['String'];
|
stripePriceId: Scalars['String'];
|
||||||
unitAmount: Scalars['Float'];
|
unitAmount: Scalars['Float'];
|
||||||
@ -168,6 +169,7 @@ export type BillingPriceLicensedDto = {
|
|||||||
|
|
||||||
export type BillingPriceMeteredDto = {
|
export type BillingPriceMeteredDto = {
|
||||||
__typename?: 'BillingPriceMeteredDTO';
|
__typename?: 'BillingPriceMeteredDTO';
|
||||||
|
priceUsageType: BillingUsageType;
|
||||||
recurringInterval: SubscriptionInterval;
|
recurringInterval: SubscriptionInterval;
|
||||||
stripePriceId: Scalars['String'];
|
stripePriceId: Scalars['String'];
|
||||||
tiers?: Maybe<Array<BillingPriceTierDto>>;
|
tiers?: Maybe<Array<BillingPriceTierDto>>;
|
||||||
@ -194,24 +196,10 @@ export type BillingProductDto = {
|
|||||||
description: Scalars['String'];
|
description: Scalars['String'];
|
||||||
images?: Maybe<Array<Scalars['String']>>;
|
images?: Maybe<Array<Scalars['String']>>;
|
||||||
name: Scalars['String'];
|
name: Scalars['String'];
|
||||||
prices: Array<Maybe<BillingPriceUnionDto>>;
|
prices: Array<BillingPriceUnionDto>;
|
||||||
type: BillingUsageType;
|
type: BillingUsageType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BillingProductPriceDto = {
|
|
||||||
__typename?: 'BillingProductPriceDTO';
|
|
||||||
created: Scalars['Float'];
|
|
||||||
recurringInterval: SubscriptionInterval;
|
|
||||||
stripePriceId: Scalars['String'];
|
|
||||||
unitAmount: Scalars['Float'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BillingProductPricesOutput = {
|
|
||||||
__typename?: 'BillingProductPricesOutput';
|
|
||||||
productPrices: Array<BillingProductPriceDto>;
|
|
||||||
totalNumberOfPrices: Scalars['Int'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BillingSessionOutput = {
|
export type BillingSessionOutput = {
|
||||||
__typename?: 'BillingSessionOutput';
|
__typename?: 'BillingSessionOutput';
|
||||||
url?: Maybe<Scalars['String']>;
|
url?: Maybe<Scalars['String']>;
|
||||||
@ -1306,7 +1294,6 @@ export type Query = {
|
|||||||
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
|
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
|
||||||
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
getIndicatorHealthStatus: AdminPanelHealthServiceData;
|
||||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||||
getProductPrices: BillingProductPricesOutput;
|
|
||||||
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
|
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
|
||||||
getQueueMetrics: QueueMetricsData;
|
getQueueMetrics: QueueMetricsData;
|
||||||
getRoles: Array<Role>;
|
getRoles: Array<Role>;
|
||||||
@ -2356,6 +2343,11 @@ 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 BillingBaseProductPricesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type BillingBaseProductPricesQuery = { __typename?: 'Query', plans: Array<{ __typename?: 'BillingPlanOutput', planKey: BillingPlanKey, baseProduct: { __typename?: 'BillingProductDTO', prices: Array<{ __typename?: 'BillingPriceLicensedDTO', unitAmount: number, stripePriceId: string, recurringInterval: SubscriptionInterval } | { __typename?: 'BillingPriceMeteredDTO' }> } }> };
|
||||||
|
|
||||||
export type BillingPortalSessionQueryVariables = Exact<{
|
export type BillingPortalSessionQueryVariables = Exact<{
|
||||||
returnUrlPath?: InputMaybe<Scalars['String']>;
|
returnUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
}>;
|
}>;
|
||||||
@ -2373,13 +2365,6 @@ export type CheckoutSessionMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSessionOutput', url?: string | null } };
|
export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'BillingSessionOutput', url?: string | null } };
|
||||||
|
|
||||||
export type GetProductPricesQueryVariables = Exact<{
|
|
||||||
product: Scalars['String'];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
|
|
||||||
export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'BillingProductPricesOutput', productPrices: Array<{ __typename?: 'BillingProductPriceDTO', created: number, recurringInterval: SubscriptionInterval, stripePriceId: string, unitAmount: number }> } };
|
|
||||||
|
|
||||||
export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>;
|
export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
@ -3817,6 +3802,49 @@ 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 BillingBaseProductPricesDocument = gql`
|
||||||
|
query billingBaseProductPrices {
|
||||||
|
plans {
|
||||||
|
planKey
|
||||||
|
baseProduct {
|
||||||
|
prices {
|
||||||
|
... on BillingPriceLicensedDTO {
|
||||||
|
unitAmount
|
||||||
|
stripePriceId
|
||||||
|
recurringInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useBillingBaseProductPricesQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useBillingBaseProductPricesQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useBillingBaseProductPricesQuery` 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 } = useBillingBaseProductPricesQuery({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useBillingBaseProductPricesQuery(baseOptions?: Apollo.QueryHookOptions<BillingBaseProductPricesQuery, BillingBaseProductPricesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<BillingBaseProductPricesQuery, BillingBaseProductPricesQueryVariables>(BillingBaseProductPricesDocument, options);
|
||||||
|
}
|
||||||
|
export function useBillingBaseProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<BillingBaseProductPricesQuery, BillingBaseProductPricesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<BillingBaseProductPricesQuery, BillingBaseProductPricesQueryVariables>(BillingBaseProductPricesDocument, options);
|
||||||
|
}
|
||||||
|
export type BillingBaseProductPricesQueryHookResult = ReturnType<typeof useBillingBaseProductPricesQuery>;
|
||||||
|
export type BillingBaseProductPricesLazyQueryHookResult = ReturnType<typeof useBillingBaseProductPricesLazyQuery>;
|
||||||
|
export type BillingBaseProductPricesQueryResult = Apollo.QueryResult<BillingBaseProductPricesQuery, BillingBaseProductPricesQueryVariables>;
|
||||||
export const BillingPortalSessionDocument = gql`
|
export const BillingPortalSessionDocument = gql`
|
||||||
query BillingPortalSession($returnUrlPath: String) {
|
query BillingPortalSession($returnUrlPath: String) {
|
||||||
billingPortalSession(returnUrlPath: $returnUrlPath) {
|
billingPortalSession(returnUrlPath: $returnUrlPath) {
|
||||||
@ -3893,46 +3921,6 @@ export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOpti
|
|||||||
export type CheckoutSessionMutationHookResult = ReturnType<typeof useCheckoutSessionMutation>;
|
export type CheckoutSessionMutationHookResult = ReturnType<typeof useCheckoutSessionMutation>;
|
||||||
export type CheckoutSessionMutationResult = Apollo.MutationResult<CheckoutSessionMutation>;
|
export type CheckoutSessionMutationResult = Apollo.MutationResult<CheckoutSessionMutation>;
|
||||||
export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
|
export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
|
||||||
export const GetProductPricesDocument = gql`
|
|
||||||
query GetProductPrices($product: String!) {
|
|
||||||
getProductPrices(product: $product) {
|
|
||||||
productPrices {
|
|
||||||
created
|
|
||||||
recurringInterval
|
|
||||||
stripePriceId
|
|
||||||
unitAmount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* __useGetProductPricesQuery__
|
|
||||||
*
|
|
||||||
* To run a query within a React component, call `useGetProductPricesQuery` and pass it any options that fit your needs.
|
|
||||||
* When your component renders, `useGetProductPricesQuery` 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 } = useGetProductPricesQuery({
|
|
||||||
* variables: {
|
|
||||||
* product: // value for 'product'
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
export function useGetProductPricesQuery(baseOptions: Apollo.QueryHookOptions<GetProductPricesQuery, GetProductPricesQueryVariables>) {
|
|
||||||
const options = {...defaultOptions, ...baseOptions}
|
|
||||||
return Apollo.useQuery<GetProductPricesQuery, GetProductPricesQueryVariables>(GetProductPricesDocument, options);
|
|
||||||
}
|
|
||||||
export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetProductPricesQuery, GetProductPricesQueryVariables>) {
|
|
||||||
const options = {...defaultOptions, ...baseOptions}
|
|
||||||
return Apollo.useLazyQuery<GetProductPricesQuery, GetProductPricesQueryVariables>(GetProductPricesDocument, options);
|
|
||||||
}
|
|
||||||
export type GetProductPricesQueryHookResult = ReturnType<typeof useGetProductPricesQuery>;
|
|
||||||
export type GetProductPricesLazyQueryHookResult = ReturnType<typeof useGetProductPricesLazyQuery>;
|
|
||||||
export type GetProductPricesQueryResult = Apollo.QueryResult<GetProductPricesQuery, GetProductPricesQueryVariables>;
|
|
||||||
export const UpdateBillingSubscriptionDocument = gql`
|
export const UpdateBillingSubscriptionDocument = gql`
|
||||||
mutation UpdateBillingSubscription {
|
mutation UpdateBillingSubscription {
|
||||||
updateBillingSubscription {
|
updateBillingSubscription {
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const BILLING_BASE_PRODUCT_PRICES = gql`
|
||||||
|
query billingBaseProductPrices {
|
||||||
|
plans {
|
||||||
|
planKey
|
||||||
|
baseProduct {
|
||||||
|
prices {
|
||||||
|
... on BillingPriceLicensedDTO {
|
||||||
|
unitAmount
|
||||||
|
stripePriceId
|
||||||
|
recurringInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { gql } from '@apollo/client';
|
|
||||||
|
|
||||||
export const GET_PRODUCT_PRICES = gql`
|
|
||||||
query GetProductPrices($product: String!) {
|
|
||||||
getProductPrices(product: $product) {
|
|
||||||
productPrices {
|
|
||||||
created
|
|
||||||
recurringInterval
|
|
||||||
stripePriceId
|
|
||||||
unitAmount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { BillingPriceLicensedDto } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export const isBillingPriceLicensed = <T extends { __typename?: string }>(
|
||||||
|
price: T,
|
||||||
|
): price is T & BillingPriceLicensedDto => {
|
||||||
|
return price?.__typename === 'BillingPriceLicensedDTO';
|
||||||
|
};
|
||||||
@ -6,6 +6,7 @@ import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
|
|||||||
import { SubscriptionPrice } from '@/billing/components/SubscriptionPrice';
|
import { SubscriptionPrice } from '@/billing/components/SubscriptionPrice';
|
||||||
import { TrialCard } from '@/billing/components/TrialCard';
|
import { TrialCard } from '@/billing/components/TrialCard';
|
||||||
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
|
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
|
||||||
|
import { isBillingPriceLicensed } from '@/billing/utils/isBillingPriceLicensed';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
@ -18,8 +19,12 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
MainButton,
|
MainButton,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import { SubscriptionInterval } from '~/generated-metadata/graphql';
|
import {
|
||||||
import { useGetProductPricesQuery } from '~/generated/graphql';
|
BillingPlanKey,
|
||||||
|
BillingPriceLicensedDto,
|
||||||
|
SubscriptionInterval,
|
||||||
|
useBillingBaseProductPricesQuery,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
const StyledSubscriptionContainer = styled.div<{
|
const StyledSubscriptionContainer = styled.div<{
|
||||||
withLongerMarginBottom: boolean;
|
withLongerMarginBottom: boolean;
|
||||||
@ -92,13 +97,15 @@ export const ChooseYourPlan = () => {
|
|||||||
t`1 000 workflow node executions`,
|
t`1 000 workflow node executions`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const { data: prices } = useGetProductPricesQuery({
|
const { data: plans } = useBillingBaseProductPricesQuery();
|
||||||
variables: { product: 'base-plan' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const price = prices?.getProductPrices?.productPrices.find(
|
const baseProduct = plans?.plans.find(
|
||||||
(productPrice) =>
|
(plan) => plan.planKey === BillingPlanKey.PRO,
|
||||||
productPrice.recurringInterval === SubscriptionInterval.Month,
|
)?.baseProduct;
|
||||||
|
const baseProductPrice = baseProduct?.prices.find(
|
||||||
|
(price): price is BillingPriceLicensedDto =>
|
||||||
|
isBillingPriceLicensed(price) &&
|
||||||
|
price.recurringInterval === SubscriptionInterval.Month,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasWithoutCreditCardTrialPeriod = billing?.trialPeriods.some(
|
const hasWithoutCreditCardTrialPeriod = billing?.trialPeriods.some(
|
||||||
@ -122,12 +129,12 @@ export const ChooseYourPlan = () => {
|
|||||||
const handleTrialPeriodChange = (withCreditCard: boolean) => {
|
const handleTrialPeriodChange = (withCreditCard: boolean) => {
|
||||||
return () => {
|
return () => {
|
||||||
if (
|
if (
|
||||||
isDefined(price) &&
|
isDefined(baseProductPrice) &&
|
||||||
billingCheckoutSession.requirePaymentMethod !== withCreditCard
|
billingCheckoutSession.requirePaymentMethod !== withCreditCard
|
||||||
) {
|
) {
|
||||||
setBillingCheckoutSession({
|
setBillingCheckoutSession({
|
||||||
plan: billingCheckoutSession.plan,
|
plan: billingCheckoutSession.plan,
|
||||||
interval: price.recurringInterval,
|
interval: baseProductPrice.recurringInterval,
|
||||||
requirePaymentMethod: withCreditCard,
|
requirePaymentMethod: withCreditCard,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -139,7 +146,7 @@ export const ChooseYourPlan = () => {
|
|||||||
const withCreditCardTrialPeriodDuration = withCreditCardTrialPeriod?.duration;
|
const withCreditCardTrialPeriodDuration = withCreditCardTrialPeriod?.duration;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isDefined(price) &&
|
isDefined(baseProductPrice) &&
|
||||||
isDefined(billing) && (
|
isDefined(billing) && (
|
||||||
<>
|
<>
|
||||||
<Title noMarginTop>
|
<Title noMarginTop>
|
||||||
@ -163,8 +170,8 @@ export const ChooseYourPlan = () => {
|
|||||||
>
|
>
|
||||||
<StyledSubscriptionPriceContainer>
|
<StyledSubscriptionPriceContainer>
|
||||||
<SubscriptionPrice
|
<SubscriptionPrice
|
||||||
type={price.recurringInterval}
|
type={baseProductPrice.recurringInterval}
|
||||||
price={price.unitAmount / 100}
|
price={baseProductPrice.unitAmount / 100}
|
||||||
/>
|
/>
|
||||||
</StyledSubscriptionPriceContainer>
|
</StyledSubscriptionPriceContainer>
|
||||||
<StyledBenefitsContainer>
|
<StyledBenefitsContainer>
|
||||||
|
|||||||
@ -3,9 +3,14 @@ import { Meta, StoryObj } from '@storybook/react';
|
|||||||
import { within } from '@storybook/testing-library';
|
import { within } from '@storybook/testing-library';
|
||||||
import { HttpResponse, graphql } from 'msw';
|
import { HttpResponse, graphql } from 'msw';
|
||||||
|
|
||||||
|
import { BILLING_BASE_PRODUCT_PRICES } from '@/billing/graphql/billingBaseProductPrices';
|
||||||
import { AppPath } from '@/types/AppPath';
|
import { AppPath } from '@/types/AppPath';
|
||||||
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
|
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
|
||||||
import { OnboardingStatus } from '~/generated/graphql';
|
import {
|
||||||
|
BillingPlanKey,
|
||||||
|
OnboardingStatus,
|
||||||
|
SubscriptionInterval,
|
||||||
|
} from '~/generated/graphql';
|
||||||
import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan';
|
import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan';
|
||||||
import {
|
import {
|
||||||
PageDecorator,
|
PageDecorator,
|
||||||
@ -31,31 +36,30 @@ const meta: Meta<PageDecoratorArgs> = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
graphql.query('GetProductPrices', () => {
|
graphql.query(
|
||||||
return HttpResponse.json({
|
getOperationName(BILLING_BASE_PRODUCT_PRICES) ?? '',
|
||||||
data: {
|
() => {
|
||||||
getProductPrices: {
|
return HttpResponse.json({
|
||||||
__typename: 'ProductPricesEntity',
|
data: {
|
||||||
productPrices: [
|
plans: [
|
||||||
{
|
{
|
||||||
__typename: 'ProductPriceEntity',
|
planKey: BillingPlanKey.PRO,
|
||||||
created: 1699860608,
|
baseProduct: {
|
||||||
recurringInterval: 'Month',
|
prices: [
|
||||||
stripePriceId: 'monthly8usd',
|
{
|
||||||
unitAmount: 900,
|
__typename: 'BillingPriceLicensedDTO',
|
||||||
},
|
unitAmount: 900,
|
||||||
{
|
stripePriceId: 'monthly8usd',
|
||||||
__typename: 'ProductPriceEntity',
|
recurringInterval: SubscriptionInterval.Month,
|
||||||
created: 1701874964,
|
},
|
||||||
recurringInterval: 'Year',
|
],
|
||||||
stripePriceId: 'priceId',
|
},
|
||||||
unitAmount: 9000,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
}),
|
),
|
||||||
...graphqlMocks.handlers,
|
...graphqlMocks.handlers,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,10 +7,8 @@ import { GraphQLError } from 'graphql';
|
|||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
|
|
||||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||||
import { BillingProductInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-product.input';
|
|
||||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||||
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||||
import { BillingProductPricesOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-product-prices.output';
|
|
||||||
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
|
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
|
||||||
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
|
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
|
||||||
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
|
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
|
||||||
@ -48,28 +46,13 @@ export class BillingResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
|
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
|
||||||
private readonly stripePriceService: StripePriceService,
|
|
||||||
private readonly billingPlanService: BillingPlanService,
|
private readonly billingPlanService: BillingPlanService,
|
||||||
|
private readonly stripePriceService: StripePriceService,
|
||||||
private readonly featureFlagService: FeatureFlagService,
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly permissionsService: PermissionsService,
|
private readonly permissionsService: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Query(() => BillingProductPricesOutput)
|
|
||||||
@UseGuards(WorkspaceAuthGuard)
|
|
||||||
async getProductPrices(
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
@Args() { product }: BillingProductInput,
|
|
||||||
) {
|
|
||||||
const productPrices =
|
|
||||||
await this.stripePriceService.getStripePrices(product);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalNumberOfPrices: productPrices.length,
|
|
||||||
productPrices,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => BillingSessionOutput)
|
@Query(() => BillingSessionOutput)
|
||||||
@UseGuards(
|
@UseGuards(
|
||||||
WorkspaceAuthGuard,
|
WorkspaceAuthGuard,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { Field, ObjectType } from '@nestjs/graphql';
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||||
|
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class BillingPriceLicensedDTO {
|
export class BillingPriceLicensedDTO {
|
||||||
@ -14,4 +15,7 @@ export class BillingPriceLicensedDTO {
|
|||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
stripePriceId: string;
|
stripePriceId: string;
|
||||||
|
|
||||||
|
@Field(() => BillingUsageType)
|
||||||
|
priceUsageType: BillingUsageType.LICENSED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
|||||||
import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billing-price-tier.dto';
|
import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billing-price-tier.dto';
|
||||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||||
|
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class BillingPriceMeteredDTO {
|
export class BillingPriceMeteredDTO {
|
||||||
@ -19,4 +20,7 @@ export class BillingPriceMeteredDTO {
|
|||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
stripePriceId: string;
|
stripePriceId: string;
|
||||||
|
|
||||||
|
@Field(() => BillingUsageType)
|
||||||
|
priceUsageType: BillingUsageType.METERED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import { createUnionType } from '@nestjs/graphql';
|
|||||||
|
|
||||||
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||||
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||||
|
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
export const BillingPriceUnionDTO = createUnionType({
|
export const BillingPriceUnionDTO = createUnionType({
|
||||||
name: 'BillingPriceUnionDTO',
|
name: 'BillingPriceUnionDTO',
|
||||||
types: () => [BillingPriceLicensedDTO, BillingPriceMeteredDTO],
|
types: () => [BillingPriceLicensedDTO, BillingPriceMeteredDTO],
|
||||||
resolveType(value) {
|
resolveType(value) {
|
||||||
if ('unitAmount' in value) {
|
if (value.priceUsageType === BillingUsageType.LICENSED) {
|
||||||
return BillingPriceLicensedDTO;
|
return BillingPriceLicensedDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,6 @@ export class BillingProductDTO {
|
|||||||
@Field(() => BillingUsageType)
|
@Field(() => BillingUsageType)
|
||||||
type: BillingUsageType;
|
type: BillingUsageType;
|
||||||
|
|
||||||
@Field(() => [BillingPriceUnionDTO], { nullable: 'items' })
|
@Field(() => [BillingPriceUnionDTO])
|
||||||
prices: Array<BillingPriceLicensedDTO | BillingPriceMeteredDTO>;
|
prices: Array<BillingPriceLicensedDTO> | Array<BillingPriceMeteredDTO>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
/* @license Enterprise */
|
|
||||||
|
|
||||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
import { BillingProductPriceDTO } from 'src/engine/core-modules/billing/dtos/billing-product-price.dto';
|
|
||||||
|
|
||||||
@ObjectType()
|
|
||||||
export class BillingProductPricesOutput {
|
|
||||||
@Field(() => Int)
|
|
||||||
totalNumberOfPrices: number;
|
|
||||||
|
|
||||||
@Field(() => [BillingProductPriceDTO])
|
|
||||||
productPrices: BillingProductPriceDTO[];
|
|
||||||
}
|
|
||||||
@ -85,7 +85,7 @@ export class BillingPlanService {
|
|||||||
|
|
||||||
if (!baseProduct) {
|
if (!baseProduct) {
|
||||||
throw new BillingException(
|
throw new BillingException(
|
||||||
'Base product not found',
|
'Base product not found, did you run the billing:sync-products command?',
|
||||||
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
|
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { BillingProductService } from 'src/engine/core-modules/billing/services/
|
|||||||
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
|
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
|
||||||
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
|
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
|
||||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||||
|
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
@ -176,8 +177,9 @@ export class BillingSubscriptionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isBillingPlansEnabled) {
|
if (isBillingPlansEnabled) {
|
||||||
|
const planKey = getPlanKeyFromSubscription(billingSubscription);
|
||||||
const billingProductsByPlan =
|
const billingProductsByPlan =
|
||||||
await this.billingProductService.getProductsByPlan(BillingPlanKey.PRO);
|
await this.billingProductService.getProductsByPlan(planKey);
|
||||||
const pricesPerPlanArray =
|
const pricesPerPlanArray =
|
||||||
this.billingProductService.getProductPricesByInterval({
|
this.billingProductService.getProductPricesByInterval({
|
||||||
interval: newInterval,
|
interval: newInterval,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: SubscriptionInterval.Month,
|
interval: SubscriptionInterval.Month,
|
||||||
unitAmount: 1000,
|
unitAmount: 1000,
|
||||||
stripePriceId: 'price_base1',
|
stripePriceId: 'price_base1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -31,6 +32,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: SubscriptionInterval.Year,
|
interval: SubscriptionInterval.Year,
|
||||||
unitAmount: 2000,
|
unitAmount: 2000,
|
||||||
stripePriceId: 'price_licensed1',
|
stripePriceId: 'price_licensed1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -51,6 +53,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stripePriceId: 'price_metered1',
|
stripePriceId: 'price_metered1',
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -71,6 +74,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: SubscriptionInterval.Month,
|
interval: SubscriptionInterval.Month,
|
||||||
unitAmount: 1000,
|
unitAmount: 1000,
|
||||||
stripePriceId: 'price_base1',
|
stripePriceId: 'price_base1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: BillingUsageType.LICENSED,
|
type: BillingUsageType.LICENSED,
|
||||||
@ -79,6 +83,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
recurringInterval: SubscriptionInterval.Month,
|
recurringInterval: SubscriptionInterval.Month,
|
||||||
unitAmount: 1000,
|
unitAmount: 1000,
|
||||||
stripePriceId: 'price_base1',
|
stripePriceId: 'price_base1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -91,6 +96,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: SubscriptionInterval.Year,
|
interval: SubscriptionInterval.Year,
|
||||||
unitAmount: 2000,
|
unitAmount: 2000,
|
||||||
stripePriceId: 'price_licensed1',
|
stripePriceId: 'price_licensed1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: BillingUsageType.LICENSED,
|
type: BillingUsageType.LICENSED,
|
||||||
@ -99,6 +105,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
recurringInterval: SubscriptionInterval.Year,
|
recurringInterval: SubscriptionInterval.Year,
|
||||||
unitAmount: 2000,
|
unitAmount: 2000,
|
||||||
stripePriceId: 'price_licensed1',
|
stripePriceId: 'price_licensed1',
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -119,6 +126,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stripePriceId: 'price_metered1',
|
stripePriceId: 'price_metered1',
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: BillingUsageType.METERED,
|
type: BillingUsageType.METERED,
|
||||||
@ -134,6 +142,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
],
|
],
|
||||||
recurringInterval: SubscriptionInterval.Month,
|
recurringInterval: SubscriptionInterval.Month,
|
||||||
stripePriceId: 'price_metered1',
|
stripePriceId: 'price_metered1',
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -152,6 +161,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: null,
|
interval: null,
|
||||||
unitAmount: null,
|
unitAmount: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -166,6 +176,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
tiersMode: null,
|
tiersMode: null,
|
||||||
tiers: null,
|
tiers: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -186,6 +197,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
interval: null,
|
interval: null,
|
||||||
unitAmount: null,
|
unitAmount: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: BillingUsageType.LICENSED,
|
type: BillingUsageType.LICENSED,
|
||||||
@ -194,6 +206,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
recurringInterval: SubscriptionInterval.Month,
|
recurringInterval: SubscriptionInterval.Month,
|
||||||
unitAmount: 0,
|
unitAmount: 0,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -208,6 +221,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
tiersMode: null,
|
tiersMode: null,
|
||||||
tiers: null,
|
tiers: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
type: BillingUsageType.METERED,
|
type: BillingUsageType.METERED,
|
||||||
@ -217,6 +231,7 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
|||||||
tiers: [],
|
tiers: [],
|
||||||
recurringInterval: SubscriptionInterval.Month,
|
recurringInterval: SubscriptionInterval.Month,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -58,6 +58,7 @@ const formatBillingDatabasePriceToMeteredPriceDTO = (
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month,
|
recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month,
|
||||||
stripePriceId: billingPrice?.stripePriceId,
|
stripePriceId: billingPrice?.stripePriceId,
|
||||||
|
priceUsageType: BillingUsageType.METERED,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,5 +69,6 @@ const formatBillingDatabasePriceToLicensedPriceDTO = (
|
|||||||
recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month,
|
recurringInterval: billingPrice?.interval ?? SubscriptionInterval.Month,
|
||||||
unitAmount: billingPrice?.unitAmount ?? 0,
|
unitAmount: billingPrice?.unitAmount ?? 0,
|
||||||
stripePriceId: billingPrice?.stripePriceId,
|
stripePriceId: billingPrice?.stripePriceId,
|
||||||
|
priceUsageType: BillingUsageType.LICENSED,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||||
|
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||||
|
|
||||||
|
export const getPlanKeyFromSubscription = (
|
||||||
|
subscription: BillingSubscription,
|
||||||
|
): BillingPlanKey => {
|
||||||
|
const planKey = subscription.metadata?.planKey;
|
||||||
|
|
||||||
|
switch (planKey) {
|
||||||
|
case 'PRO':
|
||||||
|
return BillingPlanKey.PRO;
|
||||||
|
case 'ENTERPRISE':
|
||||||
|
return BillingPlanKey.ENTERPRISE;
|
||||||
|
default:
|
||||||
|
return BillingPlanKey.PRO;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user