From dac8ca30f212dd94734f44293b24d38f3024dac1 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Sun, 20 Sep 2026 23:25:33 +0530 Subject: [PATCH 1/4] feat: redesign pricing model to usage and scale-based tiering - Unlock BYOS, BYOK, custom mail templates, and marketing broadcasts on Free tier - Increase Free tier mail limit to 50/month and active webhooks to 3 - Implement smart count-based webhook creation gate (unrestricted edit/delete/test) - Enforce shared monthly mail quota pool for broadcast sends with auto-refund - Make StatsRow Current Plan display dynamic using useAuth - Sync landing page pricing and dashboard UpgradeModal features and FAQs - Fix outdated bitbros.in pricing URLs to urbackend.in - Sync Mintlify documentation quotas (10MB storage, 50MB database) - Fix CLI status command to properly render unlimited (-1) metrics --- .../src/middlewares/planEnforcement.js | 16 ++++++++-- apps/landing/src/pages/pricing.astro | 32 +++++++++---------- .../src/controllers/mail.controller.js | 23 +++++++++++-- .../src/components/Dashboard/StatsRow.jsx | 10 ++++-- .../src/components/Dashboard/UsageQuota.jsx | 8 ++--- .../src/components/UpgradeModal.jsx | 28 ++++++++-------- apps/web-dashboard/src/pages/Dashboard.jsx | 2 +- apps/web-dashboard/src/pages/Docs.jsx | 2 +- apps/web-dashboard/src/utils/api.js | 4 +-- .../docs/api-reference/storage/upload.mdx | 2 +- mintlify/docs/guides/ai-byok.mdx | 1 - mintlify/docs/guides/mail-platform.mdx | 4 +-- mintlify/docs/guides/storage.mdx | 4 +-- mintlify/docs/limits-and-quotas.mdx | 4 +-- mintlify/docs/sdk/storage.mdx | 2 +- packages/common/src/utils/planLimits.js | 14 ++++---- .../src/commands/status/index.ts | 24 ++++++++++---- sdks/urbackend-cli/src/utils/format.ts | 1 + 18 files changed, 109 insertions(+), 72 deletions(-) diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 70cd25cb6..fb96a1317 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -177,7 +177,7 @@ exports.checkByokGate = async function(req, res, next) { } exports.checkWebhookGate = async function(req, res, next) { - const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); + const { Project, Webhook, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); try { if (isAdminRequest(req)) return next(); @@ -186,8 +186,15 @@ exports.checkWebhookGate = async function(req, res, next) { return next(new AppError(403, 'Verify your email to create or test webhooks.')); } + // Only enforce count limit on new webhook creation (POST without webhookId) + const isCreate = req.method === 'POST' && !req.params.webhookId; + if (!isCreate) { + return next(); + } + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; const cleanProjectId = sanitizeObjectId(rawProjectId); + if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); let customLimits = null; if (cleanProjectId) { @@ -198,8 +205,11 @@ exports.checkWebhookGate = async function(req, res, next) { const effectivePlan = resolveEffectivePlan(req.developer); const limits = getPlanLimits({ plan: effectivePlan, customLimits }); - if (limits.webhooksLimit === 0) { - return next(new AppError(403, 'Webhooks are a Pro feature. Please upgrade to create integrations.')); + if (limits.webhooksLimit !== -1) { + const currentCount = await Webhook.countDocuments({ projectId: cleanProjectId }); + if (currentCount >= limits.webhooksLimit) { + return next(new AppError(403, `Webhook limit reached (${limits.webhooksLimit}). Please upgrade your plan for unlimited webhooks.`)); + } } next(); diff --git a/apps/landing/src/pages/pricing.astro b/apps/landing/src/pages/pricing.astro index 05ab5babd..7a121234e 100644 --- a/apps/landing/src/pages/pricing.astro +++ b/apps/landing/src/pages/pricing.astro @@ -5,29 +5,27 @@ import Footer from '../components/Footer.astro'; import { Check, ChevronDown, HelpCircle, ArrowRight } from 'lucide-react'; const FREE_FEATURES = [ - '1 Project', - '5 Collections per project', + '1 Project, 5 Collections', '2,000 API requests / day', - '10MB File Storage', '200 Auth Users', - '25 Emails / month', - 'Global email templates', + '3 Active Webhooks', + '50 Emails / month (Sends & Broadcasts)', + 'Custom HTML Email Templates', + 'Bring Your Own MongoDB (BYOM)', + 'Bring Your Own Storage (S3 / R2)', + 'Bring Your Own Keys (Resend, OAuth, AI)', + 'Team Collaboration (up to 2 members)', 'Community support', ]; const PRO_FEATURES = [ - '10 Projects', - 'Unlimited collections', - 'Unlimited API requests', + '10 Projects, Unlimited Collections', + 'Unlimited API requests (600 req/min burst)', 'Unlimited Auth Users', - 'Unlimited Webhooks', - 'External Database (BYOM)', - 'Bring your own Storage (S3/R2)', + 'Unlimited Webhooks + Priority Delivery', '1,000 Emails / month', - 'Custom HTML email templates', - 'BYOK — own API keys', - 'Analytics Pro', - 'AI integrations (OpenAI, Groq)', + 'Up to 6 Team Members', + 'Analytics Pro & Deep Insights', 'Priority support', ]; @@ -38,7 +36,7 @@ const PRICING_FAQS = [ }, { q: 'Can I bring my own MongoDB Atlas cluster on the Free tier?', - a: 'The Free tier includes urBackend managed database storage. Bring Your Own MongoDB (BYOM) is available on the Pro tier during beta.' + a: 'Yes! Both Free and Pro tiers support Bring Your Own MongoDB (BYOM) and Bring Your Own Storage (BYOS). Connect your Atlas cluster or S3 bucket directly on any plan.' }, { q: 'How does the free 1-month Pro trial work?', @@ -46,7 +44,7 @@ const PRICING_FAQS = [ }, { q: 'Do you offer team collaboration?', - a: 'Yes. Pro plans support inviting team members with granular project access controls and role-based permissions.' + a: 'Yes. Free plans allow up to 2 team members (owner + 1 collaborator). Pro plans support up to 6 members with role-based access control.' } ]; --- diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index 6fae46f00..be6c1dc1f 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -727,12 +727,12 @@ module.exports.deleteContact = async (req, res, next) => { } }; -// --- BROADCASTS (BYOK + Pro Gate) --- +// --- BROADCASTS (BYOK Gate + Quota Tracking) --- const requireBroadcastGate = async (req) => { const { resend, usingByok } = await resolveResendClient(req); if (!usingByok || !req.planLimits?.byokEnabled) { - const err = new Error("Broadcasts require both a BYOK Resend key and a Pro plan."); + const err = new Error("Broadcasts require a configured BYOK Resend key."); err.statusCode = 403; throw err; } @@ -766,17 +766,34 @@ module.exports.createBroadcast = async (req, res, next) => { }; module.exports.sendBroadcast = async (req, res, next) => { + let consumedQuotaKey = null; try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); + + const projectId = req.project?._id?.toString() || req.project?._id; + if (projectId) { + const limit = getMonthlyMailLimit(req.project, req.planLimits); + const { count, key } = await reserveMonthlyMailSlot(projectId, limit); + consumedQuotaKey = key; + } + const { data, error } = await resend.broadcasts.send(id); - if (error) return next(new AppError(error.statusCode || 500, error.message)); + if (error) { + if (consumedQuotaKey) { + await redis.decr(consumedQuotaKey).catch(() => {}); + } + return next(new AppError(error.statusCode || 500, error.message)); + } return new ApiResponse(data).send(res, 200); } catch (err) { + if (consumedQuotaKey) { + await redis.decr(consumedQuotaKey).catch(() => {}); + } return next(new AppError(err.statusCode || 500, err.message)); } }; diff --git a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx index add9b3fa6..2b138f19b 100644 --- a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx +++ b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx @@ -1,8 +1,12 @@ import React from 'react'; import { Folder, Activity, Zap } from 'lucide-react'; import StatCard from './StatCard'; +import { useAuth } from '../../context/AuthContext'; const StatsRow = ({ projectsCount }) => { + const { user } = useAuth(); + const isPro = user?.plan === 'pro'; + return (
{ />
); diff --git a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx index 161f45563..a19bd269c 100644 --- a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx +++ b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx @@ -67,7 +67,7 @@ const UsageQuota = () => { { limit={limits?.storageBytes ?? 10485760} formatValue={formatBytes} unit="" - unlimited={limits?.storageBytes === -1} - tooltip={limits?.storageBytes === -1 ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : ""} + unlimited={limits?.storageBytes === -1 || limits?.byosEnabled} + tooltip={limits?.byosEnabled ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : (limits?.storageBytes === -1 ? "Unlimited managed file storage" : "")} /> { {!isPro && (