Skip to content

Commit 3f4d9d8

Browse files
Merge pull request #445 from ever-works/feat/add-currency-localization-support
feat: add multi-currency support for payment providers
1 parent 3230af6 commit 3f4d9d8

File tree

18 files changed

+861
-190
lines changed

18 files changed

+861
-190
lines changed

app/[locale]/config.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,47 @@
33
import type { Config } from '@/lib/content';
44
import { createContext, useContext, useMemo } from 'react';
55
import { getAuthConfig } from '@/lib/auth/config';
6-
import { defaultPricingConfig } from '@/lib/types';
6+
import { defaultPricingConfig, getDefaultPricingConfigWithCurrency } from '@/lib/types';
7+
import { useCurrencyContext } from '@/components/context/currency-provider';
8+
import { useSelectedCheckoutProvider } from '@/hooks/use-selected-checkout-provider';
9+
import { usePaymentProvider } from '@/lib/utils/payment-provider';
710

811
const ConfigContext = createContext<Config>({});
912

1013
// Initialize auth config once during module load
1114
const initialAuthConfig = getAuthConfig();
1215

1316
export function ConfigProvider({ config, children }: { config: Config; children: React.ReactNode }) {
17+
// Get user's currency from currency context
18+
// CurrencyProvider must be a parent of ConfigProvider (which it is in providers.tsx)
19+
const { currency, isLoading: currencyLoading } = useCurrencyContext();
20+
21+
// Get user's selected checkout provider from Settings
22+
const { getActiveProvider } = useSelectedCheckoutProvider();
23+
24+
// Determine payment provider: User selection takes precedence over config
25+
const paymentProvider = usePaymentProvider(getActiveProvider, config.pricing);
26+
1427
const enhancedConfig = useMemo(() => {
15-
const pricing = config.pricing ?? defaultPricingConfig;
16-
const configWithPricing = { ...config, pricing };
17-
return { ...configWithPricing,
18-
authConfig: initialAuthConfig,
19-
};
20-
}, [config]);
28+
//
29+
let pricing = config.pricing;
30+
31+
if (!pricing && !currencyLoading) {
32+
// If no pricing in config, generate currency-aware default config
33+
pricing = getDefaultPricingConfigWithCurrency(currency, paymentProvider);
34+
} else if (!pricing) {
35+
// If currency is still loading, use default config
36+
pricing = defaultPricingConfig;
37+
} else if (!currencyLoading && currency) {
38+
// If config has pricing but we want to enrich it with currency-aware price IDs
39+
// We can optionally enhance the existing config with currency-specific IDs
40+
// For now, we keep the config as-is since it might come from content/config.yml
41+
// The currency-aware price IDs will be resolved at checkout time via getStripePriceConfig/getLemonSqueezyPriceConfig
42+
}
43+
44+
const configWithPricing = { ...config, pricing };
45+
return { ...configWithPricing, authConfig: initialAuthConfig };
46+
}, [config, currency, currencyLoading, paymentProvider]);
2147

2248
return <ConfigContext.Provider value={enhancedConfig}>{children}</ConfigContext.Provider>;
2349
}

