diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 61dcfe3..08a2062 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -196,58 +196,7 @@ export class BillingLimitService { }; } - // Free tier projects (no subscription): enforce total 1000 email/month limit - 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 + // Determine which limit to use based on source type let limit: number | null; switch (sourceType) { case EmailSourceType.WORKFLOW: @@ -263,6 +212,63 @@ export class BillingLimitService { 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 (limit === null) { return { @@ -385,8 +391,14 @@ export class BillingLimitService { }; }; - // Free tier projects: show total usage with shared limit - if (!project.subscription) { + // 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): show total usage with shared limit + if (!project.subscription && !hasCustomLimits) { const totalUsage = workflowUsage + campaignUsage + transactionalUsage; const limit = this.FREE_TIER_TOTAL_LIMIT; 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 { workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows), campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns), diff --git a/apps/api/src/services/__tests__/BillingLimitService.test.ts b/apps/api/src/services/__tests__/BillingLimitService.test.ts index 2e1e20c..19adbe8 100644 --- a/apps/api/src/services/__tests__/BillingLimitService.test.ts +++ b/apps/api/src/services/__tests__/BillingLimitService.test.ts @@ -110,7 +110,11 @@ describe('BillingLimitService - Critical Enforcement', () => { it('should ALLOW emails when limit is null (unlimited)', async () => { await prisma.project.update({ 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 @@ -420,4 +424,336 @@ describe('BillingLimitService - Critical Enforcement', () => { 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: 'test@example.com', + }), + ).rejects.toThrow(/free tier limit/i); + + await expect( + EmailService.sendCampaignEmail({ + projectId, + contactId: contact.id, + subject: 'Test', + body: 'Test', + from: 'test@example.com', + }), + ).rejects.toThrow(/free tier limit/i); + + await expect( + EmailService.sendTransactionalEmail({ + projectId, + contactId: contact.id, + subject: 'Test', + body: 'Test', + from: 'test@example.com', + }), + ).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: 'test@example.com', + }); + + 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); + }); + }); });