add landing and subscription plans

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 23:49:19 +01:00
parent 05f0455744
commit 6ec8855c00
79 changed files with 8839 additions and 361 deletions

View File

@@ -0,0 +1,120 @@
// Planes de suscripción con sus límites y precios
export const SUBSCRIPTION_PLANS = {
free: {
id: 'free',
name: 'Gratis',
description: 'Perfecto para empezar',
price: {
monthly: 0,
yearly: 0,
},
limits: {
maxWorkers: 2, // Número máximo de búsquedas/workers
maxNotificationsPerDay: 50,
platforms: ['wallapop'], // Solo Wallapop
},
features: [
'Hasta 2 búsquedas simultáneas',
'Solo Wallapop',
'50 notificaciones por día',
'Soporte por email',
],
},
basic: {
id: 'basic',
name: 'Básico',
description: 'Para usuarios ocasionales',
price: {
monthly: 9.99,
yearly: 99.99, // ~17% descuento
},
limits: {
maxWorkers: 5,
maxNotificationsPerDay: 200,
platforms: ['wallapop', 'vinted'],
},
features: [
'Hasta 5 búsquedas simultáneas',
'Wallapop y Vinted',
'200 notificaciones por día',
'Soporte prioritario',
'Sin límite de favoritos',
],
},
pro: {
id: 'pro',
name: 'Pro',
description: 'Para usuarios avanzados',
price: {
monthly: 19.99,
yearly: 199.99, // ~17% descuento
},
limits: {
maxWorkers: 15,
maxNotificationsPerDay: 1000,
platforms: ['wallapop', 'vinted', 'buyee'],
},
features: [
'Hasta 15 búsquedas simultáneas',
'Todas las plataformas',
'1000 notificaciones por día',
'Soporte prioritario 24/7',
'API access',
'Webhooks personalizados',
],
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
description: 'Para equipos y uso intensivo',
price: {
monthly: 49.99,
yearly: 499.99, // ~17% descuento
},
limits: {
maxWorkers: -1, // Ilimitado
maxNotificationsPerDay: -1, // Ilimitado
platforms: ['wallapop', 'vinted', 'buyee'], // Todas
},
features: [
'Búsquedas ilimitadas',
'Notificaciones ilimitadas',
'Todas las plataformas',
'Soporte dedicado',
'API completa',
'Webhooks personalizados',
'Gestión de múltiples usuarios',
'Estadísticas avanzadas',
],
},
};
// Obtener plan por ID
export function getPlan(planId) {
return SUBSCRIPTION_PLANS[planId] || SUBSCRIPTION_PLANS.free;
}
// Verificar si un plan tiene una característica
export function hasFeature(planId, feature) {
const plan = getPlan(planId);
return plan.features.includes(feature);
}
// Verificar si un plan tiene acceso a una plataforma
export function hasPlatformAccess(planId, platform) {
const plan = getPlan(planId);
return plan.limits.platforms.includes(platform) || plan.limits.platforms.length === 0;
}
// Obtener límite de workers para un plan
export function getMaxWorkers(planId) {
const plan = getPlan(planId);
return plan.limits.maxWorkers;
}
// Obtener límite de notificaciones diarias
export function getMaxNotificationsPerDay(planId) {
const plan = getPlan(planId);
return plan.limits.maxNotificationsPerDay;
}

View File

