Add billing limits display in currency
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import signale from 'signale';
|
||||
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
@@ -23,6 +24,7 @@ export interface BillingLimitsResponse {
|
||||
workflows: CategoryUsage;
|
||||
campaigns: CategoryUsage;
|
||||
transactional: CategoryUsage;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,6 +363,7 @@ export class BillingLimitService {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {
|
||||
customer: true,
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
@@ -372,6 +375,20 @@ export class BillingLimitService {
|
||||
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),
|
||||
@@ -419,6 +436,7 @@ export class BillingLimitService {
|
||||
workflows: sharedUsageInfo,
|
||||
campaigns: sharedUsageInfo,
|
||||
transactional: sharedUsageInfo,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,6 +445,7 @@ export class BillingLimitService {
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {memo, useEffect, useMemo, useState} from 'react';
|
||||
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {BillingLimitSchemas} from '@plunk/shared';
|
||||
@@ -26,6 +26,36 @@ import type {z} from 'zod';
|
||||
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
|
||||
const PRICE_PER_EMAIL = 0.1; // 0.001 USD/EUR = 0.1 cents
|
||||
|
||||
/**
|
||||
* Calculate the monetary cost for a given number of emails
|
||||
* @param emailCount - Number of emails
|
||||
* @param currency - Currency code (e.g., 'usd', 'eur')
|
||||
* @returns Formatted currency string
|
||||
*/
|
||||
const formatEmailCost = (emailCount: number, currency: string | null): string => {
|
||||
if (!currency) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Calculate cost in smallest currency unit (cents)
|
||||
const costInCents = emailCount * PRICE_PER_EMAIL;
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(costInCents / 100);
|
||||
} catch (error) {
|
||||
// Fallback if currency is invalid
|
||||
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
||||
}
|
||||
};
|
||||
|
||||
interface BillingLimitsProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
@@ -173,12 +203,20 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<div className="space-y-4">
|
||||
{/* For free tier, show total usage across all categories */}
|
||||
{!hasSubscription ? (
|
||||
<UsageDisplay category="Total Emails (All Categories)" usage={limitsData.workflows} />
|
||||
<UsageDisplay
|
||||
category="Total Emails (All Categories)"
|
||||
usage={limitsData.workflows}
|
||||
currency={limitsData.currency}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<UsageDisplay category="Workflows" usage={limitsData.workflows} />
|
||||
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
|
||||
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
|
||||
<UsageDisplay category="Workflows" usage={limitsData.workflows} currency={limitsData.currency} />
|
||||
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} currency={limitsData.currency} />
|
||||
<UsageDisplay
|
||||
category="Transactional"
|
||||
usage={limitsData.transactional}
|
||||
currency={limitsData.currency}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -197,66 +235,97 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="workflows"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Workflow Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Maximum workflow emails per month. Leave empty for unlimited.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({field}) => {
|
||||
const estimatedCost =
|
||||
limitsData?.currency && field.value
|
||||
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||
: null;
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Workflow Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum workflow emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="campaigns"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Maximum campaign emails per month. Leave empty for unlimited.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({field}) => {
|
||||
const estimatedCost =
|
||||
limitsData?.currency && field.value
|
||||
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||
: null;
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum campaign emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transactional"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transactional Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum transactional emails per month. Leave empty for unlimited.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({field}) => {
|
||||
const estimatedCost =
|
||||
limitsData?.currency && field.value
|
||||
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||
: null;
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Transactional Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum transactional emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
@@ -279,9 +348,10 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
interface UsageDisplayProps {
|
||||
category: string;
|
||||
usage: CategoryLimit;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
const UsageDisplay = memo(function UsageDisplay({category, usage}: UsageDisplayProps) {
|
||||
const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: UsageDisplayProps) {
|
||||
const statusColor = useMemo(() => {
|
||||
if (usage.isBlocked) return 'text-red-600';
|
||||
if (usage.isWarning) return 'text-orange-600';
|
||||
@@ -302,14 +372,29 @@ const UsageDisplay = memo(function UsageDisplay({category, usage}: UsageDisplayP
|
||||
|
||||
const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString();
|
||||
|
||||
// Calculate monetary values
|
||||
const usageCost = currency ? formatEmailCost(usage.usage, currency) : null;
|
||||
const limitCost = currency && usage.limit !== null ? formatEmailCost(usage.limit, currency) : null;
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900">{category}</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{usage.usage.toLocaleString()} / {limitText} emails this month
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<p className="text-sm text-neutral-600">
|
||||
{usage.usage.toLocaleString()} / {limitText} emails
|
||||
</p>
|
||||
{currency && (
|
||||
<>
|
||||
<span className="text-neutral-300">•</span>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{usageCost}
|
||||
{limitCost && ` / ${limitCost}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${statusColor}`}>
|
||||
{statusIcon}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface BillingLimitsData {
|
||||
workflows: CategoryLimit;
|
||||
campaigns: CategoryLimit;
|
||||
transactional: CategoryLimit;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user