Update billing limit tests
This commit is contained in:
@@ -196,58 +196,7 @@ export class BillingLimitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free tier projects (no subscription): enforce total 1000 email/month limit
|
// Determine which limit to use based on source type
|
||||||
if (!project.subscription) {
|
|
||||||
const totalUsage = await this.getTotalUsage(projectId);
|
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
|
||||||
const percentage = (totalUsage / limit) * 100;
|
|
||||||
|
|
||||||
// Check if blocked (at or over limit)
|
|
||||||
if (totalUsage >= limit) {
|
|
||||||
await NtfyService.notifyBillingLimitExceeded(
|
|
||||||
project.name,
|
|
||||||
projectId,
|
|
||||||
totalUsage,
|
|
||||||
limit,
|
|
||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
warning: false,
|
|
||||||
usage: totalUsage,
|
|
||||||
limit,
|
|
||||||
percentage,
|
|
||||||
message: `Free tier limit reached. You've sent ${totalUsage}/${limit} emails this month. Upgrade to continue sending.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if warning (80% or more)
|
|
||||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
|
||||||
if (isWarning) {
|
|
||||||
await NtfyService.notifyBillingLimitApproaching(
|
|
||||||
project.name,
|
|
||||||
projectId,
|
|
||||||
totalUsage,
|
|
||||||
limit,
|
|
||||||
percentage,
|
|
||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
warning: isWarning,
|
|
||||||
usage: totalUsage,
|
|
||||||
limit,
|
|
||||||
percentage,
|
|
||||||
message: isWarning
|
|
||||||
? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${limit} emails)`
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paid tier projects (with subscription): check per-category limits if set
|
|
||||||
let limit: number | null;
|
let limit: number | null;
|
||||||
switch (sourceType) {
|
switch (sourceType) {
|
||||||
case EmailSourceType.WORKFLOW:
|
case EmailSourceType.WORKFLOW:
|
||||||
@@ -263,6 +212,63 @@ export class BillingLimitService {
|
|||||||
limit = null;
|
limit = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if any custom per-category limits are set
|
||||||
|
const hasCustomLimits =
|
||||||
|
project.billingLimitWorkflows !== null ||
|
||||||
|
project.billingLimitCampaigns !== null ||
|
||||||
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
|
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||||
|
if (!project.subscription && !hasCustomLimits) {
|
||||||
|
const totalUsage = await this.getTotalUsage(projectId);
|
||||||
|
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
|
const percentage = (totalUsage / freeLimit) * 100;
|
||||||
|
|
||||||
|
// Check if blocked (at or over limit)
|
||||||
|
if (totalUsage >= freeLimit) {
|
||||||
|
await NtfyService.notifyBillingLimitExceeded(
|
||||||
|
project.name,
|
||||||
|
projectId,
|
||||||
|
totalUsage,
|
||||||
|
freeLimit,
|
||||||
|
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
warning: false,
|
||||||
|
usage: totalUsage,
|
||||||
|
limit: freeLimit,
|
||||||
|
percentage,
|
||||||
|
message: `Free tier limit reached. You've sent ${totalUsage}/${freeLimit} emails this month. Upgrade to continue sending.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if warning (80% or more)
|
||||||
|
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||||
|
if (isWarning) {
|
||||||
|
await NtfyService.notifyBillingLimitApproaching(
|
||||||
|
project.name,
|
||||||
|
projectId,
|
||||||
|
totalUsage,
|
||||||
|
freeLimit,
|
||||||
|
percentage,
|
||||||
|
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
warning: isWarning,
|
||||||
|
usage: totalUsage,
|
||||||
|
limit: freeLimit,
|
||||||
|
percentage,
|
||||||
|
message: isWarning
|
||||||
|
? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${freeLimit} emails)`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// If no limit set for paid tier, allow unlimited
|
// If no limit set for paid tier, allow unlimited
|
||||||
if (limit === null) {
|
if (limit === null) {
|
||||||
return {
|
return {
|
||||||
@@ -385,8 +391,14 @@ export class BillingLimitService {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Free tier projects: show total usage with shared limit
|
// Check if any custom per-category limits are set
|
||||||
if (!project.subscription) {
|
const hasCustomLimits =
|
||||||
|
project.billingLimitWorkflows !== null ||
|
||||||
|
project.billingLimitCampaigns !== null ||
|
||||||
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
|
if (!project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
@@ -410,7 +422,7 @@ export class BillingLimitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paid tier projects: show per-category limits
|
// Projects with subscription or custom limits: show per-category limits
|
||||||
return {
|
return {
|
||||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ describe('BillingLimitService - Critical Enforcement', () => {
|
|||||||
it('should ALLOW emails when limit is null (unlimited)', async () => {
|
it('should ALLOW emails when limit is null (unlimited)', async () => {
|
||||||
await prisma.project.update({
|
await prisma.project.update({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
data: {billingLimitTransactional: null},
|
data: {
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
// Set one other limit to trigger per-category mode (not free tier)
|
||||||
|
billingLimitCampaigns: 1000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create 100 emails
|
// Create 100 emails
|
||||||
@@ -420,4 +424,336 @@ describe('BillingLimitService - Critical Enforcement', () => {
|
|||||||
expect(totalEmails).toBeLessThanOrEqual(15);
|
expect(totalEmails).toBeLessThanOrEqual(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Free Tier Total Limit (1000 emails/month across all types)', () => {
|
||||||
|
it('should enforce 1000 email total limit for free tier projects', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Free tier project has no subscription and no custom limits set
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: null,
|
||||||
|
billingLimitCampaigns: null,
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 1000 emails total across different types
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.CAMPAIGN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// All three types should now be blocked since total is 1000
|
||||||
|
await expect(
|
||||||
|
EmailService.sendWorkflowEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
from: '[email protected]',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/free tier limit/i);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmailService.sendCampaignEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
from: '[email protected]',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/free tier limit/i);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmailService.sendTransactionalEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
from: '[email protected]',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/free tier limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count all email types towards free tier total limit', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Free tier project
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: null,
|
||||||
|
billingLimitCampaigns: null,
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 200 of each type (600 total)
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.CAMPAIGN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// Check limits for each type - all should report 600 total usage
|
||||||
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||||
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||||
|
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
||||||
|
|
||||||
|
// All types should see the same total usage (600) and same limit (1000)
|
||||||
|
expect(workflowCheck.usage).toBe(600);
|
||||||
|
expect(workflowCheck.limit).toBe(1000);
|
||||||
|
expect(workflowCheck.allowed).toBe(true);
|
||||||
|
expect(workflowCheck.percentage).toBe(60);
|
||||||
|
|
||||||
|
expect(campaignCheck.usage).toBe(600);
|
||||||
|
expect(campaignCheck.limit).toBe(1000);
|
||||||
|
expect(campaignCheck.allowed).toBe(true);
|
||||||
|
|
||||||
|
expect(transactionalCheck.usage).toBe(600);
|
||||||
|
expect(transactionalCheck.limit).toBe(1000);
|
||||||
|
expect(transactionalCheck.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show warning at 80% of free tier total limit (800 emails)', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Free tier project
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: null,
|
||||||
|
billingLimitCampaigns: null,
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 800 emails spread across types
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 250; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.CAMPAIGN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 250; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// All types should show warning
|
||||||
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||||
|
expect(workflowCheck.allowed).toBe(true);
|
||||||
|
expect(workflowCheck.warning).toBe(true);
|
||||||
|
expect(workflowCheck.usage).toBe(800);
|
||||||
|
expect(workflowCheck.percentage).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(workflowCheck.message).toMatch(/warning/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow free tier to send different distribution of email types', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Free tier project
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: null,
|
||||||
|
billingLimitCampaigns: null,
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 900 transactional, 50 campaigns, 49 workflows (999 total - under limit)
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.CAMPAIGN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 49; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// Should still allow one more email of any type
|
||||||
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||||
|
expect(workflowCheck.allowed).toBe(true);
|
||||||
|
expect(workflowCheck.usage).toBe(999);
|
||||||
|
|
||||||
|
// Send the 1000th email (should succeed)
|
||||||
|
await EmailService.sendWorkflowEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
from: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// Now all types should be blocked
|
||||||
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||||
|
expect(campaignCheck.allowed).toBe(false);
|
||||||
|
expect(campaignCheck.usage).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should distinguish free tier from paid tier with per-category limits', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Set custom per-category limits (paid tier behavior)
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: 100,
|
||||||
|
billingLimitCampaigns: 200,
|
||||||
|
billingLimitTransactional: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 150 workflow emails (exceeds workflow limit but under total)
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
// Workflow should be blocked (150 > 100)
|
||||||
|
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||||
|
expect(workflowCheck.allowed).toBe(false);
|
||||||
|
expect(workflowCheck.usage).toBe(150);
|
||||||
|
expect(workflowCheck.limit).toBe(100);
|
||||||
|
|
||||||
|
// But campaigns should still be allowed (independent limit)
|
||||||
|
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||||
|
expect(campaignCheck.allowed).toBe(true);
|
||||||
|
expect(campaignCheck.usage).toBe(0);
|
||||||
|
expect(campaignCheck.limit).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show shared limit for all categories in getLimitsAndUsage for free tier', async () => {
|
||||||
|
// Create a contact for this test
|
||||||
|
const contact = await factories.createContact({projectId});
|
||||||
|
|
||||||
|
// Free tier project
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {
|
||||||
|
billingLimitWorkflows: null,
|
||||||
|
billingLimitCampaigns: null,
|
||||||
|
billingLimitTransactional: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create various emails
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.WORKFLOW,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.CAMPAIGN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await BillingLimitService.invalidateCache(projectId);
|
||||||
|
|
||||||
|
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
|
||||||
|
|
||||||
|
// All categories should show the same total usage (450) and limit (1000)
|
||||||
|
expect(limits.workflows.limit).toBe(1000);
|
||||||
|
expect(limits.workflows.usage).toBe(450);
|
||||||
|
expect(limits.workflows.percentage).toBe(45);
|
||||||
|
|
||||||
|
expect(limits.campaigns.limit).toBe(1000);
|
||||||
|
expect(limits.campaigns.usage).toBe(450);
|
||||||
|
|
||||||
|
expect(limits.transactional.limit).toBe(1000);
|
||||||
|
expect(limits.transactional.usage).toBe(450);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user