@@ -0,0 +1,133 @@
import { getUser, getUserSubscription, getWorkerCount } from '../services/mongodb.js';
import { getPlan, getMaxWorkers, hasPlatformAccess } from '../config/subscriptionPlans.js';
// Middleware para verificar límites de suscripción
export async function checkSubscriptionLimits(req, res, next) {
try {
const username = req.user?.username;
if (!username) {
return res.status(401).json({ error: 'Authentication required' });
}
// Obtener usuario y suscripción
const user = await getUser(username);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Si es admin, no aplicar límites
if (user.role === 'admin') {
return next();
}
const subscription = await getUserSubscription(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
// Añadir información del plan al request para uso posterior
req.userPlan = {
planId,
plan,
subscription,
};
next();
} catch (error) {
console.error('Error verificando límites de suscripción:', error);
res.status(500).json({ error: 'Error verificando límites de suscripción' });
}
}
// Middleware para verificar límite de workers
export async function checkWorkerLimit(req, res, next) {
try {
const username = req.user?.username;
const userPlan = req.userPlan;
if (!userPlan) {
// Si no hay userPlan, ejecutar checkSubscriptionLimits primero
await checkSubscriptionLimits(req, res, () => {});
if (res.headersSent) return;
}
const planId = req.userPlan.planId;
const maxWorkers = getMaxWorkers(planId);
// Si es ilimitado (-1), permitir
if (maxWorkers === -1) {
return next();
}
// Obtener número actual de workers
const currentWorkerCount = await getWorkerCount(username);
// Si estamos actualizando workers, verificar el nuevo número
if (req.method === 'PUT' && req.body?.items) {
const newWorkerCount = (req.body.items || []).filter(
(item, index) => !(req.body.disabled || []).includes(index)
).length;
if (newWorkerCount > maxWorkers) {
return res.status(403).json({
error: 'Límite de búsquedas excedido',
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas. Estás intentando crear ${newWorkerCount}.`,
currentPlan: planId,
maxWorkers,
currentCount: currentWorkerCount,
requestedCount: newWorkerCount,
});
}
} else if (currentWorkerCount >= maxWorkers) {
// Si ya alcanzó el límite y está intentando crear más
return res.status(403).json({
error: 'Límite de búsquedas alcanzado',
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas.`,
currentPlan: planId,
maxWorkers,
currentCount: currentWorkerCount,
});
}
next();
} catch (error) {
console.error('Error verificando límite de workers:', error);
res.status(500).json({ error: 'Error verificando límite de workers' });
}
}
// Middleware para verificar acceso a plataforma
export async function checkPlatformAccess(req, res, next) {
try {
const platform = req.body?.platform || req.query?.platform;
if (!platform) {
return next(); // Si no hay plataforma especificada, continuar
}
const userPlan = req.userPlan;
if (!userPlan) {
await checkSubscriptionLimits(req, res, () => {});
if (res.headersSent) return;
}
const planId = req.userPlan.planId;
if (!hasPlatformAccess(planId, platform)) {
return res.status(403).json({
error: 'Plataforma no disponible en tu plan',
message: `La plataforma "${platform}" no está disponible en tu plan actual (${req.userPlan.plan.name}).`,
currentPlan: planId,
requestedPlatform: platform,
availablePlatforms: req.userPlan.plan.limits.platforms,
});
}
next();
} catch (error) {
console.error('Error verificando acceso a plataforma:', error);
res.status(500).json({ error: 'Error verificando acceso a plataforma' });
}
}

View File

@@ -0,0 +1,208 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { getUser, getUserSubscription, updateUserSubscription, getWorkerCount } from '../services/mongodb.js';
import { SUBSCRIPTION_PLANS, getPlan } from '../config/subscriptionPlans.js';
const router = express.Router();
// Obtener planes disponibles (público)
router.get('/plans', (req, res) => {
try {
const plans = Object.values(SUBSCRIPTION_PLANS).map(plan => ({
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: {
maxWorkers: plan.limits.maxWorkers,
maxNotificationsPerDay: plan.limits.maxNotificationsPerDay,
platforms: plan.limits.platforms,
},
features: plan.features,
}));
res.json({ plans });
} catch (error) {
console.error('Error obteniendo planes:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripción del usuario actual
router.get('/me', basicAuthMiddleware, async (req, res) => {
try {
const username = req.user.username;
const subscription = await getUserSubscription(username);
const user = await getUser(username);
const workerCount = await getWorkerCount(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
res.json({
subscription: {
planId,
plan: {
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: plan.limits,
features: plan.features,
},
status: subscription?.status || 'active',
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
currentPeriodEnd: subscription?.currentPeriodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
},
usage: {
workers: workerCount,
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
},
});
} catch (error) {
console.error('Error obteniendo suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Actualizar suscripción (requiere admin o para el propio usuario en caso de cancelación)
router.put('/me', basicAuthMiddleware, async (req, res) => {
try {
const username = req.user.username;
const { planId, status, cancelAtPeriodEnd } = req.body;
if (!planId) {
return res.status(400).json({ error: 'planId es requerido' });
}
// Verificar que el plan existe
const plan = getPlan(planId);
if (!plan) {
return res.status(400).json({ error: 'Plan no válido' });
}
// Solo permitir actualizar a plan gratuito o cancelar suscripción
// Para actualizar a planes de pago, se requiere integración con pasarela de pago
if (planId !== 'free' && !req.user.role === 'admin') {
return res.status(403).json({
error: 'Para actualizar a un plan de pago, contacta con soporte o usa la pasarela de pago'
});
}
const subscription = await getUserSubscription(username);
// Calcular fechas del período
const now = new Date();
let currentPeriodStart = subscription?.currentPeriodStart || now;
let currentPeriodEnd = null;
if (planId !== 'free') {
// Para planes de pago, establecer período mensual o anual según corresponda
// Por ahora, asumimos mensual (30 días)
currentPeriodEnd = new Date(now);
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
}
await updateUserSubscription(username, {
planId,
status: status || 'active',
currentPeriodStart,
currentPeriodEnd,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
});
console.log(`✅ Suscripción actualizada para ${username}: ${planId}`);
res.json({
success: true,
message: 'Suscripción actualizada correctamente',
subscription: {
planId,
status: status || 'active',
currentPeriodStart,
currentPeriodEnd,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
},
});
} catch (error) {
console.error('Error actualizando suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripción de cualquier usuario (solo admin)
router.get('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const { username } = req.params;
const subscription = await getUserSubscription(username);
const user = await getUser(username);
const workerCount = await getWorkerCount(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
res.json({
subscription: {
planId,
plan: {
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: plan.limits,
features: plan.features,
},
status: subscription?.status || 'active',
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
currentPeriodEnd: subscription?.currentPeriodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
},
usage: {
workers: workerCount,
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
},
});
} catch (error) {
console.error('Error obteniendo suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Actualizar suscripción de cualquier usuario (solo admin)
router.put('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const { username } = req.params;
const { planId, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd } = req.body;
if (!planId) {
return res.status(400).json({ error: 'planId es requerido' });
}
// Verificar que el plan existe
const plan = getPlan(planId);
if (!plan) {
return res.status(400).json({ error: 'Plan no válido' });
}
await updateUserSubscription(username, {
planId,
status: status || 'active',
currentPeriodStart: currentPeriodStart ? new Date(currentPeriodStart) : new Date(),
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : null,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
});
console.log(`✅ Suscripción actualizada para ${username}: ${planId} por admin ${req.user.username}`);
res.json({
success: true,
message: `Suscripción de ${username} actualizada correctamente`,
});
} catch (error) {
console.error('Error actualizando suscripción:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -2,6 +2,7 @@ import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
import { checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess } from '../middlewares/subscriptionLimits.js';
const router = express.Router();
@@ -23,7 +24,7 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
});
// Actualizar workers del usuario autenticado (requiere autenticación)
router.put('/', basicAuthMiddleware, async (req, res) => {
router.put('/', basicAuthMiddleware, checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess, async (req, res) => {
try {
const db = getDB();
if (!db) {

View File

@@ -18,6 +18,7 @@ import telegramRouter from './routes/telegram.js';
import pushRouter from './routes/push.js';
import usersRouter from './routes/users.js';
import adminRouter from './routes/admin.js';
import subscriptionRouter from './routes/subscription.js';
const app = express();
const server = createServer(app);
@@ -49,6 +50,7 @@ app.use('/api/telegram', telegramRouter);
app.use('/api/push', pushRouter);
app.use('/api/users', usersRouter);
app.use('/api/admin', adminRouter);
app.use('/api/subscription', subscriptionRouter);
// Inicializar servidor
async function startServer() {

View File

@@ -1277,6 +1277,104 @@ export async function updateArticleFavorite(platform, id, is_favorite, username)
}
}
// Funciones para suscripciones
export async function getUserSubscription(username) {
if (!db) {
return null;
}
try {
const usersCollection = db.collection('users');
const user = await usersCollection.findOne({ username });
if (!user) {
return null;
}
// Si no tiene suscripción, retornar plan gratuito por defecto
if (!user.subscription) {
return {
planId: 'free',
status: 'active',
currentPeriodStart: user.createdAt || new Date(),
currentPeriodEnd: null, // Plan gratuito no expira
cancelAtPeriodEnd: false,
};
}
return user.subscription;
} catch (error) {
console.error(`Error obteniendo suscripción de ${username}:`, error.message);
return null;
}
}
export async function updateUserSubscription(username, subscriptionData) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const usersCollection = db.collection('users');
// Verificar que el usuario existe
const user = await usersCollection.findOne({ username });
if (!user) {
throw new Error(`Usuario ${username} no existe`);
}
// Actualizar suscripción
await usersCollection.updateOne(
{ username },
{
$set: {
subscription: {
planId: subscriptionData.planId || 'free',
status: subscriptionData.status || 'active',
currentPeriodStart: subscriptionData.currentPeriodStart || new Date(),
currentPeriodEnd: subscriptionData.currentPeriodEnd || null,
cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd || false,
stripeCustomerId: subscriptionData.stripeCustomerId || null,
stripeSubscriptionId: subscriptionData.stripeSubscriptionId || null,
updatedAt: new Date(),
},
updatedAt: new Date(),
},
}
);
return true;
} catch (error) {
console.error(`Error actualizando suscripción de ${username}:`, error.message);
throw error;
}
}
export async function getWorkerCount(username) {
if (!db) {
return 0;
}
try {
const workersCollection = db.collection('workers');
const workersData = await workersCollection.findOne({ username });
if (!workersData || !workersData.items) {
return 0;
}
// Contar solo workers activos (no deshabilitados)
const activeWorkers = (workersData.items || []).filter(
(item, index) => !(workersData.disabled || []).includes(index)
);
return activeWorkers.length;
} catch (error) {
console.error(`Error contando workers de ${username}:`, error.message);
return 0;
}
}
// Cerrar conexión
export async function closeMongoDB() {
if (mongoClient) {

View File

@@ -139,7 +139,7 @@ function parseUserAgent(userAgent) {
}
/**
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
* Genera un fingerprint desde el dashboard (cuando se envía desde el cliente)
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
* @param {Object} deviceInfo - Información del dispositivo del cliente
* @param {Object} req - Request object de Express