Add billing limit

This commit is contained in:
Dries Augustyns
2025-12-04 19:23:04 +01:00
parent 6cdbc43c8e
commit 5fc5bc68fd
4 changed files with 175 additions and 37 deletions
+124 -4
View File
@@ -41,6 +41,11 @@ export interface LimitCheckResult {
* Billing Limit Service * Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category * 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: * PERFORMANCE CONSIDERATIONS:
* - Operates at scale with 1M+ contacts/month (potentially millions of emails) * - 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 * - 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 { export class BillingLimitService {
private static readonly CACHE_TTL = 300; // 5 minutes private static readonly CACHE_TTL = 300; // 5 minutes
private static readonly WARNING_THRESHOLD = 0.8; // 80% 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<number> {
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 * 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 * Check if sending an email would exceed the billing limit
* Returns detailed result including warning status * 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 projectId - Project ID
* @param sourceType - Email category * @param sourceType - Email category
* @returns LimitCheckResult with allowed/warning status * @returns LimitCheckResult with allowed/warning status
*/ */
public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> { public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> {
try { try {
// Get project billing limits // Get project billing info
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {id: projectId}, where: {id: projectId},
select: { select: {
name: true,
subscription: true,
billingLimitWorkflows: true, billingLimitWorkflows: true,
billingLimitCampaigns: true, billingLimitCampaigns: true,
billingLimitTransactional: 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; let limit: number | null;
switch (sourceType) { switch (sourceType) {
case EmailSourceType.WORKFLOW: case EmailSourceType.WORKFLOW:
@@ -173,7 +263,7 @@ export class BillingLimitService {
limit = null; limit = null;
} }
// If no limit set, allow unlimited // If no limit set for paid tier, allow unlimited
if (limit === null) { if (limit === null) {
return { return {
allowed: true, allowed: true,
@@ -253,15 +343,19 @@ export class BillingLimitService {
* Get complete billing limits and usage for all categories * Get complete billing limits and usage for all categories
* Used for displaying limits in UI * 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 * @param projectId - Project ID
* @returns Complete billing limits and usage information * @returns Complete billing limits and usage information
*/ */
public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> { public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> {
try { try {
// Get project limits // Get project info
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {id: projectId}, where: {id: projectId},
select: { select: {
subscription: true,
billingLimitWorkflows: true, billingLimitWorkflows: true,
billingLimitCampaigns: true, billingLimitCampaigns: true,
billingLimitTransactional: 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 { return {
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows), workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns), campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
+41 -30
View File
@@ -29,17 +29,18 @@ import {network} from '../lib/network';
interface BillingLimitsProps { interface BillingLimitsProps {
projectId: string; projectId: string;
hasSubscription: boolean; hasSubscription: boolean;
billingEnabled: boolean;
} }
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>; type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) { export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch billing limits using SWR // Fetch billing limits using SWR (fetch for both free and paid tiers when billing is enabled)
const {limitsData, isLoading, mutate} = useBillingLimits(projectId, hasSubscription); const {limitsData, isLoading, mutate} = useBillingLimits(projectId, billingEnabled);
const form = useForm<LimitsFormValues>({ const form = useForm<LimitsFormValues>({
resolver: zodResolver(BillingLimitSchemas.update), resolver: zodResolver(BillingLimitSchemas.update),
@@ -96,26 +97,12 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps)
setErrorMessage(null); setErrorMessage(null);
}; };
if (!hasSubscription) { // Free tier projects can view their usage but can't edit limits
return ( const canEditLimits = hasSubscription;
<Card>
<CardHeader> // If billing is not enabled, don't show the component
<CardTitle>Billing Limits</CardTitle> if (!billingEnabled) {
<CardDescription>Set monthly limits for each email category</CardDescription> return null;
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
Billing limits are only available with an active subscription. Start a subscription to set limits for
your email usage.
</p>
</div>
</Alert>
</CardContent>
</Card>
);
} }
if (isLoading) { if (isLoading) {
@@ -137,11 +124,26 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps)
<CardHeader> <CardHeader>
<CardTitle>Billing Limits</CardTitle> <CardTitle>Billing Limits</CardTitle>
<CardDescription> <CardDescription>
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.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-6">
{/* Free tier info banner */}
{!hasSubscription && limitsData && (
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
unlimited emails or custom limits.
</p>
</div>
</Alert>
)}
{/* Success/Error Messages */} {/* Success/Error Messages */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{successMessage && ( {successMessage && (
@@ -169,13 +171,22 @@ export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps)
{/* Usage Display (when not editing) */} {/* Usage Display (when not editing) */}
{!isEditing && limitsData && ( {!isEditing && limitsData && (
<div className="space-y-4"> <div className="space-y-4">
<UsageDisplay category="Workflows" usage={limitsData.workflows} /> {/* For free tier, show total usage across all categories */}
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} /> {!hasSubscription ? (
<UsageDisplay category="Transactional" usage={limitsData.transactional} /> <UsageDisplay category="Total Emails (All Categories)" usage={limitsData.workflows} />
) : (
<>
<UsageDisplay category="Workflows" usage={limitsData.workflows} />
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
</>
)}
<div className="flex justify-end pt-4"> {canEditLimits && (
<Button onClick={() => setIsEditing(true)}>Edit Limits</Button> <div className="flex justify-end pt-4">
</div> <Button onClick={() => setIsEditing(true)}>Edit Limits</Button>
</div>
)}
</div> </div>
)} )}
+5 -2
View File
@@ -16,10 +16,13 @@ export interface BillingLimitsData {
/** /**
* Hook to fetch billing limits for a project * 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<BillingLimitsData>( const {data, error, mutate, isLoading} = useSWR<BillingLimitsData>(
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-limits` : null, projectId && billingEnabled ? `/users/@me/projects/${projectId}/billing-limits` : null,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated
+5 -1
View File
@@ -639,7 +639,11 @@ export default function Settings() {
</Card> </Card>
{/* Billing Limits */} {/* Billing Limits */}
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} /> <BillingLimits
projectId={activeProject.id}
hasSubscription={!!activeProject.subscription}
billingEnabled={billingEnabled}
/>
{/* Current Month Consumption */} {/* Current Month Consumption */}
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} /> <BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />