Add metered product to checkout session (#9788)
Solves https://github.com/twentyhq/private-issues/issues/238 **TLDR:** Add metered product in the checkout session when purchasing a subscription **In order to test:** 1. Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work 2. Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) 3. 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) 4. Run the server , the frontend, the worker, and the stripe listen command (stripe listen --forward-to http://localhost:3000/billing/webhooks) 5. Buy a subscription for the Acme workspace , in the checkout session you should see that there is two products
This commit is contained in:
committed by
GitHub
parent
e7ba1c82b4
commit
cc53cb3b7b
@ -11,6 +11,7 @@ export class BillingException extends CustomException {
|
|||||||
|
|
||||||
export enum BillingExceptionCode {
|
export enum BillingExceptionCode {
|
||||||
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
||||||
|
BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND',
|
||||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||||
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
||||||
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil
|
|||||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||||
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 { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||||
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';
|
||||||
@ -84,42 +85,49 @@ export class BillingResolver {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
let productPrice;
|
const checkoutSessionParams: BillingPortalCheckoutSessionParameters = {
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
successUrlPath,
|
||||||
|
plan: plan ?? BillingPlanKey.PRO,
|
||||||
|
requirePaymentMethod,
|
||||||
|
};
|
||||||
|
|
||||||
if (isBillingPlansEnabled) {
|
if (isBillingPlansEnabled) {
|
||||||
const baseProduct = await this.billingPlanService.getPlanBaseProduct(
|
const billingPricesPerPlan =
|
||||||
plan ?? BillingPlanKey.PRO,
|
await this.billingPlanService.getPricesPerPlan({
|
||||||
);
|
planKey: checkoutSessionParams.plan,
|
||||||
|
interval: recurringInterval,
|
||||||
|
});
|
||||||
|
const checkoutSessionURL =
|
||||||
|
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
|
||||||
|
...checkoutSessionParams,
|
||||||
|
billingPricesPerPlan,
|
||||||
|
});
|
||||||
|
|
||||||
if (!baseProduct) {
|
return {
|
||||||
throw new GraphQLError('Base product not found');
|
url: checkoutSessionURL,
|
||||||
}
|
};
|
||||||
|
|
||||||
productPrice = baseProduct.billingPrices.find(
|
|
||||||
(price) => price.interval === recurringInterval,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
productPrice = await this.stripePriceService.getStripePrice(
|
|
||||||
AvailableProduct.BasePlan,
|
|
||||||
recurringInterval,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const productPrice = await this.stripePriceService.getStripePrice(
|
||||||
|
AvailableProduct.BasePlan,
|
||||||
|
recurringInterval,
|
||||||
|
);
|
||||||
|
|
||||||
if (!productPrice) {
|
if (!productPrice) {
|
||||||
throw new GraphQLError(
|
throw new GraphQLError(
|
||||||
'Product price not found for the given recurring interval',
|
'Product price not found for the given recurring interval',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const checkoutSessionURL =
|
||||||
|
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
|
||||||
|
...checkoutSessionParams,
|
||||||
|
priceId: productPrice.stripePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL(
|
url: checkoutSessionURL,
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
productPrice.stripePriceId,
|
|
||||||
successUrlPath,
|
|
||||||
plan,
|
|
||||||
requirePaymentMethod,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import {
|
|||||||
} from 'src/engine/core-modules/billing/billing.exception';
|
} from 'src/engine/core-modules/billing/billing.exception';
|
||||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
||||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.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';
|
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||||
|
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingPlanService {
|
export class BillingPlanService {
|
||||||
@ -104,4 +106,48 @@ export class BillingPlanService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPricesPerPlan({
|
||||||
|
planKey,
|
||||||
|
interval,
|
||||||
|
}: {
|
||||||
|
planKey: BillingPlanKey;
|
||||||
|
interval: SubscriptionInterval;
|
||||||
|
}): Promise<BillingGetPricesPerPlanResult> {
|
||||||
|
const plans = await this.getPlans();
|
||||||
|
const plan = plans.find((plan) => plan.planKey === planKey);
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw new BillingException(
|
||||||
|
'Billing plan not found',
|
||||||
|
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { baseProduct, meteredProducts, otherLicensedProducts } = plan;
|
||||||
|
const baseProductPrice = baseProduct.billingPrices.find(
|
||||||
|
(price) => price.interval === interval,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!baseProductPrice) {
|
||||||
|
throw new BillingException(
|
||||||
|
'Base product price not found for given interval',
|
||||||
|
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const filterPricesByInterval = (product: BillingProduct) =>
|
||||||
|
product.billingPrices.filter((price) => price.interval === interval);
|
||||||
|
|
||||||
|
const meteredProductsPrices = meteredProducts.flatMap(
|
||||||
|
filterPricesByInterval,
|
||||||
|
);
|
||||||
|
const otherLicensedProductsPrices = otherLicensedProducts.flatMap(
|
||||||
|
filterPricesByInterval,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseProductPrice,
|
||||||
|
meteredProductsPrices,
|
||||||
|
otherLicensedProductsPrices,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { isDefined } from 'class-validator';
|
import { isDefined } from 'class-validator';
|
||||||
|
import Stripe from 'stripe';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BillingException,
|
||||||
|
BillingExceptionCode,
|
||||||
|
} from 'src/engine/core-modules/billing/billing.exception';
|
||||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
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';
|
|
||||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
|
||||||
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
|
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
|
||||||
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
|
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
|
||||||
|
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||||
|
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||||
|
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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { assert } from 'src/utils/assert';
|
import { assert } from 'src/utils/assert';
|
||||||
|
|
||||||
@ -22,21 +28,22 @@ export class BillingPortalWorkspaceService {
|
|||||||
private readonly stripeCheckoutService: StripeCheckoutService,
|
private readonly stripeCheckoutService: StripeCheckoutService,
|
||||||
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
||||||
private readonly domainManagerService: DomainManagerService,
|
private readonly domainManagerService: DomainManagerService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
@InjectRepository(UserWorkspace, 'core')
|
@InjectRepository(UserWorkspace, 'core')
|
||||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async computeCheckoutSessionURL(
|
async computeCheckoutSessionURL({
|
||||||
user: User,
|
user,
|
||||||
workspace: Workspace,
|
workspace,
|
||||||
priceId: string,
|
billingPricesPerPlan,
|
||||||
successUrlPath?: string,
|
successUrlPath,
|
||||||
plan?: BillingPlanKey,
|
plan,
|
||||||
requirePaymentMethod?: boolean,
|
priceId,
|
||||||
): Promise<string> {
|
requirePaymentMethod,
|
||||||
|
}: BillingPortalCheckoutSessionParameters): Promise<string> {
|
||||||
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
||||||
subdomain: workspace.subdomain,
|
subdomain: workspace.subdomain,
|
||||||
});
|
});
|
||||||
@ -56,23 +63,37 @@ export class BillingPortalWorkspaceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stripeCustomerId = subscription?.stripeCustomerId;
|
const stripeCustomerId = subscription?.stripeCustomerId;
|
||||||
|
const isBillingPlansEnabled =
|
||||||
|
await this.featureFlagService.isFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsBillingPlansEnabled,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
const session = await this.stripeCheckoutService.createCheckoutSession({
|
const stripeSubscriptionLineItems =
|
||||||
user,
|
await this.getStripeSubscriptionLineItems({
|
||||||
workspaceId: workspace.id,
|
quantity,
|
||||||
priceId,
|
isBillingPlansEnabled,
|
||||||
quantity,
|
billingPricesPerPlan,
|
||||||
successUrl,
|
priceId,
|
||||||
cancelUrl,
|
});
|
||||||
stripeCustomerId,
|
|
||||||
plan,
|
|
||||||
requirePaymentMethod,
|
|
||||||
withTrialPeriod: !isDefined(subscription),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert(session.url, 'Error: missing checkout.session.url');
|
const checkoutSession =
|
||||||
|
await this.stripeCheckoutService.createCheckoutSession({
|
||||||
|
user,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
stripeSubscriptionLineItems,
|
||||||
|
successUrl,
|
||||||
|
cancelUrl,
|
||||||
|
stripeCustomerId,
|
||||||
|
plan,
|
||||||
|
requirePaymentMethod,
|
||||||
|
withTrialPeriod: !isDefined(subscription),
|
||||||
|
isBillingPlansEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
return session.url;
|
assert(checkoutSession.url, 'Error: missing checkout.session.url');
|
||||||
|
|
||||||
|
return checkoutSession.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async computeBillingPortalSessionURLOrThrow(
|
async computeBillingPortalSessionURLOrThrow(
|
||||||
@ -113,4 +134,39 @@ export class BillingPortalWorkspaceService {
|
|||||||
|
|
||||||
return session.url;
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getStripeSubscriptionLineItems({
|
||||||
|
quantity,
|
||||||
|
isBillingPlansEnabled,
|
||||||
|
billingPricesPerPlan,
|
||||||
|
priceId,
|
||||||
|
}: {
|
||||||
|
quantity: number;
|
||||||
|
isBillingPlansEnabled: boolean;
|
||||||
|
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||||
|
priceId?: string;
|
||||||
|
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||||
|
if (isBillingPlansEnabled && billingPricesPerPlan) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
||||||
|
price: price.stripePriceId,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceId && !isBillingPlansEnabled) {
|
||||||
|
return [{ price: priceId, quantity }];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BillingException(
|
||||||
|
isBillingPlansEnabled
|
||||||
|
? 'Missing Billing prices per plan'
|
||||||
|
: 'Missing price id',
|
||||||
|
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,33 +27,28 @@ export class StripeCheckoutService {
|
|||||||
async createCheckoutSession({
|
async createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
priceId,
|
stripeSubscriptionLineItems,
|
||||||
quantity,
|
|
||||||
successUrl,
|
successUrl,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
stripeCustomerId,
|
stripeCustomerId,
|
||||||
plan = BillingPlanKey.PRO,
|
plan = BillingPlanKey.PRO,
|
||||||
requirePaymentMethod = true,
|
requirePaymentMethod = true,
|
||||||
withTrialPeriod,
|
withTrialPeriod,
|
||||||
|
isBillingPlansEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
priceId: string;
|
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||||
quantity: number;
|
|
||||||
successUrl?: string;
|
successUrl?: string;
|
||||||
cancelUrl?: string;
|
cancelUrl?: string;
|
||||||
stripeCustomerId?: string;
|
stripeCustomerId?: string;
|
||||||
plan?: BillingPlanKey;
|
plan?: BillingPlanKey;
|
||||||
requirePaymentMethod?: boolean;
|
requirePaymentMethod?: boolean;
|
||||||
withTrialPeriod: boolean;
|
withTrialPeriod: boolean;
|
||||||
|
isBillingPlansEnabled: boolean;
|
||||||
}): Promise<Stripe.Checkout.Session> {
|
}): Promise<Stripe.Checkout.Session> {
|
||||||
return await this.stripe.checkout.sessions.create({
|
return await this.stripe.checkout.sessions.create({
|
||||||
line_items: [
|
line_items: stripeSubscriptionLineItems,
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
@ -68,7 +63,11 @@ export class StripeCheckoutService {
|
|||||||
: 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
|
: 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
|
||||||
),
|
),
|
||||||
trial_settings: {
|
trial_settings: {
|
||||||
end_behavior: { missing_payment_method: 'pause' },
|
end_behavior: {
|
||||||
|
missing_payment_method: isBillingPlansEnabled
|
||||||
|
? 'create_invoice'
|
||||||
|
: 'pause',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||||
|
|
||||||
|
export type BillingGetPricesPerPlanResult = {
|
||||||
|
baseProductPrice: BillingPrice;
|
||||||
|
meteredProductsPrices: BillingPrice[];
|
||||||
|
otherLicensedProductsPrices: BillingPrice[];
|
||||||
|
};
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||||
|
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||||
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
|
|
||||||
|
export type BillingPortalCheckoutSessionParameters = {
|
||||||
|
user: User;
|
||||||
|
workspace: Workspace;
|
||||||
|
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||||
|
successUrlPath?: string;
|
||||||
|
plan: BillingPlanKey;
|
||||||
|
priceId?: string;
|
||||||
|
requirePaymentMethod?: boolean;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user