Skip to content

Commit c4f6c04

Browse files
Merge pull request #409 from geturbackend/feat/pricing-model-redesign
feat: redesign pricing model to usage and scale-based tiering
2 parents 333f9ef + 9335e3a commit c4f6c04

24 files changed

Lines changed: 296 additions & 91 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,7 @@ That's it! 🎉
5858

5959
## 🟢 Why urBackend?
6060

61-
While tools like PocketBase are great for small single-server apps (SQLite), **urBackend** is built for scale.
62-
63-
| Feature | urBackend | Firebase | PocketBase |
64-
| :--- | :--- | :--- | :--- |
65-
| **Database** | **MongoDB (Scalable)** | Proprietary NoSQL | SQLite (Single Server) |
66-
| **Caching** | **Redis Built-in** | None | None |
67-
| **Hosting** | **Self-Hosted / Cloud** | Cloud Only | Self-Hosted |
68-
| **Source** | **Open-Source** | Closed-Source | Open-Source |
61+
**urBackend** is built for developer velocity and horizontal scale, combining the simplicity of a modern BaaS with the power of MongoDB and Redis.
6962

7063
### Core Features:
7164
- **Instant NoSQL:** Create collections and push JSON data instantly.

‎apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jest.mock('@urbackend/common', () => {
88

99
const Project = {
1010
countDocuments: jest.fn(),
11+
findById: jest.fn(),
1112
findOne: jest.fn(),
1213
};
1314

@@ -16,12 +17,12 @@ jest.mock('@urbackend/common', () => {
1617
Project,
1718
sanitizeObjectId: jest.fn((value) => value || null),
1819
resolveEffectivePlan: jest.fn(() => 'free'),
19-
getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5 })),
20+
getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5, webhooksLimit: 3 })),
2021
};
2122
});
2223

2324
const { Project, AppError } = require('@urbackend/common');
24-
const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit } = require('../middlewares/planEnforcement');
25+
const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit, checkWebhookGate } = require('../middlewares/planEnforcement');
2526

2627
const makeReq = (overrides = {}) => ({
2728
user: { _id: 'dev_1', email: 'dev@example.com', isVerified: false },
@@ -131,4 +132,39 @@ describe('planEnforcement capability checks', () => {
131132

132133
expect(next).toHaveBeenCalledWith();
133134
});
135+
136+
test('passes the finite webhook quota to the create controller without pre-counting', async () => {
137+
Project.findById.mockReturnValue({
138+
select: jest.fn().mockReturnThis(),
139+
lean: jest.fn().mockResolvedValue({ customLimits: null }),
140+
});
141+
const req = makeReq({
142+
method: 'POST',
143+
params: { projectId: 'project_1' },
144+
user: { _id: 'dev_1', email: 'dev@example.com', isVerified: true },
145+
developer: { _id: 'dev_1', isVerified: true },
146+
});
147+
const next = jest.fn();
148+
149+
await checkWebhookGate(req, {}, next);
150+
151+
expect(req.webhookQuotaLimit).toBe(3);
152+
expect(next).toHaveBeenCalledWith();
153+
});
154+
155+
test('preserves the admin webhook quota bypass', async () => {
156+
const req = makeReq({
157+
method: 'POST',
158+
params: { projectId: 'project_1' },
159+
user: { _id: 'admin_1', email: 'admin@example.com', isAdmin: true, isVerified: true },
160+
developer: { _id: 'admin_1', isVerified: true },
161+
});
162+
const next = jest.fn();
163+
164+
await checkWebhookGate(req, {}, next);
165+
166+
expect(req.webhookQuotaLimit).toBeUndefined();
167+
expect(Project.findById).not.toHaveBeenCalled();
168+
expect(next).toHaveBeenCalledWith();
169+
});
134170
});

