feat: Allow to pick currency when starting subscription
This commit is contained in:
@@ -190,6 +190,7 @@ export class Users {
|
|||||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
const {currency} = req.query;
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -245,6 +246,17 @@ export class Users {
|
|||||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
|
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
|
||||||
|
|
||||||
|
// Validate currency if provided
|
||||||
|
let checkoutCurrency: string | undefined;
|
||||||
|
if (currency && typeof currency === 'string') {
|
||||||
|
const validCurrencies = ['usd', 'eur', 'gbp'];
|
||||||
|
if (validCurrencies.includes(currency.toLowerCase())) {
|
||||||
|
checkoutCurrency = currency.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create checkout session
|
// Create checkout session
|
||||||
// Note: proration_behavior cannot be set when one-time prices are included
|
// Note: proration_behavior cannot be set when one-time prices are included
|
||||||
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
|
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
|
||||||
@@ -253,6 +265,7 @@ export class Users {
|
|||||||
customer: project.customer ?? undefined, // Use existing customer if available
|
customer: project.customer ?? undefined, // Use existing customer if available
|
||||||
client_reference_id: project.id, // Store project ID for webhook
|
client_reference_id: project.id, // Store project ID for webhook
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
|
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
billing_cycle_anchor: billingCycleAnchor,
|
billing_cycle_anchor: billingCycleAnchor,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
SelectItemWithDescription,
|
SelectItemWithDescription,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -105,6 +106,8 @@ export default function Settings() {
|
|||||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||||
const [resetConfirmText, setResetConfirmText] = useState('');
|
const [resetConfirmText, setResetConfirmText] = useState('');
|
||||||
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
|
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
|
||||||
|
const [selectedCurrency, setSelectedCurrency] = useState<string>('auto');
|
||||||
|
const [showCurrencySelector, setShowCurrencySelector] = useState(false);
|
||||||
|
|
||||||
// Fetch current user's membership for the active project
|
// Fetch current user's membership for the active project
|
||||||
const {data: membershipData} = useSWR<{
|
const {data: membershipData} = useSWR<{
|
||||||
@@ -252,7 +255,7 @@ export default function Settings() {
|
|||||||
setShowRegenerateDialog(true);
|
setShowRegenerateDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartSubscription = async () => {
|
const handleStartSubscription = async (currency: string = 'auto') => {
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
if (!billingEnabled) {
|
if (!billingEnabled) {
|
||||||
setErrorMessage('Billing is disabled on this instance.');
|
setErrorMessage('Billing is disabled on this instance.');
|
||||||
@@ -263,7 +266,13 @@ export default function Settings() {
|
|||||||
setIsLoadingBilling(true);
|
setIsLoadingBilling(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
const response = await network.fetch<{url: string}>('POST', `/users/@me/projects/${activeProject.id}/checkout`);
|
// Build URL with optional currency parameter
|
||||||
|
const url =
|
||||||
|
currency === 'auto'
|
||||||
|
? `/users/@me/projects/${activeProject.id}/checkout`
|
||||||
|
: `/users/@me/projects/${activeProject.id}/checkout?currency=${currency}`;
|
||||||
|
|
||||||
|
const response = await network.fetch<{url: string}>('POST', url);
|
||||||
|
|
||||||
// Redirect to Stripe checkout
|
// Redirect to Stripe checkout
|
||||||
if (response.url) {
|
if (response.url) {
|
||||||
@@ -666,10 +675,49 @@ export default function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-start">
|
<div className="flex flex-col gap-2">
|
||||||
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
|
<div className="flex justify-start">
|
||||||
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
|
<Button onClick={() => handleStartSubscription(selectedCurrency)} disabled={isLoadingBilling}>
|
||||||
</Button>
|
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCurrencySelector(!showCurrencySelector)}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors w-fit"
|
||||||
|
>
|
||||||
|
{showCurrencySelector ? 'Hide currency options' : 'Select a different currency'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCurrencySelector && (
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, height: 0}}
|
||||||
|
animate={{opacity: 1, height: 'auto'}}
|
||||||
|
exit={{opacity: 0, height: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<label className="text-sm font-medium text-neutral-600">Currency:</label>
|
||||||
|
<Select value={selectedCurrency} onValueChange={setSelectedCurrency}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||||
|
<SelectItem value="usd">USD ($) - US Dollar</SelectItem>
|
||||||
|
<SelectItem value="eur">EUR (€) - Euro</SelectItem>
|
||||||
|
<SelectItem value="gbp">GBP (£) - British Pound</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user