import { io } from "../../index"; import { IPlanSuscriptionDocument, MP_SUBS_STATUS, PlanSuscriptionsAdapterMongoose, } from "./PlanSubscriptions.Adapter.Mongoose"; import UsersManager from "../Users/Users"; import { CancellPlanSuscriptionParams, CheckFeatureParams, CreateMPPreApprovalParams, DeletePlanSuscriptionByUserParams, FindPlanSuscripcionsParams, GetSubscriptionInitPointParams, GetSuscriptionInitPointResponse, IPlanSuscriptionsManager, ISubscriptionInfo, MP_PRE_APPROVAL_STATUS, MPPreApprovalResponse, ToFreePlanParams, ToMpParams, } from "./PlanSubscriptions.interface"; import axios from "axios"; import PlansList from "../../Models/Plans/Plans"; import dayjs from "dayjs"; import "dayjs/locale/es"; import { isNull } from "../../helpers/IsNull"; import { PlanFeatures } from "../Plans/Plans.interface"; import { NotificationsManager } from "../Notifications/Notifications"; import WapServerList from "../WapServer/WapServer"; import CompaniesList from "../Companies/Companies"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing"; dayjs.locale("es"); const DAY_MS = 24 * 60 * 60 * 1000; class PlanSuscriptionsManager implements IPlanSuscriptionsManager { planSuscriptions: PlanSuscriptionsAdapterMongoose; constructor() { this.planSuscriptions = new PlanSuscriptionsAdapterMongoose(); } private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean { return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED; } private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string { const backUrl = process.env.MP_BACK_URL || ""; if (!backUrl) return backUrl; try { const url = new URL(backUrl); url.searchParams.set("mp_return", returnStatus); return url.toString(); } catch { const separator = backUrl.includes("?") ? "&" : "?"; return `${backUrl}${separator}mp_return=${returnStatus}`; } } public async getInitPoint( data: GetSubscriptionInitPointParams ): Promise { const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error( "No se ha encontrado el usuario o no tiene permisos para realizar esta acción" ); } const subscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser, }); if (!subscription) { throw new Error("No se ha encontrado una suscripción activa"); } const plan = await PlansList.plans.findOne({ _id: String(subscription.planId), }); if (!plan) { throw new Error("No se ha encontrado el plan"); } if (plan.price == 0) { throw new Error("El plan gratuito no requiere el pago de una suscripción"); } if (!subscription.mpPreferenceId) { throw new Error("No se ha encontrado una suscripción activa"); } if (subscription.mpStatus !== MP_SUBS_STATUS.PENDING) { throw new Error("No se ha encontrado una suscripción pendiente de pago"); } if (!subscription.mpInitPoint) { throw new Error("No se ha encontrado una suscripción pendiente de pago"); } return { init_point: subscription.mpInitPoint, }; } public async deletePlanSuscriptionByUser( data: DeletePlanSuscriptionByUserParams ): Promise { await this.planSuscriptions.delete({ sessionUser: data.sessionUser }); } public async create(data: CreateMPPreApprovalParams): Promise { const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error( "No se ha encontrado el usuario o no tiene permisos para realizar esta acción" ); } const plan = await PlansList.plans.findOne({ _id: data.planId }); if (!plan) { throw new Error("El plan no existe"); } if (!plan.active) { throw new Error("El plan no esta activo"); } let subscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser, }); let isNewSubscription = false; if (!subscription) { const subscriptionStartDate = dayjs(new Date()); if (plan.price === 0) { isNewSubscription = true; subscription = await this.planSuscriptions.create({ userId: data.sessionUser, planId: data.planId, mpPlanId: data.planId, startDate: subscriptionStartDate.toDate(), endDate: subscriptionStartDate.clone().add(10, "years").toDate(), autoRenew: false, mpStatus: "", mpInitPoint: "/landing/dashboard", }); } else if (plan.price > 0) { isNewSubscription = true; subscription = await this.toMP({ planId: data.planId, sessionUser: data.sessionUser, months: data.months || 1, }); } } else { const actualPlan = await PlansList.plans.findOne({ _id: subscription.planId, }); if (!actualPlan) { throw new Error("El plan no existe"); } isNewSubscription = true; const dashboardUrl = "/landing/dashboard"; if (plan.price === 0) { if (actualPlan.id !== data.planId) { await this.toFreePlan({ sessionUser: data.sessionUser }); subscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser }); } if (subscription) { const subscriptionStartDate = dayjs(new Date()); subscription.startDate = subscriptionStartDate.toDate(); subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate(); subscription.autoRenew = false; subscription.mpStatus = ""; subscription.mpInitPoint = dashboardUrl || ""; subscription.downgradedFromPlanId = undefined; subscription.downgradedFromPlanName = undefined; subscription.downgradedFromPlanCode = undefined; subscription.downgradedAt = undefined; subscription.downgradeReason = undefined; await subscription.save(); } } else { // Actualizamos el plan si actualmente tiene el plan gratuito. if (actualPlan.id !== data.planId && actualPlan.price === 0) { //realizar la suscripcion... subscription = await this.toMP({ planId: data.planId, sessionUser: data.sessionUser, months: data.months || 1, }); } else { const isActivePaidSubscription = actualPlan.price > 0 && (subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) && dayjs(subscription.endDate).isAfter(new Date()); if (isActivePaidSubscription && actualPlan.id !== data.planId) { if (plan.price <= actualPlan.price) { throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización."); } subscription = await this.toMP({ planId: data.planId, sessionUser: data.sessionUser, months: data.months || 1, }); } else { if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) { throw new Error( "No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual." ); } subscription = await this.toMP({ planId: data.planId, sessionUser: data.sessionUser, months: data.months || 1, }); } } } } if (!subscription) { throw new Error("No fue posible crear la suscripción"); } return { init_point: isNewSubscription ? subscription.mpInitPoint : isNull(process.env.MP_BACK_URL, ""), status: MP_PRE_APPROVAL_STATUS.READY_TO_START, }; } public async toMP(data: ToMpParams): Promise { const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error( "No se ha encontrado el usuario o no tiene permisos para realizar esta acción" ); } const plan = await PlansList.plans.findOne({ _id: data.planId }); if (!plan) { throw new Error("El plan no existe"); } try { const preferenceDate = dayjs(new Date()); let discount = 0; if (data.months === 3) discount = plan.discount3Months || 0; else if (data.months === 6) discount = plan.discount6Months || 0; else if (data.months === 12) discount = plan.discount12Months || 0; const totalPrice = plan.price * data.months; const finalPrice = totalPrice - (totalPrice * (discount / 100)); const currentSubscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser, }); const currentPlan = currentSubscription ? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) }) : null; const isUpgradePayment = currentSubscription && currentPlan && String(currentSubscription.planId) !== String(data.planId) && currentPlan.price > 0 && plan.price > currentPlan.price && (currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) && dayjs(currentSubscription.endDate).isAfter(preferenceDate); const remainingDays = isUpgradePayment ? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS)) : 0; const proratedAmount = isUpgradePayment ? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays) : 0; if (isUpgradePayment && remainingDays <= 0) { throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes."); } if ( currentSubscription?.pendingPaymentPreferenceId && currentSubscription.pendingPaymentInitPoint ) { if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) { return currentSubscription; } throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio."); } 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", { items: [ { title: isUpgradePayment ? "TurnosXpress.com.ar - Upgrade al plan " + plan.name : "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)", quantity: 1, currency_id: "ARS", unit_price: isUpgradePayment ? proratedAmount : finalPrice } ], back_urls: { success: this.buildMercadoPagoBackUrl("success"), failure: this.buildMercadoPagoBackUrl("failure"), pending: this.buildMercadoPagoBackUrl("pending") }, auto_return: "approved", payer: { email: sessionUser.mpPayerEmail || "" }, external_reference: String(sessionUser._id), notification_url: process.env.MP_WEBHOOK_URL }, { headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`, "Content-Type": "application/json", }, } ); const subscriptionData = response.data; if (isUpgradePayment && currentSubscription && currentPlan) { currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point; currentSubscription.pendingPaymentPreferenceId = subscriptionData.id; currentSubscription.pendingPaymentBillingMonths = undefined; currentSubscription.pendingPaymentType = "upgrade"; currentSubscription.pendingPaymentPlanId = data.planId; currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price; currentSubscription.pendingPaymentRequestedPlanPrice = plan.price; currentSubscription.pendingPaymentProratedAmount = proratedAmount; currentSubscription.pendingPaymentRemainingDays = remainingDays; currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate; currentSubscription.lastPaymentStatus = undefined; currentSubscription.lastPaymentPreferenceId = undefined; currentSubscription.lastPaymentAt = undefined; currentSubscription.lastPaymentType = undefined; currentSubscription.downgradedFromPlanId = undefined; currentSubscription.downgradedFromPlanName = undefined; currentSubscription.downgradedFromPlanCode = undefined; currentSubscription.downgradedAt = undefined; currentSubscription.downgradeReason = undefined; currentSubscription.mpInitPoint = subscriptionData.init_point; await currentSubscription.save(); await NotificationsManager.sendSystemNotification({ userId: String(data.sessionUser), subject: "Se ha creado una actualización de suscripción", message: `Se ha creado una actualización al plan ${plan.name}.`, type: NotificationType.BILLING, code: String(currentSubscription._id) }); return currentSubscription; } 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.pendingPaymentType = "extension"; currentSubscription.lastPaymentStatus = undefined; currentSubscription.lastPaymentPreferenceId = undefined; currentSubscription.lastPaymentAt = undefined; currentSubscription.lastPaymentType = undefined; currentSubscription.downgradedFromPlanId = undefined; currentSubscription.downgradedFromPlanName = undefined; currentSubscription.downgradedFromPlanCode = undefined; currentSubscription.downgradedAt = undefined; currentSubscription.downgradeReason = undefined; 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: 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, pendingPaymentType: undefined, lastPaymentStatus: undefined, lastPaymentPreferenceId: undefined, lastPaymentAt: undefined, lastPaymentType: undefined, downgradedFromPlanId: undefined, downgradedFromPlanName: undefined, downgradedFromPlanCode: undefined, downgradedAt: undefined, downgradeReason: undefined, }); await NotificationsManager.sendSystemNotification({ userId: String(data.sessionUser), subject: "Se ha creado una nueva suscripción", message: `Se ha creado una nueva suscripción para el plan ${plan.name}.`, type: NotificationType.BILLING, code: String(subscription._id) }); return subscription; } catch (error) { console.log("ha ocurrido un error:", error); throw new Error("No fue posible crear la suscripción en el proveedor de pagos"); } } public async cancell(data: CancellPlanSuscriptionParams): Promise { const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error( "No se ha encontrado el usuario o no tiene permisos para realizar esta acción" ); } const subscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser, }); if (!subscription) { throw new Error("No se ha encontrado una suscripción activa"); } if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) { throw new Error("La suscripción ya se encuentraba cancelada"); } try { if (subscription.mpPreferenceId) { // Preferences don't need to be explicitly cancelled in MercadoPago // We just cancel it locally if not paid, or change to free plan if paid. } const dateNow = new Date(); const dateEnd = new Date(subscription.endDate); if (dateNow > dateEnd) { await this.toFreePlan({ sessionUser: data.sessionUser, downgradeReason: "expired", }); } else { subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; subscription.mpInitPoint = ""; subscription.mpPayerEmail = ""; subscription.mpPayerId = -1; subscription.pendingPaymentInitPoint = ""; subscription.pendingPaymentPreferenceId = ""; subscription.pendingPaymentBillingMonths = undefined; subscription.pendingPaymentType = undefined; subscription.pendingPaymentPlanId = undefined; subscription.pendingPaymentCurrentPlanPrice = undefined; subscription.pendingPaymentRequestedPlanPrice = undefined; subscription.pendingPaymentProratedAmount = undefined; subscription.pendingPaymentRemainingDays = undefined; subscription.pendingPaymentPeriodEndDate = undefined; await subscription.save(); } } catch (error) { console.log("ha ocurrido un error:", error); throw new Error("No fue posible crear la suscripción en el proveedor de pagos"); } } public async toFreePlan(data: ToFreePlanParams): Promise { console.log("intentando aplicar el free plan..."); const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error( "No se ha encontrado el usuario o no tiene permisos para realizar esta acción" ); } const subscription = await this.planSuscriptions.findOne({ sessionUser: data.sessionUser, }); if (!subscription) { throw new Error("No se ha encontrado una suscripción activa"); } const freePlan = await PlansList.plans.findOne({ price: 0 }); if (!freePlan || !freePlan._id) { throw new Error("No se ha encontrado el plan gratuito"); } if (String(subscription.planId) == String(freePlan._id)) { //console.log("El usuario ya tiene el plan gratuito"); return; } const company = await CompaniesList.companies.findOne({ ownerId: data.sessionUser, }); if (company) { //Intentamos eliminar el bot de el usuario. try { //console.log("intentando borrar el bot."); await WapServerList.deleteBot({ companyId: String(company._id), sessionUser: data.sessionUser, }); } catch (error) { console.log("ha ocurrido un error al intentar eliminar el bot:", error); } } //console.log("guardando el plan gratuito..."); //try { const downgradedFromPlan = data.downgradeReason === "expired" ? await PlansList.plans.findOne({ _id: String(subscription.planId) }) : null; console.log("cambiando al free plan:", freePlan.id); 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.pendingPaymentType = undefined; subscription.pendingPaymentPlanId = undefined; subscription.pendingPaymentCurrentPlanPrice = undefined; subscription.pendingPaymentRequestedPlanPrice = undefined; subscription.pendingPaymentProratedAmount = undefined; subscription.pendingPaymentRemainingDays = undefined; subscription.pendingPaymentPeriodEndDate = undefined; subscription.startDate = new Date(); subscription.endDate = dayjs(new Date()).add(10, "years").toDate(); if (data.downgradeReason === "expired" && downgradedFromPlan) { subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id); subscription.downgradedFromPlanName = downgradedFromPlan.name; subscription.downgradedFromPlanCode = downgradedFromPlan.code; subscription.downgradedAt = new Date(); subscription.downgradeReason = "expired"; } else { subscription.downgradedFromPlanId = undefined; subscription.downgradedFromPlanName = undefined; subscription.downgradedFromPlanCode = undefined; subscription.downgradedAt = undefined; subscription.downgradeReason = undefined; } await subscription.save(); // console.log("plan gratuito guardado:", freePlan.id); //} catch (error) { // console.log("no se pudo guardar el plan gratuito."); //} await NotificationsManager.sendSystemNotification({ userId: String(data.sessionUser), subject: "Bienvenido al plan gratuito", message: `El plan gratuito ha sido asignado a su usuario. Cuando lo desee puede actualizar su plan para acceder más funciones.`, type: NotificationType.BILLING, code: String(subscription._id) }); const susInfo: ISubscriptionInfo = { id: subscription.id, plan: freePlan, startDate: subscription.startDate, endDate: subscription.endDate, isActive: subscription.isActive, autoRenew: subscription.autoRenew, mpStatus: subscription.mpStatus, mpDateCreated: subscription.mpDateCreated, lastPaymentStatus: subscription.lastPaymentStatus, lastPaymentPreferenceId: subscription.lastPaymentPreferenceId, lastPaymentAt: subscription.lastPaymentAt, lastPaymentType: subscription.lastPaymentType, downgradedFromPlanId: subscription.downgradedFromPlanId, downgradedFromPlanName: subscription.downgradedFromPlanName, downgradedFromPlanCode: subscription.downgradedFromPlanCode, downgradedAt: subscription.downgradedAt, downgradeReason: subscription.downgradeReason, }; io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo); } public async findOne( data: FindPlanSuscripcionsParams ): Promise { const subscription = await this.planSuscriptions.findOne(data); //console.log("findOne", subscription); if (!subscription) { return null; } const dateNow = new Date(); const dateEnd = new Date(subscription.endDate); if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) { await this.toFreePlan({ sessionUser: data.sessionUser, downgradeReason: "expired", }); } return await this.planSuscriptions.findOne(data); } public async getSubscriptionByUser( data: FindPlanSuscripcionsParams ): Promise { const subscription = await this.planSuscriptions.findOne(data); if (!subscription) { return null; } const dateNow = new Date(); const dateEnd = new Date(subscription.endDate); if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) { await this.toFreePlan({ sessionUser: data.sessionUser, downgradeReason: "expired", }); return this.getSubscriptionByUser(data); } let plan = await PlansList.findOne({ _id: subscription.planId, }); const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0; if (!isPendingPaidSubscription && (!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"); } return { id: subscription.id, plan: plan, startDate: subscription.startDate, endDate: subscription.endDate, isActive: subscription.isActive, autoRenew: subscription.autoRenew, mpStatus: subscription.mpStatus, mpDateCreated: subscription.mpDateCreated, pendingPaymentInitPoint: subscription.pendingPaymentInitPoint, pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId, pendingPaymentType: subscription.pendingPaymentType, lastPaymentStatus: subscription.lastPaymentStatus, lastPaymentPreferenceId: subscription.lastPaymentPreferenceId, lastPaymentAt: subscription.lastPaymentAt, lastPaymentType: subscription.lastPaymentType, downgradedFromPlanId: subscription.downgradedFromPlanId, downgradedFromPlanName: subscription.downgradedFromPlanName, downgradedFromPlanCode: subscription.downgradedFromPlanCode, downgradedAt: subscription.downgradedAt, downgradeReason: subscription.downgradeReason, }; } public async checkFeature(data: CheckFeatureParams): Promise { const subscriptionInfo = await this.getSubscriptionByUser({ sessionUser: data.userId, }); if (!subscriptionInfo) { throw new Error("No se encontro una suscripcion activa"); } const plan = subscriptionInfo.plan; if (!subscriptionInfo.isActive) { return false; } if (!plan) { throw new Error("No se encontro el plan de la suscripcion"); } switch (data.feature) { case PlanFeatures.WAP_NOTIFICATIONS: if (plan.wapNotifications) { return true; } break; case PlanFeatures.MAIL_NOTIFICATIONS: if (plan.mailNotifications) { return true; } break; case PlanFeatures.SMS_NOTIFICATIONS: if (plan.smsNotifications) { return true; } break; case PlanFeatures.HAS_BOT: if (plan.bot) { return true; } break; case PlanFeatures.PAYMENTS: if (plan.payments) { return true; } break; } return false; } public async isSubscriptionActive(data: FindPlanSuscripcionsParams): Promise { const subscriptionInfo = await this.getSubscriptionByUser(data); if (!subscriptionInfo) { throw new Error("No se encontro una suscripcion activa"); } return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING; } } const PlanSubscriptionsList = new PlanSuscriptionsManager(); export default PlanSubscriptionsList;