‎apps/dashboard-api/src/__tests__/webhook.controller.test.js‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jest.mock('@urbackend/common', () => {
1515
return {
1616
Webhook: {
1717
create: jest.fn(),
18+
countDocuments: jest.fn(),
1819
find: jest.fn(),
1920
findOne: jest.fn(),
2021
findOneAndUpdate: jest.fn(),
@@ -26,6 +27,7 @@ jest.mock('@urbackend/common', () => {
2627
},
2728
Project: {
2829
findOne: jest.fn(),
30+
updateOne: jest.fn(),
2931
},
3032
AppError,
3133
encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })),
@@ -95,6 +97,10 @@ describe('webhook.controller', () => {
9597
next = jest.fn();
9698
});
9799

100+
afterEach(() => {
101+
jest.restoreAllMocks();
102+
});
103+
98104
describe('createWebhook', () => {
99105
test('creates webhook with valid input', async () => {
100106
Project.findOne.mockResolvedValue({ _id: validProjectId });
@@ -145,6 +151,90 @@ describe('webhook.controller', () => {
145151
);
146152
});
147153

154+
test('checks finite quota and creates the webhook in one transaction', async () => {
155+
const session = {
156+
withTransaction: jest.fn(async (operation) => operation()),
157+
endSession: jest.fn(),
158+
};
159+
jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
160+
Project.findOne.mockResolvedValue({ _id: validProjectId });
161+
Project.updateOne.mockResolvedValue({ matchedCount: 1 });
162+
Webhook.countDocuments.mockResolvedValue(2);
163+
createWebhookSchema.safeParse.mockReturnValue({
164+
success: true,
165+
data: {
166+
name: 'Test Webhook',
167+
url: 'https://example.com/hook',
168+
secret: 'whsec_testsecret12345678',
169+
events: {},
170+
},
171+
});
172+
Webhook.create.mockResolvedValue([{
173+
_id: validWebhookId,
174+
projectId: validProjectId,
175+
name: 'Test Webhook',
176+
url: 'https://example.com/hook',
177+
events: new Map(),
178+
enabled: true,
179+
}]);
180+
req.body = {
181+
name: 'Test Webhook',
182+
url: 'https://example.com/hook',
183+
secret: 'whsec_testsecret12345678',
184+
};
185+
req.webhookQuotaLimit = 3;
186+
187+
await createWebhook(req, res, next);
188+
189+
expect(Project.updateOne).toHaveBeenCalledWith(
190+
{ _id: validProjectId, owner: 'user123' },
191+
{ $inc: { webhookQuotaVersion: 1 } },
192+
{ session }
193+
);
194+
expect(Webhook.countDocuments).toHaveBeenCalledWith(
195+
{ projectId: validProjectId },
196+
{ session }
197+
);
198+
expect(Webhook.create).toHaveBeenCalledWith(
199+
[expect.objectContaining({ projectId: validProjectId, name: 'Test Webhook' })],
200+
{ session }
201+
);
202+
expect(session.endSession).toHaveBeenCalled();
203+
expect(res.status).toHaveBeenCalledWith(201);
204+
});
205+
206+
test('does not create a webhook when the transactional quota reservation fails', async () => {
207+
const session = {
208+
withTransaction: jest.fn(async (operation) => operation()),
209+
endSession: jest.fn(),
210+
};
211+
jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
212+
Project.findOne.mockResolvedValue({ _id: validProjectId });
213+
Project.updateOne.mockResolvedValue({ matchedCount: 1 });
214+
Webhook.countDocuments.mockResolvedValue(3);
215+
createWebhookSchema.safeParse.mockReturnValue({
216+
success: true,
217+
data: {
218+
name: 'Test Webhook',
219+
url: 'https://example.com/hook',
220+
secret: 'whsec_testsecret12345678',
221+
},
222+
});
223+
req.body = {
224+
name: 'Test Webhook',
225+
url: 'https://example.com/hook',
226+
secret: 'whsec_testsecret12345678',
227+
};
228+
req.webhookQuotaLimit = 3;
229+
230+
await createWebhook(req, res, next);
231+
232+
expect(Webhook.create).not.toHaveBeenCalled();
233+
expect(session.endSession).toHaveBeenCalled();
234+
expect(next).toHaveBeenCalledWith(expect.any(AppError));
235+
expect(next.mock.calls[0][0].statusCode).toBe(403);
236+
});
237+
148238
test('returns 404 if project not found', async () => {
149239
Project.findOne.mockResolvedValue(null);
150240

‎apps/dashboard-api/src/controllers/webhook.controller.js‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,48 @@ module.exports.createWebhook = async (req, res, next) => {
4747

4848
// Encrypt the secret
4949
const encryptedSecret = encrypt(secret);
50-
51-
const webhook = await Webhook.create({
50+
const webhookData = {
5251
projectId,
5352
name,
5453
url,
5554
secret: encryptedSecret,
5655
events: events || {},
5756
enabled: enabled !== false,
58-
});
57+
};
58+
59+
let webhook;
60+
const quotaLimit = req.webhookQuotaLimit;
61+
if (Number.isInteger(quotaLimit) && quotaLimit >= 0) {
62+
const session = await mongoose.startSession();
63+
try {
64+
await session.withTransaction(async () => {
65+
// Updating one shared project document serializes concurrent quota
66+
// checks for this project. The update rolls back with the insert.
67+
const reservation = await Project.updateOne(
68+
{ _id: projectId, ...getProjectAccessQuery(req.user._id) },
69+
{ $inc: { webhookQuotaVersion: 1 } },
70+
{ session }
71+
);
72+
if (reservation.matchedCount !== 1) {
73+
throw new AppError(404, "Project not found");
74+
}
75+
76+
const currentCount = await Webhook.countDocuments(
77+
{ projectId },
78+
{ session }
79+
);
80+
if (currentCount >= quotaLimit) {
81+
throw new AppError(403, `Webhook limit reached (${quotaLimit}). Please upgrade your plan for unlimited webhooks.`);
82+
}
83+
84+
[webhook] = await Webhook.create([webhookData], { session });
85+
});
86+
} finally {
87+
await session.endSession();
88+
}
89+
} else {
90+
webhook = await Webhook.create(webhookData);
91+
}
5992

6093
// Return without secret
6194
return new ApiResponse({

‎apps/dashboard-api/src/middlewares/planEnforcement.js‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,15 @@ exports.checkWebhookGate = async function(req, res, next) {
186186
return next(new AppError(403, 'Verify your email to create or test webhooks.'));
187187
}
188188

189+
// Only enforce count limit on new webhook creation (POST without webhookId)
190+
const isCreate = req.method === 'POST' && !req.params.webhookId;
191+
if (!isCreate) {
192+
return next();
193+
}
194+
189195
const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId;
190196
const cleanProjectId = sanitizeObjectId(rawProjectId);
197+
if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId'));
191198

192199
let customLimits = null;
193200
if (cleanProjectId) {
@@ -198,9 +205,9 @@ exports.checkWebhookGate = async function(req, res, next) {
198205
const effectivePlan = resolveEffectivePlan(req.developer);
199206
const limits = getPlanLimits({ plan: effectivePlan, customLimits });
200207

201-
if (limits.webhooksLimit === 0) {
202-
return next(new AppError(403, 'Webhooks are a Pro feature. Please upgrade to create integrations.'));
203-
}
208+
// The controller performs the authoritative quota check and insert in
209+
// one transaction. Passing the limit avoids a count-then-create race.
210+
req.webhookQuotaLimit = limits.webhooksLimit;
204211

205212
next();
206213
} catch (err) {

‎apps/landing/src/pages/pricing.astro‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,27 @@ import Footer from '../components/Footer.astro';
55
import { Check, ChevronDown, HelpCircle, ArrowRight } from 'lucide-react';
66
77
const FREE_FEATURES = [
8-
'1 Project',
9-
'5 Collections per project',
8+
'1 Project, 5 Collections',
109
'2,000 API requests / day',
11-
'10MB File Storage',
1210
'200 Auth Users',
13-
'25 Emails / month',
14-
'Global email templates',
11+
'3 Active Webhooks',
12+
'50 Emails / month (Sends & Broadcasts)',
13+
'Custom HTML Email Templates',
14+
'Bring Your Own MongoDB (BYOM)',
15+
'Bring Your Own Storage (S3 / R2)',
16+
'Bring Your Own Keys (Resend, OAuth, AI)',
17+
'Team Collaboration (up to 2 members)',
18+
'Analytics Pro & Deep Insights',
1519
'Community support',
1620
];
1721
1822
const PRO_FEATURES = [
19-
'10 Projects',
20-
'Unlimited collections',
21-
'Unlimited API requests',
23+
'10 Projects, Unlimited Collections',
24+
'Unlimited API requests (600 req/min burst)',
2225
'Unlimited Auth Users',
23-
'Unlimited Webhooks',
24-
'External Database (BYOM)',
25-
'Bring your own Storage (S3/R2)',
26+
'Unlimited Webhooks + Priority Delivery',
2627
'1,000 Emails / month',
27-
'Custom HTML email templates',
28-
'BYOK — own API keys',
29-
'Analytics Pro',
30-
'AI integrations (OpenAI, Groq)',
28+
'Up to 6 Team Members',
3129
'Priority support',
3230
];
3331
@@ -38,15 +36,15 @@ const PRICING_FAQS = [
3836
},
3937
{
4038
q: 'Can I bring my own MongoDB Atlas cluster on the Free tier?',
41-
a: 'The Free tier includes urBackend managed database storage. Bring Your Own MongoDB (BYOM) is available on the Pro tier during beta.'
39+
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.'
4240
},
4341
{
4442
q: 'How does the free 1-month Pro trial work?',
4543
a: 'Click "Get 1 month Pro for free" to unlock Pro features on your account with zero credit card required.'
4644
},
4745
{
4846
q: 'Do you offer team collaboration?',
49-
a: 'Yes. Pro plans support inviting team members with granular project access controls and role-based permissions.'
47+
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.'
5048
}
5149
];
5250
---

‎apps/public-api/src/controllers/mail.controller.js‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,12 +727,12 @@ module.exports.deleteContact = async (req, res, next) => {
727727
}
728728
};
729729

730-
// --- BROADCASTS (BYOK + Pro Gate) ---
730+
// --- BROADCASTS (BYOK Gate + Quota Tracking) ---
731731

732732
const requireBroadcastGate = async (req) => {
733733
const { resend, usingByok } = await resolveResendClient(req);
734734
if (!usingByok || !req.planLimits?.byokEnabled) {
735-
const err = new Error("Broadcasts require both a BYOK Resend key and a Pro plan.");
735+
const err = new Error("Broadcasts require a configured BYOK Resend key.");
736736
err.statusCode = 403;
737737
throw err;
738738
}
@@ -766,17 +766,34 @@ module.exports.createBroadcast = async (req, res, next) => {
766766
};
767767

768768
module.exports.sendBroadcast = async (req, res, next) => {
769+
let consumedQuotaKey = null;
769770
try {
770771
const { id } = req.params;
771772
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
772773
return next(new AppError(400, "Invalid broadcast ID format."));
773774
}
774775
const resend = await requireBroadcastGate(req);
776+
777+
const projectId = req.project?._id?.toString() || req.project?._id;
778+
if (projectId) {
779+
const limit = getMonthlyMailLimit(req.project, req.planLimits);
780+
const { count, key } = await reserveMonthlyMailSlot(projectId, limit);
781+
consumedQuotaKey = key;
782+
}
783+
775784
const { data, error } = await resend.broadcasts.send(id);
776-
if (error) return next(new AppError(error.statusCode || 500, error.message));
785+
if (error) {
786+
if (consumedQuotaKey) {
787+
await redis.decr(consumedQuotaKey).catch(() => {});
788+
}
789+
return next(new AppError(error.statusCode || 500, error.message));
790+
}
777791

778792
return new ApiResponse(data).send(res, 200);
779793
} catch (err) {
794+
if (consumedQuotaKey) {
795+
await redis.decr(consumedQuotaKey).catch(() => {});
796+
}
780797
return next(new AppError(err.statusCode || 500, err.message));
781798
}
782799
};

0 commit comments

Comments
 (0)