diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 22e7cd7..61dcfe3 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -41,6 +41,11 @@ export interface LimitCheckResult { * Billing Limit Service * Handles usage tracking and enforcement of billing limits per email category * + * FREE TIER LIMITS: + * - Free tier projects (billing enabled, no subscription) have a total limit of 1000 emails/month + * - This limit is shared across all email types (workflows + campaigns + transactional) + * - Paid tier projects (with subscription) can have custom per-category limits or unlimited + * * PERFORMANCE CONSIDERATIONS: * - Operates at scale with 1M+ contacts/month (potentially millions of emails) * - Uses Redis caching (5-min TTL) to avoid expensive DB queries on every email send @@ -51,6 +56,35 @@ export interface LimitCheckResult { export class BillingLimitService { private static readonly CACHE_TTL = 300; // 5 minutes private static readonly WARNING_THRESHOLD = 0.8; // 80% + private static readonly FREE_TIER_TOTAL_LIMIT = 1000; // Total emails per month for free tier projects + + /** + * Get total usage count across all email categories + * Used for free tier total limit enforcement + * + * @param projectId - Project ID + * @returns Total email count for the calendar month (all types combined) + */ + public static async getTotalUsage(projectId: string): Promise { + const {start, end} = this.getCurrentMonthRange(); + + try { + const count = await prisma.email.count({ + where: { + projectId, + createdAt: { + gte: start, + lt: end, + }, + }, + }); + + return count; + } catch (error) { + signale.error(`[BILLING_LIMIT] Failed to query total usage for ${projectId}:`, error); + return 0; // Return 0 on error to avoid blocking + } + } /** * Get current usage count for a specific email category @@ -130,16 +164,21 @@ export class BillingLimitService { * Check if sending an email would exceed the billing limit * Returns detailed result including warning status * + * For free tier projects (no subscription): checks total usage across all categories against 1000/month limit + * For paid tier projects (with subscription): checks per-category limits if set + * * @param projectId - Project ID * @param sourceType - Email category * @returns LimitCheckResult with allowed/warning status */ public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise { try { - // Get project billing limits + // Get project billing info const project = await prisma.project.findUnique({ where: {id: projectId}, select: { + name: true, + subscription: true, billingLimitWorkflows: true, billingLimitCampaigns: true, billingLimitTransactional: true, @@ -157,7 +196,58 @@ export class BillingLimitService { }; } - // Get the limit for this source type + // 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 let limit: number | null; switch (sourceType) { case EmailSourceType.WORKFLOW: @@ -173,7 +263,7 @@ export class BillingLimitService { limit = null; } - // If no limit set, allow unlimited + // If no limit set for paid tier, allow unlimited if (limit === null) { return { allowed: true, @@ -253,15 +343,19 @@ export class BillingLimitService { * Get complete billing limits and usage for all categories * Used for displaying limits in UI * + * For free tier projects: shows total usage across all categories with 1000/month limit + * For paid tier projects: shows per-category usage with custom limits + * * @param projectId - Project ID * @returns Complete billing limits and usage information */ public static async getLimitsAndUsage(projectId: string): Promise { try { - // Get project limits + // Get project info const project = await prisma.project.findUnique({ where: {id: projectId}, select: { + subscription: true, billingLimitWorkflows: true, billingLimitCampaigns: true, billingLimitTransactional: true, @@ -291,6 +385,32 @@ export class BillingLimitService { }; }; + // Free tier projects: show total usage with shared limit + if (!project.subscription) { + const totalUsage = workflowUsage + campaignUsage + transactionalUsage; + const limit = this.FREE_TIER_TOTAL_LIMIT; + const percentage = (totalUsage / limit) * 100; + const isWarning = percentage >= this.WARNING_THRESHOLD * 100; + const isBlocked = totalUsage >= limit; + + // For free tier, show the same limit and total usage for all three categories + // This makes it clear in the UI that it's a shared limit + const sharedUsageInfo: CategoryUsage = { + limit, + usage: totalUsage, + percentage, + isWarning, + isBlocked, + }; + + return { + workflows: sharedUsageInfo, + campaigns: sharedUsageInfo, + transactional: sharedUsageInfo, + }; + } + + // Paid tier projects: show per-category limits return { workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows), campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns), diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index 0736307..7b6b430 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -29,17 +29,18 @@ import {network} from '../lib/network'; interface BillingLimitsProps { projectId: string; hasSubscription: boolean; + billingEnabled: boolean; } type LimitsFormValues = z.infer; -export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) { +export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) { const [isEditing, setIsEditing] = useState(false); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - // Fetch billing limits using SWR - const {limitsData, isLoading, mutate} = useBillingLimits(projectId, hasSubscription); + // Fetch billing limits using SWR (fetch for both free and paid tiers when billing is enabled) + const {limitsData, isLoading, mutate} = useBillingLimits(projectId, billingEnabled); const form = useForm({ resolver: zodResolver(BillingLimitSchemas.update), @@ -96,26 +97,12 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) setErrorMessage(null); }; - if (!hasSubscription) { - return ( - - - Billing Limits - Set monthly limits for each email category - - - - -
-

- Billing limits are only available with an active subscription. Start a subscription to set limits for - your email usage. -

-
-
-
-
- ); + // Free tier projects can view their usage but can't edit limits + const canEditLimits = hasSubscription; + + // If billing is not enabled, don't show the component + if (!billingEnabled) { + return null; } if (isLoading) { @@ -137,11 +124,26 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) Billing Limits - Set monthly limits for each email category. Limits reset on the 1st of each month. + {hasSubscription + ? 'Set monthly limits for each email category. Limits reset on the 1st of each month.' + : 'Free tier projects have a total limit of 1,000 emails per month across all categories.'}
+ {/* Free tier info banner */} + {!hasSubscription && limitsData && ( + + +
+

+ You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for + unlimited emails or custom limits. +

+
+
+ )} + {/* Success/Error Messages */} {successMessage && ( @@ -169,13 +171,22 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) {/* Usage Display (when not editing) */} {!isEditing && limitsData && (
- - - + {/* For free tier, show total usage across all categories */} + {!hasSubscription ? ( + + ) : ( + <> + + + + + )} -
- -
+ {canEditLimits && ( +
+ +
+ )}
)} diff --git a/apps/web/src/lib/hooks/useBillingLimits.ts b/apps/web/src/lib/hooks/useBillingLimits.ts index 7851b7c..7b02cd5 100644 --- a/apps/web/src/lib/hooks/useBillingLimits.ts +++ b/apps/web/src/lib/hooks/useBillingLimits.ts @@ -16,10 +16,13 @@ export interface BillingLimitsData { /** * Hook to fetch billing limits for a project + * + * Free tier projects (no subscription): Shows total usage with 1000/month limit + * Paid tier projects (with subscription): Shows per-category usage with custom limits */ -export function useBillingLimits(projectId: string | undefined, hasSubscription: boolean) { +export function useBillingLimits(projectId: string | undefined, billingEnabled: boolean) { const {data, error, mutate, isLoading} = useSWR( - projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-limits` : null, + projectId && billingEnabled ? `/users/@me/projects/${projectId}/billing-limits` : null, { revalidateOnFocus: false, refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 35c1dc6..ca49552 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -639,7 +639,11 @@ export default function Settings() { {/* Billing Limits */} - + {/* Current Month Consumption */}