feat: plan management
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreatePlanPaymentParams,
|
||||
FindPlanPaymentsParams,
|
||||
IPlanPayment,
|
||||
IPlanPaymentsAdapter,
|
||||
} from "./PlanPayments.interface";
|
||||
|
||||
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
|
||||
|
||||
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||
schema: Schema;
|
||||
planPaymentList: Model<IPlanPaymentDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
|
||||
amount: { type: Number, required: true, default: 0 },
|
||||
paymentDate: { type: Date, required: true, default: Date.now },
|
||||
paymentMethod: { type: String, required: false },
|
||||
status: { type: String, required: true },
|
||||
transactionId: { type: String, required: false },
|
||||
});
|
||||
|
||||
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
|
||||
}
|
||||
|
||||
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||
return this.planPaymentList.find(filters).exec();
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPaymentList.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export type FindPlanPaymentsParams = {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
subscriptionId?: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
|
||||
|
||||
export interface IPlanPayment {
|
||||
id?: string;
|
||||
userId: string; // Referencia al ID del usuario
|
||||
@@ -15,9 +20,11 @@ export interface IPlanPayment {
|
||||
|
||||
export interface IPlanPaymentsAdapter {
|
||||
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
export interface IPlanPaymentsManager {
|
||||
planPayments: IPlanPaymentsAdapter;
|
||||
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
|
||||
import {
|
||||
CreatePlanPaymentParams,
|
||||
FindPlanPaymentsParams,
|
||||
IPlanPayment,
|
||||
IPlanPaymentsManager,
|
||||
} from "./PlanPayments.interface";
|
||||
|
||||
class PlanPaymentsManager implements IPlanPaymentsManager {
|
||||
planPayments: PlanPaymentsAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.planPayments = new PlanPaymentsAdapterMongoose();
|
||||
}
|
||||
|
||||
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||
return this.planPayments.find(data);
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
const PlanPaymentsList = new PlanPaymentsManager();
|
||||
|
||||
export default PlanPaymentsList;
|
||||
|
||||
@@ -38,6 +38,10 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
mpDateCreated: { type: Date, required: false },
|
||||
mpInitPoint: { type: String, required: false },
|
||||
mpPreferenceId: { type: String, required: false },
|
||||
billingMonths: { type: Number, required: false },
|
||||
pendingPaymentInitPoint: { type: String, required: false },
|
||||
pendingPaymentPreferenceId: { type: String, required: false },
|
||||
pendingPaymentBillingMonths: { type: Number, required: false },
|
||||
});
|
||||
|
||||
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||
@@ -54,9 +58,21 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
public async findOne(
|
||||
filters: FindPlanSuscripcionsParams
|
||||
): Promise<IPlanSuscriptionDocument | null> {
|
||||
let subscription = await this.planSuscriptionList
|
||||
.findOne({ userId: filters.sessionUser })
|
||||
.exec();
|
||||
const searchCriteria: Record<string, unknown> = {};
|
||||
|
||||
if (filters.sessionUser) {
|
||||
searchCriteria.userId = filters.sessionUser;
|
||||
}
|
||||
|
||||
if (filters.mpPreferenceId) {
|
||||
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
|
||||
}
|
||||
|
||||
if (filters.mpStatus) {
|
||||
searchCriteria.mpStatus = filters.mpStatus;
|
||||
}
|
||||
|
||||
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
@@ -77,17 +93,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
await subscription.save();
|
||||
return subscription;
|
||||
}
|
||||
// Without subscriptions, we wait for the webhook to update the status.
|
||||
// If it's expired and not updated by webhook, we just cancel it.
|
||||
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
|
||||
subscription.mpPreferenceId = "";
|
||||
subscription.isActive = false;
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.mpInitPoint = "";
|
||||
await subscription.save();
|
||||
return subscription;
|
||||
}
|
||||
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
|
||||
|
||||
export type FindPlanSuscripcionsParams = {
|
||||
sessionUser: string;
|
||||
mpPreferenceId?: string;
|
||||
mpStatus?: string;
|
||||
};
|
||||
|
||||
export type CreatePlanSuscriptionParams = {
|
||||
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
|
||||
mpPlanId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isActive?: boolean;
|
||||
autoRenew: boolean;
|
||||
mpPayerId?: number;
|
||||
mpPayerEmail?: string;
|
||||
@@ -18,6 +21,10 @@ export type CreatePlanSuscriptionParams = {
|
||||
mpDateCreated?: Date;
|
||||
mpInitPoint?: string;
|
||||
mpPreferenceId?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentBillingMonths?: number;
|
||||
};
|
||||
|
||||
export type CancellPlanSuscriptionParams = {
|
||||
@@ -88,6 +95,10 @@ export interface IPlanSuscription {
|
||||
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
|
||||
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
|
||||
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
|
||||
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
|
||||
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
|
||||
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
|
||||
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
|
||||
}
|
||||
|
||||
export interface ISubscriptionInfo {
|
||||
|
||||
@@ -42,6 +42,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
|
||||
}
|
||||
|
||||
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
|
||||
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
|
||||
}
|
||||
|
||||
public async getInitPoint(
|
||||
data: GetSubscriptionInitPointParams
|
||||
): Promise<GetSuscriptionInitPointResponse> {
|
||||
@@ -135,8 +139,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
planId: data.planId,
|
||||
mpPlanId: data.planId,
|
||||
startDate: subscriptionStartDate.toDate(),
|
||||
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
|
||||
autoRenew: true,
|
||||
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
|
||||
autoRenew: false,
|
||||
mpStatus: "",
|
||||
mpInitPoint: "/landing/dashboard",
|
||||
});
|
||||
} else if (plan.price > 0) {
|
||||
@@ -168,7 +173,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
if (subscription) {
|
||||
const subscriptionStartDate = dayjs(new Date());
|
||||
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate();
|
||||
subscription.startDate = subscriptionStartDate.toDate();
|
||||
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpStatus = "";
|
||||
subscription.mpInitPoint = dashboardUrl || "";
|
||||
await subscription.save();
|
||||
}
|
||||
@@ -227,7 +235,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriptionStartDate = dayjs(new Date());
|
||||
const preferenceDate = dayjs(new Date());
|
||||
|
||||
let discount = 0;
|
||||
if (data.months === 3) discount = plan.discount3Months || 0;
|
||||
@@ -237,6 +245,21 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
const totalPrice = plan.price * data.months;
|
||||
const finalPrice = totalPrice - (totalPrice * (discount / 100));
|
||||
|
||||
const currentSubscription = await this.planSuscriptions.findOne({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
currentSubscription.pendingPaymentPreferenceId &&
|
||||
currentSubscription.pendingPaymentInitPoint
|
||||
) {
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
"https://api.mercadopago.com/checkout/preferences",
|
||||
{
|
||||
@@ -270,20 +293,48 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
const subscriptionData = response.data;
|
||||
|
||||
this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
|
||||
) {
|
||||
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||
currentSubscription.pendingPaymentBillingMonths = data.months;
|
||||
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||
await currentSubscription.save();
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(data.sessionUser),
|
||||
subject: "Se ha creado una nueva extensión de suscripción",
|
||||
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
|
||||
type: NotificationType.BILLING,
|
||||
code: String(currentSubscription._id)
|
||||
});
|
||||
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
||||
|
||||
const subscription = await this.planSuscriptions.create({
|
||||
userId: data.sessionUser,
|
||||
planId: data.planId,
|
||||
mpPlanId: data.planId,
|
||||
startDate: subscriptionStartDate.toDate(),
|
||||
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
|
||||
startDate: preferenceDate.toDate(),
|
||||
endDate: preferenceDate.toDate(),
|
||||
isActive: false,
|
||||
autoRenew: false,
|
||||
mpPayerEmail: sessionUser.mpPayerEmail,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
mpDateCreated: new Date(),
|
||||
mpInitPoint: subscriptionData.init_point,
|
||||
mpPreferenceId: subscriptionData.id,
|
||||
billingMonths: data.months,
|
||||
pendingPaymentInitPoint: "",
|
||||
pendingPaymentPreferenceId: "",
|
||||
pendingPaymentBillingMonths: undefined,
|
||||
});
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
@@ -404,8 +455,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
subscription.planId = freePlan.id;
|
||||
subscription.mpStatus = "";
|
||||
subscription.isActive = true;
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpInitPoint = "";
|
||||
subscription.mpPreferenceId = "";
|
||||
subscription.billingMonths = undefined;
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
subscription.startDate = new Date();
|
||||
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
|
||||
|
||||
await subscription.save();
|
||||
// console.log("plan gratuito guardado:", freePlan.id);
|
||||
@@ -449,12 +507,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
const dateNow = dayjs(new Date()).endOf("day");
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
|
||||
this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.planSuscriptions.findOne(data);
|
||||
@@ -472,23 +528,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
const dateNow = dayjs(new Date()).endOf("day");
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
|
||||
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
|
||||
if (
|
||||
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
|
||||
subscription.mpStatus !== ""
|
||||
) {
|
||||
this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
if (dateNow.isSameOrAfter(dateEnd) && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
return this.getSubscriptionByUser(data);
|
||||
}
|
||||
|
||||
const plan = await PlansList.plans.findOne({
|
||||
let plan = await PlansList.findOne({
|
||||
_id: subscription.planId,
|
||||
});
|
||||
|
||||
if (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
|
||||
const freePlan = await PlansList.findOne({ price: 0 });
|
||||
if (freePlan) {
|
||||
plan = freePlan;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("No se ha encontrado el plan");
|
||||
}
|
||||
@@ -514,6 +571,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
}
|
||||
const plan = subscriptionInfo.plan;
|
||||
|
||||
if (!subscriptionInfo.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("No se encontro el plan de la suscripcion");
|
||||
}
|
||||
@@ -553,7 +614,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
if (!subscriptionInfo) {
|
||||
throw new Error("No se encontro una suscripcion activa");
|
||||
}
|
||||
return subscriptionInfo.isActive;
|
||||
return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from "axios";
|
||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||
import { connect } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
|
||||
|
||||
export class MercadoPagoWebhookService {
|
||||
public async handleWebhook(body: any): Promise<void> {
|
||||
@@ -27,28 +29,75 @@ export class MercadoPagoWebhookService {
|
||||
const paymentData = paymentResponse.data;
|
||||
const status = paymentData.status; // approved, pending, rejected, etc.
|
||||
const userId = paymentData.external_reference; // We passed this when creating preference
|
||||
const preferenceId = paymentData.preference_id || paymentData.order?.id;
|
||||
|
||||
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
mpPreferenceId: preferenceId,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
});
|
||||
|
||||
if (!subscription && userId && preferenceId) {
|
||||
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||
userId,
|
||||
pendingPaymentPreferenceId: preferenceId,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (userId && status === "approved") {
|
||||
// Update subscription status in DB
|
||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
});
|
||||
|
||||
if (subscription) {
|
||||
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
|
||||
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
|
||||
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||
? subscription.endDate
|
||||
: approvedAt;
|
||||
|
||||
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
||||
subscription.isActive = true;
|
||||
// Assuming endDate is correctly set for the months purchased when preference was created
|
||||
subscription.autoRenew = false;
|
||||
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
|
||||
subscription.endDate = dayjs(periodStart).add(billingMonths, "months").toDate();
|
||||
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
|
||||
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.create({
|
||||
userId,
|
||||
subscriptionId: String(subscription._id),
|
||||
amount: paymentData.transaction_amount || 0,
|
||||
paymentDate: approvedAt,
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "completed",
|
||||
transactionId: String(paymentData.id),
|
||||
});
|
||||
}
|
||||
} else if (userId && status === "rejected") {
|
||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
});
|
||||
if (subscription) {
|
||||
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||
|
||||
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.isActive = false;
|
||||
if (isExtensionPayment) {
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
} else {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.isActive = false;
|
||||
}
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.create({
|
||||
userId,
|
||||
subscriptionId: String(subscription._id),
|
||||
amount: paymentData.transaction_amount || 0,
|
||||
paymentDate: new Date(),
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "failed",
|
||||
transactionId: String(paymentData.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user