From 18b7d7fe9836b9105117d95e0f6ca142a620773f Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Fri, 5 Dec 2025 12:28:23 +0100 Subject: [PATCH] Add billing limits display in currency --- apps/api/src/services/BillingLimitService.ts | 19 ++ apps/web/src/components/BillingLimits.tsx | 203 +++++++++++++------ apps/web/src/lib/hooks/useBillingLimits.ts | 1 + 3 files changed, 164 insertions(+), 59 deletions(-) diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 08a2062..5a8dcfc 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -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); diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index 7b6b430..eb1ce43 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -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
{/* For free tier, show total usage across all categories */} {!hasSubscription ? ( - + ) : ( <> - - - + + + )} @@ -197,66 +235,97 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill ( - - Workflow Emails Limit - - field.onChange(e.target.value === '' ? null : e.target.value)} - /> - - Maximum workflow emails per month. Leave empty for unlimited. - - - )} + render={({field}) => { + const estimatedCost = + limitsData?.currency && field.value + ? formatEmailCost(Number(field.value), limitsData.currency) + : null; + return ( + + Workflow Emails Limit + + field.onChange(e.target.value === '' ? null : e.target.value)} + /> + + + Maximum workflow emails per month. Leave empty for unlimited. + {estimatedCost && ( + ≈ {estimatedCost}/month + )} + + + + ); + }} /> ( - - Campaign Emails Limit - - field.onChange(e.target.value === '' ? null : e.target.value)} - /> - - Maximum campaign emails per month. Leave empty for unlimited. - - - )} + render={({field}) => { + const estimatedCost = + limitsData?.currency && field.value + ? formatEmailCost(Number(field.value), limitsData.currency) + : null; + return ( + + Campaign Emails Limit + + field.onChange(e.target.value === '' ? null : e.target.value)} + /> + + + Maximum campaign emails per month. Leave empty for unlimited. + {estimatedCost && ( + ≈ {estimatedCost}/month + )} + + + + ); + }} /> ( - - Transactional Emails Limit - - field.onChange(e.target.value === '' ? null : e.target.value)} - /> - - - Maximum transactional emails per month. Leave empty for unlimited. - - - - )} + render={({field}) => { + const estimatedCost = + limitsData?.currency && field.value + ? formatEmailCost(Number(field.value), limitsData.currency) + : null; + return ( + + Transactional Emails Limit + + field.onChange(e.target.value === '' ? null : e.target.value)} + /> + + + Maximum transactional emails per month. Leave empty for unlimited. + {estimatedCost && ( + ≈ {estimatedCost}/month + )} + + + + ); + }} />
@@ -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 (

{category}

-

- {usage.usage.toLocaleString()} / {limitText} emails this month -

+
+

+ {usage.usage.toLocaleString()} / {limitText} emails +

+ {currency && ( + <> + +

+ {usageCost} + {limitCost && ` / ${limitCost}`} +

+ + )} +
{statusIcon} diff --git a/apps/web/src/lib/hooks/useBillingLimits.ts b/apps/web/src/lib/hooks/useBillingLimits.ts index 7b02cd5..023cbb7 100644 --- a/apps/web/src/lib/hooks/useBillingLimits.ts +++ b/apps/web/src/lib/hooks/useBillingLimits.ts @@ -12,6 +12,7 @@ export interface BillingLimitsData { workflows: CategoryLimit; campaigns: CategoryLimit; transactional: CategoryLimit; + currency: string | null; } /**