697 lines
23 KiB
TypeScript
697 lines
23 KiB
TypeScript
import {EmailSourceType} from '@plunk/db';
|
|
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
|
import React from 'react';
|
|
import signale from 'signale';
|
|
|
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
|
import {stripe} from '../app/stripe.js';
|
|
import {prisma} from '../database/prisma.js';
|
|
import {redis} from '../database/redis.js';
|
|
import {Keys} from './keys.js';
|
|
import {MembershipService} from './MembershipService.js';
|
|
import {NtfyService} from './NtfyService.js';
|
|
|
|
/**
|
|
* Usage information for a specific email category
|
|
*/
|
|
export interface CategoryUsage {
|
|
limit: number | null; // null = unlimited
|
|
usage: number;
|
|
percentage: number; // 0-100
|
|
isWarning: boolean; // true if >= 80%
|
|
isBlocked: boolean; // true if >= 100%
|
|
}
|
|
|
|
/**
|
|
* Complete billing limits and usage for a project
|
|
*/
|
|
export interface BillingLimitsResponse {
|
|
workflows: CategoryUsage;
|
|
campaigns: CategoryUsage;
|
|
transactional: CategoryUsage;
|
|
currency: string | null;
|
|
}
|
|
|
|
/**
|
|
* Result of limit check
|
|
*/
|
|
export interface LimitCheckResult {
|
|
allowed: boolean;
|
|
warning: boolean; // true if >= 80% but < 100%
|
|
usage: number;
|
|
limit: number | null;
|
|
percentage: number;
|
|
message?: string;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* - Composite index on (projectId, sourceType, createdAt) enables fast filtered counts
|
|
* - Graceful degradation: cache misses fall back to DB without blocking
|
|
* - Non-blocking: errors are logged but don't prevent email sending
|
|
*/
|
|
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<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
|
|
* Uses Redis cache with 5-minute TTL to minimize DB queries
|
|
*
|
|
* @param projectId - Project ID
|
|
* @param sourceType - Email category (TRANSACTIONAL, CAMPAIGN, WORKFLOW)
|
|
* @returns Current usage count for the calendar month
|
|
*/
|
|
public static async getUsage(projectId: string, sourceType: EmailSourceType): Promise<number> {
|
|
const cacheKey = this.getCacheKey(projectId, sourceType);
|
|
|
|
try {
|
|
// Try to get from cache first
|
|
const cached = await redis.get(cacheKey);
|
|
if (cached !== null) {
|
|
return parseInt(cached, 10);
|
|
}
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Cache read failed for ${cacheKey}:`, error);
|
|
// Continue to DB query on cache failure
|
|
}
|
|
|
|
// Cache miss - query database
|
|
const {start, end} = this.getCurrentMonthRange();
|
|
|
|
try {
|
|
const count = await prisma.email.count({
|
|
where: {
|
|
projectId,
|
|
sourceType,
|
|
createdAt: {
|
|
gte: start,
|
|
lt: end,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Cache the result
|
|
try {
|
|
await redis.setex(cacheKey, this.CACHE_TTL, count.toString());
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Failed to cache usage for ${cacheKey}:`, error);
|
|
}
|
|
|
|
return count;
|
|
} catch (error) {
|
|
signale.error(`[BILLING_LIMIT] Failed to query usage for ${projectId}/${sourceType}:`, error);
|
|
return 0; // Return 0 on error to avoid blocking
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment usage counter in cache (called after successful email send)
|
|
* This keeps the cache accurate without requiring frequent DB queries
|
|
*
|
|
* @param projectId - Project ID
|
|
* @param sourceType - Email category
|
|
*/
|
|
public static async incrementUsage(projectId: string, sourceType: EmailSourceType): Promise<void> {
|
|
const cacheKey = this.getCacheKey(projectId, sourceType);
|
|
|
|
try {
|
|
const exists = await redis.exists(cacheKey);
|
|
if (exists) {
|
|
// Only increment if key exists (was previously cached)
|
|
await redis.incr(cacheKey);
|
|
}
|
|
// If not cached, next getUsage call will fetch from DB
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Failed to increment usage cache for ${cacheKey}:`, error);
|
|
// Non-blocking: continue even if cache increment fails
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<LimitCheckResult> {
|
|
try {
|
|
// Get project billing info
|
|
const project = await prisma.project.findUnique({
|
|
where: {id: projectId},
|
|
select: {
|
|
name: true,
|
|
subscription: true,
|
|
billingLimitWorkflows: true,
|
|
billingLimitCampaigns: true,
|
|
billingLimitTransactional: true,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
signale.warn(`[BILLING_LIMIT] Project ${projectId} not found`);
|
|
return {
|
|
allowed: true,
|
|
warning: false,
|
|
usage: 0,
|
|
limit: null,
|
|
percentage: 0,
|
|
};
|
|
}
|
|
|
|
// Determine which limit to use based on source type
|
|
let limit: number | null;
|
|
switch (sourceType) {
|
|
case EmailSourceType.WORKFLOW:
|
|
limit = project.billingLimitWorkflows;
|
|
break;
|
|
case EmailSourceType.CAMPAIGN:
|
|
limit = project.billingLimitCampaigns;
|
|
break;
|
|
case EmailSourceType.TRANSACTIONAL:
|
|
limit = project.billingLimitTransactional;
|
|
break;
|
|
default:
|
|
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
|
|
// Only enforce free tier limits if billing is enabled
|
|
if (STRIPE_ENABLED && !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
|
|
);
|
|
|
|
// Send email notification
|
|
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
|
|
|
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
|
|
);
|
|
|
|
// Send email notification (only once per month)
|
|
await this.sendWarningEmail(
|
|
projectId,
|
|
project.name,
|
|
totalUsage,
|
|
freeLimit,
|
|
percentage,
|
|
'Free Tier (All Types)',
|
|
);
|
|
}
|
|
|
|
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 {
|
|
allowed: true,
|
|
warning: false,
|
|
usage: 0,
|
|
limit: null,
|
|
percentage: 0,
|
|
};
|
|
}
|
|
|
|
// Get current usage
|
|
const usage = await this.getUsage(projectId, sourceType);
|
|
const percentage = limit > 0 ? (usage / limit) * 100 : 0;
|
|
|
|
// Check if blocked (at or over limit)
|
|
if (usage >= limit) {
|
|
// Get project name for notification
|
|
const project = await prisma.project.findUnique({
|
|
where: {id: projectId},
|
|
select: {name: true},
|
|
});
|
|
|
|
if (project) {
|
|
// Send notification about limit exceeded
|
|
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
|
|
|
// Send email notification
|
|
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
|
}
|
|
|
|
return {
|
|
allowed: false,
|
|
warning: false,
|
|
usage,
|
|
limit,
|
|
percentage,
|
|
message: `Billing limit reached for ${sourceType.toLowerCase()} emails. Current usage: ${usage}/${limit} (${Math.round(percentage)}%)`,
|
|
};
|
|
}
|
|
|
|
// Check if warning (80% or more)
|
|
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
|
|
|
// Send notification for warning threshold
|
|
if (isWarning) {
|
|
const project = await prisma.project.findUnique({
|
|
where: {id: projectId},
|
|
select: {name: true},
|
|
});
|
|
|
|
if (project) {
|
|
await NtfyService.notifyBillingLimitApproaching(
|
|
project.name,
|
|
projectId,
|
|
usage,
|
|
limit,
|
|
percentage,
|
|
sourceType,
|
|
);
|
|
|
|
// Send email notification (only once per month)
|
|
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
|
|
}
|
|
}
|
|
|
|
return {
|
|
allowed: true,
|
|
warning: isWarning,
|
|
usage,
|
|
limit,
|
|
percentage,
|
|
message: isWarning
|
|
? `Warning: ${sourceType.toLowerCase()} emails at ${Math.round(percentage)}% of limit (${usage}/${limit})`
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
signale.error(`[BILLING_LIMIT] Error checking limit for ${projectId}/${sourceType}:`, error);
|
|
// On error, allow the email to prevent service disruption
|
|
return {
|
|
allowed: true,
|
|
warning: false,
|
|
usage: 0,
|
|
limit: null,
|
|
percentage: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<BillingLimitsResponse> {
|
|
try {
|
|
// Get project info
|
|
const project = await prisma.project.findUnique({
|
|
where: {id: projectId},
|
|
select: {
|
|
customer: true,
|
|
subscription: true,
|
|
billingLimitWorkflows: true,
|
|
billingLimitCampaigns: true,
|
|
billingLimitTransactional: true,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
throw new Error('Project not found');
|
|
}
|
|
|
|
// Get currency from Stripe customer if available
|
|
let currency: string | null = null;
|
|
if (stripe && project.customer) {
|
|
try {
|
|
const customer = await stripe.customers.retrieve(project.customer);
|
|
if (!customer.deleted) {
|
|
currency = customer.currency || 'usd';
|
|
}
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Failed to fetch currency for customer ${project.customer}:`, error);
|
|
// Continue without currency - will be null in response
|
|
}
|
|
}
|
|
|
|
// Get usage for all categories in parallel
|
|
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
|
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
|
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
|
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
|
]);
|
|
|
|
// Helper to calculate category usage
|
|
const calculateCategoryUsage = (usage: number, limit: number | null): CategoryUsage => {
|
|
const percentage = limit !== null && limit > 0 ? (usage / limit) * 100 : 0;
|
|
return {
|
|
limit,
|
|
usage,
|
|
percentage,
|
|
isWarning: limit !== null && percentage >= this.WARNING_THRESHOLD * 100,
|
|
isBlocked: limit !== null && usage >= limit,
|
|
};
|
|
};
|
|
|
|
// 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
|
|
// Only show free tier limits if billing is enabled
|
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
|
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,
|
|
currency,
|
|
};
|
|
}
|
|
|
|
// Projects with subscription or custom limits: show per-category limits
|
|
return {
|
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
|
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
|
currency,
|
|
};
|
|
} catch (error) {
|
|
signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalidate usage cache for a project
|
|
* Primarily used in tests to reset cache state between scenarios.
|
|
* In production, the cache naturally expires after 5 minutes.
|
|
*
|
|
* NOTE: Updating billing limits does NOT require clearing usage cache
|
|
* (use clearNotificationCacheForChangedLimits instead)
|
|
*
|
|
* @param projectId - Project ID
|
|
*/
|
|
public static async invalidateCache(projectId: string): Promise<void> {
|
|
try {
|
|
const keys = [
|
|
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
|
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
|
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
|
];
|
|
|
|
await Promise.all(keys.map(key => redis.del(key)));
|
|
signale.debug(`[BILLING_LIMIT] Invalidated cache for project ${projectId}`);
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Failed to invalidate cache for ${projectId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear notification cache keys for billing limits that have changed
|
|
* This allows new warning/limit emails to be sent when updated limits are reached
|
|
*
|
|
* @param projectId - Project ID
|
|
* @param oldLimits - Previous billing limits
|
|
* @param newLimits - New billing limits
|
|
*/
|
|
public static async clearNotificationCacheForChangedLimits(
|
|
projectId: string,
|
|
oldLimits: {
|
|
workflows: number | null;
|
|
campaigns: number | null;
|
|
transactional: number | null;
|
|
},
|
|
newLimits: {
|
|
workflows: number | null;
|
|
campaigns: number | null;
|
|
transactional: number | null;
|
|
},
|
|
): Promise<void> {
|
|
try {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
|
|
const keysToDelete: string[] = [];
|
|
|
|
// Check workflows limit
|
|
if (oldLimits.workflows !== newLimits.workflows) {
|
|
keysToDelete.push(
|
|
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
|
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
|
);
|
|
}
|
|
|
|
// Check campaigns limit
|
|
if (oldLimits.campaigns !== newLimits.campaigns) {
|
|
keysToDelete.push(
|
|
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
|
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
|
);
|
|
}
|
|
|
|
// Check transactional limit
|
|
if (oldLimits.transactional !== newLimits.transactional) {
|
|
keysToDelete.push(
|
|
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
|
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
|
);
|
|
}
|
|
|
|
if (keysToDelete.length > 0) {
|
|
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
|
signale.debug(
|
|
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Redis cache key for usage count
|
|
*/
|
|
private static getCacheKey(projectId: string, sourceType: EmailSourceType): string {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
return Keys.Billing.usage(projectId, sourceType, year, month);
|
|
}
|
|
|
|
/**
|
|
* Get start and end dates for current calendar month
|
|
*/
|
|
private static getCurrentMonthRange(): {start: Date; end: Date} {
|
|
const now = new Date();
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
return {start, end};
|
|
}
|
|
|
|
/**
|
|
* Send billing limit warning email to project members
|
|
*/
|
|
private static async sendWarningEmail(
|
|
projectId: string,
|
|
projectName: string,
|
|
usage: number,
|
|
limit: number,
|
|
percentage: number,
|
|
sourceType: string,
|
|
): Promise<void> {
|
|
try {
|
|
// Check if we've already sent this warning email this month
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
|
|
|
const alreadySent = await redis.get(cacheKey);
|
|
if (alreadySent === '1') {
|
|
return;
|
|
}
|
|
|
|
const members = await MembershipService.getMembers(projectId);
|
|
const emails = members.map(m => m.email);
|
|
if (emails.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const template = React.createElement(BillingLimitWarningEmail, {
|
|
projectName,
|
|
projectId,
|
|
usage,
|
|
limit,
|
|
percentage,
|
|
sourceType,
|
|
dashboardUrl: DASHBOARD_URI,
|
|
landingUrl: LANDING_URI,
|
|
});
|
|
|
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
|
|
|
// Mark that we've sent the warning email (expires at end of month)
|
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
await redis.setex(cacheKey, ttl, '1');
|
|
} catch (error) {
|
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send billing limit exceeded email to project members
|
|
*/
|
|
private static async sendLimitExceededEmail(
|
|
projectId: string,
|
|
projectName: string,
|
|
usage: number,
|
|
limit: number,
|
|
sourceType: string,
|
|
): Promise<void> {
|
|
try {
|
|
// Check if we've already sent this warning email this month
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
|
|
|
const alreadySent = await redis.get(cacheKey);
|
|
if (alreadySent === '1') {
|
|
return;
|
|
}
|
|
|
|
const members = await MembershipService.getMembers(projectId);
|
|
const emails = members.map(m => m.email);
|
|
if (emails.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const template = React.createElement(BillingLimitExceededEmail, {
|
|
projectName,
|
|
projectId,
|
|
usage,
|
|
limit,
|
|
sourceType,
|
|
dashboardUrl: DASHBOARD_URI,
|
|
landingUrl: LANDING_URI,
|
|
});
|
|
|
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
|
|
|
// Mark that we've sent the limit email (expires at end of month)
|
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
await redis.setex(cacheKey, ttl, '1');
|
|
} catch (error) {
|
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
|
}
|
|
}
|
|
}
|