Add billing limits display in currency
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {EmailSourceType} from '@plunk/db';
|
import {EmailSourceType} from '@plunk/db';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
@@ -23,6 +24,7 @@ export interface BillingLimitsResponse {
|
|||||||
workflows: CategoryUsage;
|
workflows: CategoryUsage;
|
||||||
campaigns: CategoryUsage;
|
campaigns: CategoryUsage;
|
||||||
transactional: CategoryUsage;
|
transactional: CategoryUsage;
|
||||||
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -361,6 +363,7 @@ export class BillingLimitService {
|
|||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
select: {
|
select: {
|
||||||
|
customer: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
@@ -372,6 +375,20 @@ export class BillingLimitService {
|
|||||||
throw new Error('Project not found');
|
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
|
// Get usage for all categories in parallel
|
||||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
||||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||||
@@ -419,6 +436,7 @@ export class BillingLimitService {
|
|||||||
workflows: sharedUsageInfo,
|
workflows: sharedUsageInfo,
|
||||||
campaigns: sharedUsageInfo,
|
campaigns: sharedUsageInfo,
|
||||||
transactional: sharedUsageInfo,
|
transactional: sharedUsageInfo,
|
||||||
|
currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +445,7 @@ export class BillingLimitService {
|
|||||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||||
|
currency,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, 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 {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {BillingLimitSchemas} from '@plunk/shared';
|
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 {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
|
||||||
import {network} from '../lib/network';
|
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 {
|
interface BillingLimitsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
hasSubscription: boolean;
|
hasSubscription: boolean;
|
||||||
@@ -173,12 +203,20 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* For free tier, show total usage across all categories */}
|
{/* For free tier, show total usage across all categories */}
|
||||||
{!hasSubscription ? (
|
{!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="Workflows" usage={limitsData.workflows} currency={limitsData.currency} />
|
||||||
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
|
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} currency={limitsData.currency} />
|
||||||
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
|
<UsageDisplay
|
||||||
|
category="Transactional"
|
||||||
|
usage={limitsData.transactional}
|
||||||
|
currency={limitsData.currency}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -197,66 +235,97 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="workflows"
|
name="workflows"
|
||||||
render={({field}) => (
|
render={({field}) => {
|
||||||
<FormItem>
|
const estimatedCost =
|
||||||
<FormLabel>Workflow Emails Limit</FormLabel>
|
limitsData?.currency && field.value
|
||||||
<FormControl>
|
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||||
<Input
|
: null;
|
||||||
type="number"
|
return (
|
||||||
placeholder="Unlimited"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Workflow Emails Limit</FormLabel>
|
||||||
value={field.value ?? ''}
|
<FormControl>
|
||||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</FormControl>
|
placeholder="Unlimited"
|
||||||
<FormDescription>Maximum workflow emails per month. Leave empty for unlimited.</FormDescription>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ''}
|
||||||
</FormItem>
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="campaigns"
|
name="campaigns"
|
||||||
render={({field}) => (
|
render={({field}) => {
|
||||||
<FormItem>
|
const estimatedCost =
|
||||||
<FormLabel>Campaign Emails Limit</FormLabel>
|
limitsData?.currency && field.value
|
||||||
<FormControl>
|
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||||
<Input
|
: null;
|
||||||
type="number"
|
return (
|
||||||
placeholder="Unlimited"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Campaign Emails Limit</FormLabel>
|
||||||
value={field.value ?? ''}
|
<FormControl>
|
||||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</FormControl>
|
placeholder="Unlimited"
|
||||||
<FormDescription>Maximum campaign emails per month. Leave empty for unlimited.</FormDescription>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ''}
|
||||||
</FormItem>
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="transactional"
|
name="transactional"
|
||||||
render={({field}) => (
|
render={({field}) => {
|
||||||
<FormItem>
|
const estimatedCost =
|
||||||
<FormLabel>Transactional Emails Limit</FormLabel>
|
limitsData?.currency && field.value
|
||||||
<FormControl>
|
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||||
<Input
|
: null;
|
||||||
type="number"
|
return (
|
||||||
placeholder="Unlimited"
|
<FormItem>
|
||||||
{...field}
|
<FormLabel>Transactional Emails Limit</FormLabel>
|
||||||
value={field.value ?? ''}
|
<FormControl>
|
||||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
<Input
|
||||||
/>
|
type="number"
|
||||||
</FormControl>
|
placeholder="Unlimited"
|
||||||
<FormDescription>
|
{...field}
|
||||||
Maximum transactional emails per month. Leave empty for unlimited.
|
value={field.value ?? ''}
|
||||||
</FormDescription>
|
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</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">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
@@ -279,9 +348,10 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
interface UsageDisplayProps {
|
interface UsageDisplayProps {
|
||||||
category: string;
|
category: string;
|
||||||
usage: CategoryLimit;
|
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(() => {
|
const statusColor = useMemo(() => {
|
||||||
if (usage.isBlocked) return 'text-red-600';
|
if (usage.isBlocked) return 'text-red-600';
|
||||||
if (usage.isWarning) return 'text-orange-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();
|
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 (
|
return (
|
||||||
<div className="border border-neutral-200 rounded-lg p-4">
|
<div className="border border-neutral-200 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-neutral-900">{category}</h3>
|
<h3 className="font-medium text-neutral-900">{category}</h3>
|
||||||
<p className="text-sm text-neutral-500">
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
{usage.usage.toLocaleString()} / {limitText} emails this month
|
<p className="text-sm text-neutral-600">
|
||||||
</p>
|
{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>
|
||||||
<div className={`flex items-center gap-2 ${statusColor}`}>
|
<div className={`flex items-center gap-2 ${statusColor}`}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface BillingLimitsData {
|
|||||||
workflows: CategoryLimit;
|
workflows: CategoryLimit;
|
||||||
campaigns: CategoryLimit;
|
campaigns: CategoryLimit;
|
||||||
transactional: CategoryLimit;
|
transactional: CategoryLimit;
|
||||||
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user