feat: subscription tiers, period filter, dashboards, docs

- Add subscription tiers (Basic/Pro/Business) with listing limits and dynamic commission
- Add daily/monthly period filter on rentals page
- Add landlord dashboard with earnings chart, stat cards, property performance
- Add landlord subscription management page
- Add tenant dashboard with upcoming stays
- Add business model documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delta-lynx-89e8
2026-02-22 16:19:33 -08:00
parent 68beca8f30
commit dcd2dcb841
24 changed files with 1352 additions and 167 deletions

View File

@@ -6,6 +6,7 @@ import { createBookingSchema, rejectBookingSchema, cancelBookingSchema } from '.
import { AppError } from '../middleware/errorHandler.js';
import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js';
import { getPlatformConfig } from '../utils/moderation.js';
import { getLandlordTier, TIER_CONFIG } from '../utils/subscription.js';
const router = Router();
@@ -111,8 +112,9 @@ router.post('/', authenticate, validate(createBookingSchema), async (req, res, n
}
const config = await getPlatformConfig();
const landlordTier = await getLandlordTier(rental.landlordId);
const subtotal = pricePerPeriod * totalPeriods;
const commissionRate = config.rentalCommissionPercent;
const commissionRate = TIER_CONFIG[landlordTier].commissionPercent;
const commissionAmount = subtotal * (commissionRate / 100);
const depositAmount = rental.depositAmount || 0;
const totalAmount = subtotal + depositAmount;

View File

@@ -7,6 +7,7 @@ import { createRentalSchema, updateRentalSchema } from '../validators/rental.js'
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
import { checkAvailability } from '../utils/rental.js';
import { checkListingLimit, TIER_CONFIG } from '../utils/subscription.js';
const router = Router();
@@ -92,15 +93,22 @@ router.get('/', optionalAuth, async (req, res, next) => {
];
}
// Price filters
if (periodType === 'DAILY' || priceMin || priceMax) {
const priceField = periodType === 'MONTHLY' ? 'monthlyPrice' : 'dailyPrice';
// Price / period type filters
if (periodType === 'DAILY') {
const priceFilter: Record<string, unknown> = { not: null };
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
where.dailyPrice = priceFilter;
} else if (periodType === 'MONTHLY') {
const priceFilter: Record<string, unknown> = { not: null };
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
where.monthlyPrice = priceFilter;
} else if (priceMin || priceMax) {
const priceFilter: Record<string, unknown> = {};
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
if (Object.keys(priceFilter).length > 0) {
where[priceField] = priceFilter;
}
where.dailyPrice = priceFilter;
}
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
@@ -180,6 +188,12 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
// --- Create rental ---
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
try {
// Check listing limit by subscription tier
const limitCheck = await checkListingLimit(req.userId!);
if (!limitCheck.allowed) {
throw new AppError(403, `Your ${limitCheck.tier} plan allows up to ${limitCheck.max} active listings. Upgrade your plan to add more.`);
}
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
@@ -240,6 +254,12 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
throw new AppError(400, 'Rental cannot be activated from current status');
}
// Check listing limit by subscription tier
const limitCheck = await checkListingLimit(req.userId!);
if (!limitCheck.allowed) {
throw new AppError(403, `Your ${limitCheck.tier} plan allows up to ${limitCheck.max} active listings. Upgrade your plan to add more.`);
}
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { getLandlordTier, getActiveListingCount, TIER_CONFIG, type SubscriptionTier } from '../utils/subscription.js';
const router = Router();
@@ -11,7 +12,28 @@ router.get('/current', authenticate, async (req, res, next) => {
where: { userId: req.userId! },
});
res.json(subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId });
const tier = await getLandlordTier(req.userId!);
const tierConfig = TIER_CONFIG[tier];
const activeListings = await getActiveListingCount(req.userId!);
res.json({
subscription: subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId },
tierConfig,
usage: { activeListings },
});
} catch (error) {
next(error);
}
});
// GET /api/subscriptions/tiers
router.get('/tiers', async (_req, res, next) => {
try {
const tiers = Object.values(TIER_CONFIG).map(t => ({
...t,
maxActiveListings: t.maxActiveListings === Infinity ? null : t.maxActiveListings,
}));
res.json(tiers);
} catch (error) {
next(error);
}
@@ -27,8 +49,8 @@ router.post('/create', authenticate, async (req, res, next) => {
}
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (existing && existing.status === 'ACTIVE' && existing.tier !== 'BASIC') {
res.status(400).json({ message: 'Already have an active subscription' });
if (existing && existing.status === 'ACTIVE' && existing.tier === tier) {
res.status(400).json({ message: 'Already on this plan' });
return;
}
@@ -64,7 +86,7 @@ router.post('/cancel', authenticate, async (req, res, next) => {
const updated = await prisma.subscription.update({
where: { userId: req.userId! },
data: { status: 'CANCELLED' },
data: { status: 'CANCELLED', tier: 'BASIC' },
});
res.json(updated);