Skip to content

Commit 175ac38

Browse files
feat: add currency management system with auto-detection (#439)
* feat: add currency management system with auto-detection - Add currency and country fields to client profiles schema - Create currency detection service with geo-location support - Add CurrencyProvider context for application-wide currency state - Implement use-currency hook for currency management - Add API endpoint for user currency preferences (PUT /api/user/currency) - Integrate currency formatting in billing components - Add currency utilities for formatting and conversion - Update database migration to include country and currency fields * fix: add country and currency fields to client profile queries Add missing country and currency fields in all SELECT queries that return ClientProfileWithAuth objects to fix TypeScript type errors. The fields were added to the schema but were missing from the query selections in getClientProfiles, getAllClientProfiles, and searchClientProfiles functions. * feat: improve currency system with validation, i18n, and formatting fixes - Add Zod validation for ISO 4217 currency codes - Fix decimal conventions (JPY/KRW: 0 decimals) and division logic - Replace hardcoded locale with dynamic next-intl locale - Add error handling and graceful fallbacks - Update Croatia to EUR, fix migration idempotency - Centralize currency formatting across billing components - Export UpdateCurrencyOptions and update context types
1 parent 10e18ad commit 175ac38

File tree

18 files changed

+6752
-1471
lines changed

18 files changed

+6752
-1471
lines changed

app/[locale]/providers.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/components/providers';
1414
import { SessionProvider } from 'next-auth/react';
1515
import { LoginModalProvider } from '@/components/auth/login-modal-provider';
16+
import { CurrencyProvider } from '@/components/context';
1617

1718
interface ProvidersProps {
1819
config: Config;
@@ -23,28 +24,30 @@ interface ProvidersProps {
2324
export function Providers({ config, children, dehydratedState }: ProvidersProps) {
2425
// Extract homepage settings for layout defaults
2526
const configDefaults = {
26-
defaultView: config.settings?.homepage?.default_view,
27+
defaultView: config.settings?.homepage?.default_view
2728
};
2829

2930
return (
3031
<SessionProvider>
3132
<QueryClientProvider dehydratedState={dehydratedState}>
32-
<LayoutProvider configDefaults={configDefaults}>
33-
<ErrorProvider>
34-
<ConfirmProvider>
35-
<FilterProvider>
36-
<ConfigProvider config={config}>
37-
<ThemeProvider>
38-
<HeroUIProvider>
39-
<LoginModalProvider />
40-
{children}
41-
</HeroUIProvider>
42-
</ThemeProvider>
43-
</ConfigProvider>
44-
</FilterProvider>
45-
</ConfirmProvider>
46-
</ErrorProvider>
47-
</LayoutProvider>
33+
<CurrencyProvider>
34+
<LayoutProvider configDefaults={configDefaults}>
35+
<ErrorProvider>
36+
<ConfirmProvider>
37+
<FilterProvider>
38+
<ConfigProvider config={config}>
39+
<ThemeProvider>
40+
<HeroUIProvider>
41+
<LoginModalProvider />
42+
{children}
43+
</HeroUIProvider>
44+
</ThemeProvider>
45+
</ConfigProvider>
46+
</FilterProvider>
47+
</ConfirmProvider>
48+
</ErrorProvider>
49+
</LayoutProvider>
50+
</CurrencyProvider>
4851
</QueryClientProvider>
4952
</SessionProvider>
5053
);

app/api/user/currency/route.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
import { auth } from '@/lib/auth';
4+
import { getUserCurrency, updateUserCurrency } from '@/lib/services/currency.service';
5+
6+
// Whitelist of supported ISO 4217 currency codes
7+
const SUPPORTED_CURRENCIES = [
8+
'USD',
9+
'EUR',
10+
'GBP',
11+
'JPY',
12+
'CNY',
13+
'CAD',
14+
'AUD',
15+
'CHF',
16+
'INR',
17+
'BRL',
18+
'MXN',
19+
'KRW',
20+
'RUB',
21+
'TRY',
22+
'ZAR',
23+
'SGD',
24+
'HKD',
25+
'NOK',
26+
'SEK',
27+
'DKK',
28+
'PLN',
29+
'CZK',
30+
'HUF',
31+
'NZD',
32+
'THB',
33+
'ILS',
34+
'CLP',
35+
'PHP',
36+
'AED',
37+
'SAR',
38+
'MYR',
39+
'IDR',
40+
'VND',
41+
'BGN',
42+
'RON',
43+
// HRK removed - Croatia adopted EUR on January 1, 2023
44+
'ISK',
45+
'BWP',
46+
'COP',
47+
'PEN'
48+
] as const;
49+
50+
const currencyUpdateSchema = z.object({
51+
currency: z
52+
.string()
53+
.trim()
54+
.min(1, 'Currency code is required')
55+
.max(3, 'Currency code must be 3 characters')
56+
.toUpperCase()
57+
.refine((val) => SUPPORTED_CURRENCIES.includes(val as (typeof SUPPORTED_CURRENCIES)[number]), {
58+
message: `Currency code must be a valid ISO 4217 code. Supported codes: ${SUPPORTED_CURRENCIES.join(', ')}`
59+
})
60+
});
61+
62+
/**
63+
* GET /api/user/currency
64+
* Get current user's currency preference
65+
*/
66+
export async function GET(request: NextRequest) {
67+
try {
68+
const session = await auth();
69+
70+
if (!session?.user?.id) {
71+
return NextResponse.json({ currency: 'USD' }, { status: 200 });
72+
}
73+
74+
const currency = await getUserCurrency(session.user.id, request);
75+
76+
return NextResponse.json({ currency });
77+
} catch (error) {
78+
console.error('[API] Error getting user currency:', error);
79+
return NextResponse.json({ currency: 'USD' }, { status: 200 });
80+
}
81+
}
82+
83+
/**
84+
* PUT /api/user/currency
85+
* Update current user's currency preference
86+
*/
87+
export async function PUT(request: NextRequest) {
88+
try {
89+
const session = await auth();
90+
91+
if (!session?.user?.id) {
92+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
93+
}
94+
95+
const body = await request.json();
96+
97+
// Validate request body with Zod schema
98+
const validationResult = currencyUpdateSchema.safeParse(body);
99+
100+
if (!validationResult.success) {
101+
const errorMessage = validationResult.error.issues[0]?.message || 'Invalid currency code';
102+
return NextResponse.json({ error: errorMessage }, { status: 400 });
103+
}
104+
105+
const { currency } = validationResult.data;
106+
107+
const success = await updateUserCurrency(session.user.id, currency);
108+
109+
if (!success) {
110+
return NextResponse.json({ error: 'Failed to update currency' }, { status: 500 });
111+
}
112+
113+
return NextResponse.json({ currency });
114+
} catch (error) {
115+
console.error('[API] Error updating user currency:', error);
116+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
117+
}
118+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import React, { createContext, useContext } from 'react';
4+
import { useCurrency, type UpdateCurrencyOptions } from '@/hooks/use-currency';
5+
6+
interface CurrencyContextType {
7+
currency: string;
8+
isLoading: boolean;
9+
updateCurrency: (currency: string, options?: UpdateCurrencyOptions) => void;
10+
}
11+
12+
const CurrencyContext = createContext<CurrencyContextType | undefined>(undefined);
13+
14+
/**
15+
* Currency Provider
16+
* Provides currency context to the entire application
17+
*/
18+
export function CurrencyProvider({ children }: { children: React.ReactNode }) {
19+
const { currency, isLoading, updateCurrency } = useCurrency();
20+
21+
return (
22+
<CurrencyContext.Provider
23+
value={{
24+
currency,
25+
isLoading,
26+
updateCurrency
27+
}}
28+
>
29+
{children}
30+
</CurrencyContext.Provider>
31+
);
32+
}
33+
34+
/**
35+
* Hook to use currency context
36+
*/
37+
export function useCurrencyContext(): CurrencyContextType {
38+
const context = useContext(CurrencyContext);
39+
if (context === undefined) {
40+
throw new Error('useCurrencyContext must be used within a CurrencyProvider');
41+
}
42+
return context;
43+
}

components/context/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from "./LayoutThemeContext";
1+
export * from './LayoutThemeContext';
2+
export * from './currency-provider';

components/settings/billing/billing-stats.tsx

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from 'lucide-react';
1717
import { useSubscription } from '@/hooks/use-subscription';
1818
import { toast } from 'sonner';
19+
import { useLocale } from 'next-intl';
20+
import { formatCurrencyAmount } from '@/lib/utils/currency-format';
1921

2022
interface BillingStatsProps {
2123
planName: string;
@@ -47,15 +49,7 @@ export function BillingStats({
4749
currentPeriodEnd
4850
}: BillingStatsProps) {
4951
const { createBillingPortalSession, isCreateBillingPortalSessionPending } = useSubscription();
50-
51-
const formatCurrency = (amount: number) => {
52-
return new Intl.NumberFormat('en-US', {
53-
style: 'currency',
54-
currency: currency,
55-
minimumFractionDigits: 2,
56-
maximumFractionDigits: 2
57-
}).format(amount);
58-
};
52+
const locale = useLocale();
5953

6054
const getTrendIcon = (value: number, threshold: number): React.ReactElement | null => {
6155
if (value > threshold) {
@@ -131,7 +125,7 @@ export function BillingStats({
131125
{getTrendIcon(totalSpent, 100)}
132126
</div>
133127
<h3 className="text-2xl font-bold text-theme-primary-900 dark:text-theme-primary-100 mb-1 group-hover:text-theme-primary-800 dark:group-hover:text-white transition-colors">
134-
{formatCurrency(totalSpent)}
128+
{formatCurrencyAmount(totalSpent, currency, locale)}
135129
</h3>
136130
<p className="text-theme-primary-700 dark:text-theme-primary-300 text-sm font-medium">
137131
Total Spent
@@ -166,7 +160,7 @@ export function BillingStats({
166160
{getTrendIcon(monthlyAverage, 50)}
167161
</div>
168162
<h3 className="text-2xl font-bold text-purple-900 dark:text-purple-100 mb-1 group-hover:text-purple-800 dark:group-hover:text-white transition-colors">
169-
{formatCurrency(monthlyAverage)}
163+
{formatCurrencyAmount(monthlyAverage, currency, locale)}
170164
</h3>
171165
<p className="text-purple-700 dark:text-purple-300 text-sm font-medium">Monthly Average</p>
172166
<div className="mt-2 text-xs text-purple-600 dark:text-purple-400">
@@ -224,27 +218,31 @@ export function BillingStats({
224218
<div className="flex justify-between items-center">
225219
<span className="text-slate-600 dark:text-slate-300">This Month:</span>
226220
<span className="font-semibold text-slate-900 dark:text-slate-100">
227-
{formatCurrency(totalSpent)}
221+
{formatCurrencyAmount(totalSpent, currency, locale)}
228222
</span>
229223
</div>
230224
<div className="flex justify-between items-center">
231225
<span className="text-slate-600 dark:text-slate-300">Last Month:</span>
232226
<span className="font-semibold text-slate-900 dark:text-slate-100">
233-
{formatCurrency(lastMonthSpent)}
227+
{formatCurrencyAmount(lastMonthSpent || 0, currency, locale)}
234228
</span>
235229
</div>
236230
<div className="pt-2 border-t border-slate-200 dark:border-slate-700">
237231
<div className="flex justify-between items-center">
238232
<span className="text-slate-600 dark:text-slate-300">Change:</span>
239233
<span
240234
className={`font-semibold ${
241-
lastMonthSpent > totalSpent
235+
lastMonthSpent && lastMonthSpent > totalSpent
242236
? 'text-emerald-600 dark:text-emerald-400'
243237
: 'text-red-600 dark:text-red-400'
244238
}`}
245239
>
246-
{lastMonthSpent > totalSpent ? '↓' : '↑'}{' '}
247-
{formatCurrency(Math.abs(lastMonthSpent - totalSpent))}
240+
{lastMonthSpent && lastMonthSpent > totalSpent ? '↓' : '↑'}{' '}
241+
{formatCurrencyAmount(
242+
Math.abs((lastMonthSpent || 0) - totalSpent),
243+
currency,
244+
locale
245+
)}
248246
</span>
249247
</div>
250248
</div>
@@ -404,6 +402,8 @@ export function DetailedBillingStats({
404402
daysUntilRenewal,
405403
currentPeriodEnd
406404
}: BillingStatsProps) {
405+
const locale = useLocale();
406+
407407
return (
408408
<div className="space-y-6">
409409
{/* Main Stats Grid */}
@@ -435,7 +435,11 @@ export function DetailedBillingStats({
435435
<span className="text-slate-600 dark:text-slate-300">Average Transaction:</span>
436436
<span className="font-semibold text-slate-900 dark:text-slate-100">
437437
{totalPayments > 0
438-
? formatCurrency(totalPayments > 0 ? totalSpent / totalPayments : 0)
438+
? formatCurrencyAmount(
439+
totalPayments > 0 ? totalSpent / totalPayments : 0,
440+
currency,
441+
locale
442+
)
439443
: 'N/A'}
440444
</span>
441445
</div>
@@ -482,7 +486,9 @@ export function DetailedBillingStats({
482486
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg border border-slate-200 dark:border-slate-600/50">
483487
<span className="text-slate-600 dark:text-slate-300">Monthly Cost:</span>
484488
<span className="font-semibold text-slate-900 dark:text-slate-100">
485-
{hasActiveSubscription ? formatCurrency(monthlyAverage) : 'Free'}
489+
{hasActiveSubscription
490+
? formatCurrencyAmount(monthlyAverage, currency, locale)
491+
: 'Free'}
486492
</span>
487493
</div>
488494
</div>
@@ -491,13 +497,3 @@ export function DetailedBillingStats({
491497
</div>
492498
);
493499
}
494-
495-
// Helper function for currency formatting
496-
function formatCurrency(amount: number): string {
497-
return new Intl.NumberFormat('en-US', {
498-
style: 'currency',
499-
currency: 'USD',
500-
minimumFractionDigits: 2,
501-
maximumFractionDigits: 2
502-
}).format(amount);
503-
}

components/settings/billing/payment-card.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
'use client';
2+
13
import { CreditCard, Calendar, DollarSign, ExternalLink, Download, CheckCircle, Clock, AlertCircle, X, Edit3 } from 'lucide-react';
24
import { useState } from 'react';
5+
import { useLocale } from 'next-intl';
6+
import { formatCurrencyAmount } from '@/lib/utils/currency-format';
37

48
interface PaymentHistoryItem {
59
id: string;
@@ -23,19 +27,6 @@ const formatDate = (date: string) => new Date(date).toLocaleDateString(undefined
2327
day: 'numeric'
2428
});
2529

26-
const formatAmount = (amount: number, currency: string) => {
27-
const currencySymbols: Record<string, string> = {
28-
'USD': '$',
29-
'EUR': '€',
30-
'GBP': '£',
31-
'CAD': 'C$',
32-
'AUD': 'A$'
33-
};
34-
35-
const symbol = currencySymbols[currency] || currency;
36-
return `${symbol}${amount.toFixed(2)}`;
37-
};
38-
3930
const getStatusConfig = (status: string) => {
4031
switch (status.toLowerCase()) {
4132
case 'paid':
@@ -97,6 +88,7 @@ const getProviderIcon = (provider: string) => {
9788
};
9889

9990
export function PaymentCard({ payment }: { payment: PaymentHistoryItem }) {
91+
const locale = useLocale();
10092
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
10193
const statusConfig = getStatusConfig(payment.status);
10294
const StatusIcon = statusConfig.icon;
@@ -178,7 +170,7 @@ export function PaymentCard({ payment }: { payment: PaymentHistoryItem }) {
178170
{/* Right Section - Amount and Actions */}
179171
<div className="text-right ml-6">
180172
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1 group-hover:text-slate-800 dark:group-hover:text-white transition-colors">
181-
{formatAmount(payment.amount, payment.currency)}
173+
{formatCurrencyAmount(payment.amount, payment.currency, locale)}
182174
</div>
183175

184176
<div className="text-sm text-slate-600 dark:text-slate-300 mb-3">

0 commit comments

Comments
 (0)