app/api/polar/webhook/utils.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,7 @@ export const PLAN_FEATURES: Record<string, string[]> = {
4242
/**
4343
* Normalizes email config to ensure all values are strings
4444
*/
45-
export function normalizeEmailConfig(
46-
config: Awaited<ReturnType<typeof getEmailConfig>>
47-
): EmailConfig {
45+
export function normalizeEmailConfig(config: Awaited<ReturnType<typeof getEmailConfig>>): EmailConfig {
4846
const supportEmail =
4947
typeof config.supportEmail === 'string' && config.supportEmail.trim() !== ''
5048
? config.supportEmail
@@ -77,14 +75,9 @@ export function createEmailData<T extends Record<string, any>>(
7775
*/
7876
export function extractSubscriptionInfo(data: PolarWebhookData) {
7977
const productId = data.product?.id || data.product_id || '';
80-
const planName = productId ? getPlanName(productId) : DEFAULT_PLAN_NAME;
81-
const amount = formatAmount(
82-
data.price?.amount || data.amount || 0,
83-
data.currency || DEFAULT_CURRENCY
84-
);
85-
const billingPeriod = getBillingPeriod(
86-
data.price?.recurring?.interval || data.interval || DEFAULT_INTERVAL
87-
);
78+
const planName = productId ? getPlanName(productId) || DEFAULT_PLAN_NAME : DEFAULT_PLAN_NAME;
79+
const amount = formatAmount(data.price?.amount || data.amount || 0, data.currency || DEFAULT_CURRENCY);
80+
const billingPeriod = getBillingPeriod(data.price?.recurring?.interval || data.interval || DEFAULT_INTERVAL);
8881

8982
return { productId, planName, amount, billingPeriod };
9083
}
@@ -94,13 +87,10 @@ export function extractSubscriptionInfo(data: PolarWebhookData) {
9487
*/
9588
export function extractNestedSubscriptionInfo(data: PolarWebhookData) {
9689
const subscription = data.subscription;
97-
const productId =
98-
subscription?.product?.id || subscription?.product_id || data.product?.id || data.product_id;
99-
const planName = productId ? getPlanName(productId) : DEFAULT_PLAN_NAME;
90+
const productId = subscription?.product?.id || subscription?.product_id || data.product?.id || data.product_id;
91+
const planName = productId ? getPlanName(productId) || DEFAULT_PLAN_NAME : DEFAULT_PLAN_NAME;
10092
const billingPeriod = subscription
101-
? getBillingPeriod(
102-
subscription.price?.recurring?.interval || subscription.interval || DEFAULT_INTERVAL
103-
)
93+
? getBillingPeriod(subscription.price?.recurring?.interval || subscription.interval || DEFAULT_INTERVAL)
10494
: DEFAULT_INTERVAL;
10595

10696
return { productId, planName, billingPeriod, subscription };
@@ -123,7 +113,7 @@ export function validateWebhookPayload(body: unknown): body is PolarWebhookEvent
123113
}
124114

125115
const event = body as Record<string, unknown>;
126-
116+
127117
// Must have type and data
128118
if (typeof event.type !== 'string' || event.data === null || typeof event.data !== 'object') {
129119
return false;

app/api/stripe/webhook/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ async function handleSubscriptionCreated(data: any) {
315315

316316
// Extract subscription information
317317
const priceId = data.items?.data?.[0]?.price?.id;
318-
const planName = getPlanName(priceId);
318+
const planName = getPlanName(priceId) || 'Premium Plan';
319319
const amount = formatAmount(data.items?.data?.[0]?.price?.unit_amount || 0, data.currency);
320320
const billingPeriod = getBillingPeriod(data.items?.data?.[0]?.price?.recurring?.interval);
321321
const emailConfig = await getEmailConfig();
@@ -361,7 +361,7 @@ async function handleSubscriptionUpdated(data: any) {
361361

362362
// Extract subscription information
363363
const priceId = data.items?.data?.[0]?.price?.id;
364-
const planName = getPlanName(priceId);
364+
const planName = getPlanName(priceId) || 'Premium Plan';
365365
const amount = formatAmount(data.items?.data?.[0]?.price?.unit_amount || 0, data.currency);
366366
const billingPeriod = getBillingPeriod(data.items?.data?.[0]?.price?.recurring?.interval);
367367

@@ -409,7 +409,7 @@ async function handleSubscriptionCancelled(data: any) {
409409
await webhookSubscriptionService.handleSubscriptionCancelled(data);
410410
const customerInfo = extractCustomerInfo(data);
411411
const priceId = data.items?.data?.[0]?.price?.id;
412-
const planName = getPlanName(priceId);
412+
const planName = getPlanName(priceId) || 'Premium Plan';
413413
const amount = formatAmount(data.items?.data?.[0]?.price?.unit_amount || 0, data.currency);
414414
const billingPeriod = getBillingPeriod(data.items?.data?.[0]?.price?.recurring?.interval);
415415

@@ -461,7 +461,7 @@ async function handleSubscriptionPaymentSucceeded(data: any) {
461461
// Extract payment information
462462
const amount = formatAmount(data.amount_paid, data.currency);
463463
const subscription = data.subscription;
464-
const planName = subscription ? getPlanName(subscription.items?.data?.[0]?.price?.id) : 'Premium Plan';
464+
const planName = subscription ? (getPlanName(subscription.items?.data?.[0]?.price?.id) || 'Premium Plan') : 'Premium Plan';
465465
const billingPeriod = subscription
466466
? getBillingPeriod(subscription.items?.data?.[0]?.price?.recurring?.interval)
467467
: 'month';
@@ -508,7 +508,7 @@ async function handleSubscriptionPaymentFailed(data: any) {
508508
// Extract payment information
509509
const amount = formatAmount(data.amount_due, data.currency);
510510
const subscription = data.subscription;
511-
const planName = subscription ? getPlanName(subscription.items?.data?.[0]?.price?.id) : 'Premium Plan';
511+
const planName = subscription ? (getPlanName(subscription.items?.data?.[0]?.price?.id) || 'Premium Plan') : 'Premium Plan';
512512
const billingPeriod = subscription
513513
? getBillingPeriod(subscription.items?.data?.[0]?.price?.recurring?.interval)
514514
: 'month';
@@ -555,7 +555,7 @@ async function handleSubscriptionTrialEnding(data: any) {
555555

556556
// Extract subscription information
557557
const priceId = data.items?.data?.[0]?.price?.id;
558-
const planName = getPlanName(priceId);
558+
const planName = getPlanName(priceId) || 'Premium Plan';
559559
const amount = formatAmount(data.items?.data?.[0]?.price?.unit_amount || 0, data.currency);
560560
const billingPeriod = getBillingPeriod(data.items?.data?.[0]?.price?.recurring?.interval);
561561

app/api/user/currency/route.ts

Lines changed: 9 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { z } from 'zod';
33
import { auth } from '@/lib/auth';
44
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;
5+
import { SUPPORTED_CURRENCIES } from '@/lib/config/billing';
496

507
const currencyUpdateSchema = z.object({
518
currency: z
@@ -92,7 +49,14 @@ export async function PUT(request: NextRequest) {
9249
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
9350
}
9451

95-
const body = await request.json();
52+
// Parse and validate JSON body
53+
let body: unknown;
54+
try {
55+
body = await request.json();
56+
} catch (parseError) {
57+
console.error('[API] Invalid JSON in currency update request:', parseError);
58+
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
59+
}
9660

9761
// Validate request body with Zod schema
9862
const validationResult = currencyUpdateSchema.safeParse(body);

hooks/use-auto-renewal.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { serverClient, apiUtils } from '@/lib/api/server-api-client';
77
import { useConfig } from '@/app/[locale]/config';
88
import { PaymentProvider } from '@/lib/constants';
99
import { useSelectedCheckoutProvider } from './use-selected-checkout-provider';
10+
import { usePaymentProvider } from '@/lib/utils/payment-provider';
1011

1112
export interface AutoRenewalStatus {
1213
subscriptionId: string;
@@ -208,23 +209,8 @@ export function useAutoRenewal(options: UseAutoRenewalOptions): UseAutoRenewalRe
208209

209210
const { getActiveProvider } = useSelectedCheckoutProvider();
210211

211-
/**
212-
* Determine payment provider with priority:
213-
* 1. User's selected provider from Settings
214-
* 2. Config default provider
215-
* 3. Fallback to Stripe
216-
*/
217-
const paymentProvider = useMemo(() => {
218-
const userSelectedProvider = getActiveProvider();
219-
220-
// Map from CheckoutProvider type to PaymentProvider enum
221-
if (userSelectedProvider === 'stripe') return PaymentProvider.STRIPE;
222-
if (userSelectedProvider === 'lemonsqueezy') return PaymentProvider.LEMONSQUEEZY;
223-
if (userSelectedProvider === 'polar') return PaymentProvider.POLAR;
224-
225-
// Fallback to config default if no user selection or provider not configured
226-
return config.pricing?.provider || PaymentProvider.STRIPE;
227-
}, [getActiveProvider, config.pricing?.provider]);
212+
// Determine payment provider: User selection takes precedence over config
213+
const paymentProvider = usePaymentProvider(getActiveProvider, config.pricing);
228214

229215
// ===================== Query =====================
230216

hooks/use-create-checkout.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client';
22
import { apiUtils, serverClient } from '@/lib/api/server-api-client';
3-
import { PaymentInterval } from '@/lib/constants';
3+
import { PaymentInterval, PaymentPlan } from '@/lib/constants';
44
import { PricingConfig, PricingPlans } from '@/lib/content';
55
import { getQueryClient } from '@/lib/query-client';
66
import { useMutation } from '@tanstack/react-query';
77
import { useRouter } from 'next/navigation';
8+
import { useCurrencyContext } from '@/components/context/currency-provider';
9+
import { getStripePriceConfig } from '@/lib/config/billing/stripe.config';
810

911
export interface SubscriptionFormProps {
1012
selectedPlan?: PricingPlans;
@@ -64,8 +66,32 @@ class CheckoutSessionError extends Error {
6466
}
6567
}
6668

69+
/**
70+
* Validates and maps plan.id to a valid plan name for billing configs
71+
* @param planId - The plan ID to validate
72+
* @returns The validated plan name ('free' | 'standard' | 'premium')
73+
* @throws CheckoutSessionError if planId is not a valid PaymentPlan
74+
*/
75+
function validateAndMapPlanName(planId: string): 'free' | 'standard' | 'premium' {
76+
const validPlans: Record<string, 'free' | 'standard' | 'premium'> = {
77+
[PaymentPlan.FREE]: 'free',
78+
[PaymentPlan.STANDARD]: 'standard',
79+
[PaymentPlan.PREMIUM]: 'premium'
80+
};
81+
82+
const planName = validPlans[planId];
83+
if (!planName) {
84+
throw new CheckoutSessionError(
85+
`Invalid plan ID: "${planId}". Expected one of: ${Object.values(PaymentPlan).join(', ')}`
86+
);
87+
}
88+
89+
return planName;
90+
}
91+
6792
export const useCreateCheckoutSession = () => {
6893
const router = useRouter();
94+
const { currency } = useCurrencyContext();
6995

7096
const invalidateQueries = async () => {
7197
const queryClient = getQueryClient();
@@ -95,7 +121,23 @@ export const useCreateCheckoutSession = () => {
95121
);
96122
}
97123

98-
const priceId = billingInterval === PaymentInterval.YEARLY ? plan.annualPriceId : plan.stripePriceId;
124+
// Get currency-aware price ID using multi-currency configs
125+
// Map plan.id to plan name for billing configs with explicit validation
126+
const planName = validateAndMapPlanName(plan.id);
127+
const interval = billingInterval === PaymentInterval.YEARLY ? 'yearly' : 'monthly';
128+
129+
// Try to get currency-aware price ID
130+
const currencyPriceConfig = getStripePriceConfig(planName, currency, interval);
131+
132+
// Use currency-aware price ID if available, otherwise fallback to plan's price ID
133+
let priceId: string | undefined;
134+
if (currencyPriceConfig?.priceId) {
135+
priceId = currencyPriceConfig.priceId;
136+
} else {
137+
// Fallback to plan's configured price ID
138+
priceId = billingInterval === PaymentInterval.YEARLY ? plan.annualPriceId : plan.stripePriceId;
139+
}
140+
99141
if (!priceId) {
100142
throw new CheckoutSessionError('Invalid price ID');
101143
}

0 commit comments

Comments
 (0)