Add billing limit
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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'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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user