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
- {usage.usage.toLocaleString()} / {limitText} emails this month -
++ {usage.usage.toLocaleString()} / {limitText} emails +
+ {currency && ( + <> + • ++ {usageCost} + {limitCost && ` / ${limitCost}`} +
+ > + )} +