fix: prevent scheduling of campaign if billing limit reached

This commit is contained in:
Dries Augustyns
2025-12-09 18:50:05 +01:00
parent 04275e3cf5
commit a3bbb1ed64
2 changed files with 244 additions and 2 deletions
+21 -2
View File
@@ -1,17 +1,18 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
@@ -324,6 +325,24 @@ export class CampaignService {
throw new HttpException(400, 'Campaign has no recipients');
}
// Check billing limits before scheduling/sending the campaign
// This ensures users cannot schedule campaigns that would exceed their quota
if (STRIPE_ENABLED) {
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
// If there's a limit set, verify the campaign won't exceed it
if (limitCheck.limit !== null) {
const projectedUsage = limitCheck.usage + recipientCount;
if (projectedUsage > limitCheck.limit) {
throw new HttpException(
403,
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
);
}
}
}
if (scheduledFor) {
// Schedule for later
if (scheduledFor.getTime() <= Date.now()) {
@@ -356,4 +356,227 @@ describe('CampaignService', () => {
expect(matching).toBe(5);
});
});
describe('send', () => {
it('should throw error when campaign has no recipients', async () => {
// Create campaign with no contacts in project
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
});
it('should send campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Create 5 subscribed contacts
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
// Create campaign targeting all contacts
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (5 recipients < 100 limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw 403 error when campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails (usage = 5)
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 15 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
});
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 20
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 20},
});
// Create 15 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 15; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 25 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
/Cannot schedule campaign.*exceed billing limit/i,
);
});
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
// Set billing limit to null (unlimited)
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: null},
});
// Create 1000 subscribed contacts
for (let i = 0; i < 1000; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (no limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(1000);
});
it('should allow campaign that exactly reaches billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
for (let i = 0; i < 4; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully because 5 + 5 = 10 (exactly at limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw error when campaign has already been sent', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
'Campaign has already been sent or is currently sending',
);
});
it('should schedule campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 50},
});
// Create 10 subscribed contacts
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should schedule successfully (10 recipients < 50 limit)
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
expect(scheduledCampaign.totalRecipients).toBe(10);
});
});
});