mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-26 05:24:04 +03:00
42 add billing portal endpoint (#4315)
* Add create billing portal session endpoint * Rename checkout to checkoutSession * Code review returns
This commit is contained in:
parent
1f00af286b
commit
28a093d495
@ -70,11 +70,6 @@ export type BooleanFieldComparison = {
|
|||||||
isNot?: InputMaybe<Scalars['Boolean']>;
|
isNot?: InputMaybe<Scalars['Boolean']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CheckoutEntity = {
|
|
||||||
__typename?: 'CheckoutEntity';
|
|
||||||
url: Scalars['String'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
__typename?: 'ClientConfig';
|
__typename?: 'ClientConfig';
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
@ -226,7 +221,7 @@ export type Mutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
activateWorkspace: Workspace;
|
activateWorkspace: Workspace;
|
||||||
challenge: LoginToken;
|
challenge: LoginToken;
|
||||||
checkout: CheckoutEntity;
|
checkoutSession: SessionEntity;
|
||||||
createEvent: Analytics;
|
createEvent: Analytics;
|
||||||
createOneObject: Object;
|
createOneObject: Object;
|
||||||
createOneRefreshToken: RefreshToken;
|
createOneRefreshToken: RefreshToken;
|
||||||
@ -262,7 +257,7 @@ export type MutationChallengeArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationCheckoutArgs = {
|
export type MutationCheckoutSessionArgs = {
|
||||||
recurringInterval: Scalars['String'];
|
recurringInterval: Scalars['String'];
|
||||||
successUrlPath?: InputMaybe<Scalars['String']>;
|
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
@ -397,6 +392,7 @@ export type ProductPricesEntity = {
|
|||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
billingPortalSession: SessionEntity;
|
||||||
checkUserExists: UserExists;
|
checkUserExists: UserExists;
|
||||||
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
|
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
|
||||||
clientConfig: ClientConfig;
|
clientConfig: ClientConfig;
|
||||||
@ -412,6 +408,11 @@ export type Query = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryBillingPortalSessionArgs = {
|
||||||
|
returnUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryCheckUserExistsArgs = {
|
export type QueryCheckUserExistsArgs = {
|
||||||
email: Scalars['String'];
|
email: Scalars['String'];
|
||||||
};
|
};
|
||||||
@ -500,6 +501,11 @@ export type Sentry = {
|
|||||||
dsn?: Maybe<Scalars['String']>;
|
dsn?: Maybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionEntity = {
|
||||||
|
__typename?: 'SessionEntity';
|
||||||
|
url: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
/** Sort Directions */
|
/** Sort Directions */
|
||||||
export enum SortDirection {
|
export enum SortDirection {
|
||||||
Asc = 'ASC',
|
Asc = 'ASC',
|
||||||
@ -883,13 +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 CheckoutMutationVariables = Exact<{
|
export type CheckoutSessionMutationVariables = Exact<{
|
||||||
recurringInterval: Scalars['String'];
|
recurringInterval: Scalars['String'];
|
||||||
successUrlPath?: InputMaybe<Scalars['String']>;
|
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type CheckoutMutation = { __typename?: 'Mutation', checkout: { __typename?: 'CheckoutEntity', url: string } };
|
export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'SessionEntity', url: string } };
|
||||||
|
|
||||||
export type GetProductPricesQueryVariables = Exact<{
|
export type GetProductPricesQueryVariables = Exact<{
|
||||||
product: Scalars['String'];
|
product: Scalars['String'];
|
||||||
@ -1582,40 +1588,43 @@ 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 CheckoutDocument = gql`
|
export const CheckoutSessionDocument = gql`
|
||||||
mutation Checkout($recurringInterval: String!, $successUrlPath: String) {
|
mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) {
|
||||||
checkout(recurringInterval: $recurringInterval, successUrlPath: $successUrlPath) {
|
checkoutSession(
|
||||||
|
recurringInterval: $recurringInterval
|
||||||
|
successUrlPath: $successUrlPath
|
||||||
|
) {
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
export type CheckoutMutationFn = Apollo.MutationFunction<CheckoutMutation, CheckoutMutationVariables>;
|
export type CheckoutSessionMutationFn = Apollo.MutationFunction<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* __useCheckoutMutation__
|
* __useCheckoutSessionMutation__
|
||||||
*
|
*
|
||||||
* To run a mutation, you first call `useCheckoutMutation` within a React component and pass it any options that fit your needs.
|
* To run a mutation, you first call `useCheckoutSessionMutation` within a React component and pass it any options that fit your needs.
|
||||||
* When your component renders, `useCheckoutMutation` returns a tuple that includes:
|
* When your component renders, `useCheckoutSessionMutation` returns a tuple that includes:
|
||||||
* - A mutate function that you can call at any time to execute the mutation
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
* - An object with fields that represent the current status of the mutation's execution
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
*
|
*
|
||||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const [checkoutMutation, { data, loading, error }] = useCheckoutMutation({
|
* const [checkoutSessionMutation, { data, loading, error }] = useCheckoutSessionMutation({
|
||||||
* variables: {
|
* variables: {
|
||||||
* recurringInterval: // value for 'recurringInterval'
|
* recurringInterval: // value for 'recurringInterval'
|
||||||
* successUrlPath: // value for 'successUrlPath'
|
* successUrlPath: // value for 'successUrlPath'
|
||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
export function useCheckoutMutation(baseOptions?: Apollo.MutationHookOptions<CheckoutMutation, CheckoutMutationVariables>) {
|
export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>) {
|
||||||
const options = {...defaultOptions, ...baseOptions}
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
return Apollo.useMutation<CheckoutMutation, CheckoutMutationVariables>(CheckoutDocument, options);
|
return Apollo.useMutation<CheckoutSessionMutation, CheckoutSessionMutationVariables>(CheckoutSessionDocument, options);
|
||||||
}
|
}
|
||||||
export type CheckoutMutationHookResult = ReturnType<typeof useCheckoutMutation>;
|
export type CheckoutSessionMutationHookResult = ReturnType<typeof useCheckoutSessionMutation>;
|
||||||
export type CheckoutMutationResult = Apollo.MutationResult<CheckoutMutation>;
|
export type CheckoutSessionMutationResult = Apollo.MutationResult<CheckoutSessionMutation>;
|
||||||
export type CheckoutMutationOptions = Apollo.BaseMutationOptions<CheckoutMutation, CheckoutMutationVariables>;
|
export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
|
||||||
export const GetProductPricesDocument = gql`
|
export const GetProductPricesDocument = gql`
|
||||||
query GetProductPrices($product: String!) {
|
query GetProductPrices($product: String!) {
|
||||||
getProductPrices(product: $product) {
|
getProductPrices(product: $product) {
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
export const CHECKOUT = gql`
|
export const CHECKOUT_SESSION = gql`
|
||||||
mutation Checkout($recurringInterval: String!, $successUrlPath: String) {
|
mutation CheckoutSession(
|
||||||
checkout(
|
$recurringInterval: String!
|
||||||
|
$successUrlPath: String
|
||||||
|
) {
|
||||||
|
checkoutSession(
|
||||||
recurringInterval: $recurringInterval
|
recurringInterval: $recurringInterval
|
||||||
successUrlPath: $successUrlPath
|
successUrlPath: $successUrlPath
|
||||||
) {
|
) {
|
@ -14,7 +14,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
|
|||||||
import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
|
import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
|
||||||
import {
|
import {
|
||||||
ProductPriceEntity,
|
ProductPriceEntity,
|
||||||
useCheckoutMutation,
|
useCheckoutSessionMutation,
|
||||||
useGetProductPricesQuery,
|
useGetProductPricesQuery,
|
||||||
} from '~/generated/graphql.tsx';
|
} from '~/generated/graphql.tsx';
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ export const ChooseYourPlan = () => {
|
|||||||
variables: { product: 'base-plan' },
|
variables: { product: 'base-plan' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const [checkout] = useCheckoutMutation();
|
const [checkoutSession] = useCheckoutSessionMutation();
|
||||||
|
|
||||||
const handlePlanChange = (type?: string) => {
|
const handlePlanChange = (type?: string) => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -81,14 +81,14 @@ export const ChooseYourPlan = () => {
|
|||||||
|
|
||||||
const handleButtonClick = async () => {
|
const handleButtonClick = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const { data } = await checkout({
|
const { data } = await checkoutSession({
|
||||||
variables: {
|
variables: {
|
||||||
recurringInterval: planSelected,
|
recurringInterval: planSelected,
|
||||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
if (!data?.checkout.url) {
|
if (!data?.checkoutSession.url) {
|
||||||
enqueueSnackBar(
|
enqueueSnackBar(
|
||||||
'Checkout session error. Please retry or contact Twenty team',
|
'Checkout session error. Please retry or contact Twenty team',
|
||||||
{
|
{
|
||||||
@ -97,7 +97,7 @@ export const ChooseYourPlan = () => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.replace(data.checkout.url);
|
window.location.replace(data.checkoutSession.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -11,8 +11,9 @@ import { ProductPricesEntity } from 'src/core/billing/dto/product-prices.entity'
|
|||||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||||
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
|
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { CheckoutInput } from 'src/core/billing/dto/checkout.input';
|
import { CheckoutSessionInput } from 'src/core/billing/dto/checkout-session.input';
|
||||||
import { CheckoutEntity } from 'src/core/billing/dto/checkout.entity';
|
import { SessionEntity } from 'src/core/billing/dto/session.entity';
|
||||||
|
import { BillingSessionInput } from 'src/core/billing/dto/billing-session.input';
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class BillingResolver {
|
export class BillingResolver {
|
||||||
@ -38,11 +39,25 @@ export class BillingResolver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => CheckoutEntity)
|
@Query(() => SessionEntity)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async checkout(
|
async billingPortalSession(
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@Args() { recurringInterval, successUrlPath }: CheckoutInput,
|
@Args() { returnUrlPath }: BillingSessionInput,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: await this.billingService.computeBillingPortalSessionURL(
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
returnUrlPath,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => SessionEntity)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async checkoutSession(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput,
|
||||||
) {
|
) {
|
||||||
const stripeProductId = this.billingService.getProductStripeId(
|
const stripeProductId = this.billingService.getProductStripeId(
|
||||||
AvailableProduct.BasePlan,
|
AvailableProduct.BasePlan,
|
||||||
@ -66,7 +81,7 @@ export class BillingResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: await this.billingService.checkout(
|
url: await this.billingService.computeCheckoutSessionURL(
|
||||||
user,
|
user,
|
||||||
stripePriceId,
|
stripePriceId,
|
||||||
successUrlPath,
|
successUrlPath,
|
||||||
|
@ -11,6 +11,7 @@ import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subsc
|
|||||||
import { Workspace } from 'src/core/workspace/workspace.entity';
|
import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||||
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
|
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
|
import { assert } from 'src/utils/assert';
|
||||||
|
|
||||||
export enum AvailableProduct {
|
export enum AvailableProduct {
|
||||||
BasePlan = 'base-plan',
|
BasePlan = 'base-plan',
|
||||||
@ -101,18 +102,45 @@ export class BillingService {
|
|||||||
return billingSubscriptionItem;
|
return billingSubscriptionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkout(user: User, priceId: string, successUrlPath?: string) {
|
async computeBillingPortalSessionURL(
|
||||||
|
workspaceId: string,
|
||||||
|
returnUrlPath?: string,
|
||||||
|
) {
|
||||||
|
const billingSubscription =
|
||||||
|
await this.billingSubscriptionRepository.findOneOrFail({
|
||||||
|
where: { workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await this.stripeService.createBillingPortalSession(
|
||||||
|
billingSubscription.stripeCustomerId,
|
||||||
|
returnUrlPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(session.url, 'Error: missing billingPortal.session.url');
|
||||||
|
|
||||||
|
return session.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeCheckoutSessionURL(
|
||||||
|
user: User,
|
||||||
|
priceId: string,
|
||||||
|
successUrlPath?: string,
|
||||||
|
): Promise<string> {
|
||||||
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
|
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
|
||||||
const successUrl = successUrlPath
|
const successUrl = successUrlPath
|
||||||
? frontBaseUrl + successUrlPath
|
? frontBaseUrl + successUrlPath
|
||||||
: frontBaseUrl;
|
: frontBaseUrl;
|
||||||
|
|
||||||
return await this.stripeService.createCheckoutSession(
|
const session = await this.stripeService.createCheckoutSession(
|
||||||
user,
|
user,
|
||||||
priceId,
|
priceId,
|
||||||
successUrl,
|
successUrl,
|
||||||
frontBaseUrl,
|
frontBaseUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert(session.url, 'Error: missing checkout.session.url');
|
||||||
|
|
||||||
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSubscription(workspaceId: string) {
|
async deleteSubscription(workspaceId: string) {
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { ArgsType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class BillingSessionInput {
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
returnUrlPath?: string;
|
||||||
|
}
|
@ -4,7 +4,7 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@ArgsType()
|
@ArgsType()
|
||||||
export class CheckoutInput {
|
export class CheckoutSessionInput {
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
@ -1,7 +1,7 @@
|
|||||||
import { Field, ObjectType } from '@nestjs/graphql';
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class CheckoutEntity {
|
export class SessionEntity {
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
@ -4,7 +4,6 @@ import Stripe from 'stripe';
|
|||||||
|
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
import { User } from 'src/core/user/user.entity';
|
import { User } from 'src/core/user/user.entity';
|
||||||
import { assert } from 'src/utils/assert';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StripeService {
|
export class StripeService {
|
||||||
@ -43,13 +42,23 @@ export class StripeService {
|
|||||||
await this.stripe.subscriptions.cancel(stripeSubscriptionId);
|
await this.stripe.subscriptions.cancel(stripeSubscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createBillingPortalSession(
|
||||||
|
stripeCustomerId: string,
|
||||||
|
returnUrlPath?: string,
|
||||||
|
): Promise<Stripe.BillingPortal.Session> {
|
||||||
|
return await this.stripe.billingPortal.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
return_url: returnUrlPath ?? this.environmentService.getFrontBaseUrl(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createCheckoutSession(
|
async createCheckoutSession(
|
||||||
user: User,
|
user: User,
|
||||||
priceId: string,
|
priceId: string,
|
||||||
successUrl?: string,
|
successUrl?: string,
|
||||||
cancelUrl?: string,
|
cancelUrl?: string,
|
||||||
) {
|
): Promise<Stripe.Checkout.Session> {
|
||||||
const session = await this.stripe.checkout.sessions.create({
|
return await this.stripe.checkout.sessions.create({
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
@ -70,11 +79,5 @@ export class StripeService {
|
|||||||
success_url: successUrl,
|
success_url: successUrl,
|
||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(session.url, 'Error: missing checkout.session.url');
|
|
||||||
|
|
||||||
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
|
|
||||||
|
|
||||||
return session.url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user