feat: add credit purchase functionality with Stripe integration
- Introduced credit purchase payment intent actions in credits.action.ts. - Created new components for credit packages and Stripe payment form. - Added routes and layout for credits settings page. - Updated sidebar configuration to include credits settings. - Enhanced constants for credit packages with detailed pricing and descriptions. - Implemented loading and layout components for credits page. - Integrated payment confirmation handling in Stripe provider.
This commit is contained in:
parent
98421afab8
commit
fe2b1bbe39
@ -63,6 +63,7 @@ STORAGE_PUBLIC_URL=""
|
||||
# https://mksaas.com/docs/payment#setup
|
||||
# Get Stripe key and secret from https://dashboard.stripe.com
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
# Pro plan - monthly subscription
|
||||
|
@ -556,6 +556,13 @@
|
||||
"retry": "Retry",
|
||||
"errorMessage": "Failed to get data"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Credits",
|
||||
"description": "Manage your credits",
|
||||
"credits": "Credits",
|
||||
"creditsDescription": "You have {credits} credits",
|
||||
"creditsExpired": "Credits expired"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notification",
|
||||
"description": "Manage your notification preferences",
|
||||
|
@ -1,12 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
addMonthlyFreeCredits,
|
||||
addRegisterGiftCredits,
|
||||
consumeCredits,
|
||||
getUserCredits,
|
||||
addCredits,
|
||||
} from '@/lib/credits';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
import { createPaymentIntent, confirmPaymentIntent } from '@/payment';
|
||||
import { CREDIT_PACKAGES } from '@/lib/constants';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
@ -57,3 +63,84 @@ export const consumeCreditsAction = actionClient
|
||||
return { success: false, error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Credit purchase payment intent action
|
||||
const createPaymentIntentSchema = z.object({
|
||||
packageId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const createCreditPaymentIntent = actionClient
|
||||
.schema(createPaymentIntentSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const session = await getSession();
|
||||
if (!session) return { success: false, error: 'User not authenticated' };
|
||||
|
||||
const { packageId } = parsedInput;
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||
if (!creditPackage) {
|
||||
return { success: false, error: 'Invalid credit package' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Create payment intent
|
||||
const paymentIntent = await createPaymentIntent({
|
||||
amount: creditPackage.price * 100, // Convert to cents
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
packageId,
|
||||
userId: session.user.id,
|
||||
credits: creditPackage.credits.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
clientSecret: paymentIntent.clientSecret,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create credit payment intent error:', error);
|
||||
return { success: false, error: 'Failed to create payment intent' };
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm credit payment action
|
||||
const confirmPaymentSchema = z.object({
|
||||
packageId: z.string().min(1),
|
||||
paymentIntentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const confirmCreditPayment = actionClient
|
||||
.schema(confirmPaymentSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const session = await getSession();
|
||||
if (!session) return { success: false, error: 'User not authenticated' };
|
||||
|
||||
const { packageId, paymentIntentId } = parsedInput;
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||
if (!creditPackage) {
|
||||
return { success: false, error: 'Invalid credit package' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Confirm payment intent
|
||||
const isSuccessful = await confirmPaymentIntent({
|
||||
paymentIntentId,
|
||||
});
|
||||
|
||||
if (!isSuccessful) {
|
||||
return { success: false, error: 'Payment confirmation failed' };
|
||||
}
|
||||
|
||||
// Revalidate the credits page to show updated balance
|
||||
revalidatePath('/settings/credits');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Confirm credit payment error:', error);
|
||||
return { success: false, error: 'Failed to confirm payment' };
|
||||
}
|
||||
});
|
||||
|
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal file
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface CreditsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function CreditsLayout({ children }: CreditsLayoutProps) {
|
||||
const t = await getTranslations('Dashboard.settings');
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: t('title'),
|
||||
isCurrentPage: false,
|
||||
},
|
||||
{
|
||||
label: t('credits.title'),
|
||||
isCurrentPage: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="px-4 lg:px-6 space-y-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t('credits.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('credits.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
9
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal file
9
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { CreditPackages } from '@/components/settings/credits/credit-packages';
|
||||
|
||||
export default function CreditsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CreditPackages />
|
||||
</div>
|
||||
);
|
||||
}
|
216
src/components/settings/credits/credit-packages.tsx
Normal file
216
src/components/settings/credits/credit-packages.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CREDIT_PACKAGES } from '@/lib/constants';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Separator } from '../../ui/separator';
|
||||
import { StripePaymentForm } from './stripe-payment-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CreditPackages() {
|
||||
const [loadingPackage, setLoadingPackage] = useState<string | null>(null);
|
||||
const [paymentDialog, setPaymentDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
clientSecret: string | null;
|
||||
packageId: string | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
clientSecret: null,
|
||||
packageId: null,
|
||||
});
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchCredits = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getCreditsAction();
|
||||
if (result?.data?.success) {
|
||||
console.log('CreditPackages, fetched credits:', result.data.credits);
|
||||
setCredits(result.data.credits || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || 'Failed to fetch credits';
|
||||
console.error('CreditPackages, failed to fetch credits:', errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CreditPackages, failed to fetch credits:', error);
|
||||
toast.error('Failed to fetch credits');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredits();
|
||||
}, []);
|
||||
|
||||
const handlePurchase = async (packageId: string) => {
|
||||
try {
|
||||
setLoadingPackage(packageId);
|
||||
|
||||
const result = await createCreditPaymentIntent({ packageId });
|
||||
if (result?.data?.success && result?.data?.clientSecret) {
|
||||
setPaymentDialog({
|
||||
isOpen: true,
|
||||
clientSecret: result.data.clientSecret,
|
||||
packageId,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || 'Failed to create payment intent';
|
||||
console.error('CreditPackages, failed to create payment intent:', errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CreditPackages, failed to initiate payment:', error);
|
||||
toast.error('Failed to initiate payment');
|
||||
} finally {
|
||||
setLoadingPackage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = () => {
|
||||
console.log('CreditPackages, payment successful');
|
||||
setPaymentDialog({
|
||||
isOpen: false,
|
||||
clientSecret: null,
|
||||
packageId: null,
|
||||
});
|
||||
|
||||
// Refresh credit balance without page reload
|
||||
fetchCredits();
|
||||
|
||||
// Show success toast
|
||||
toast.success('Your credits have been added to your account');
|
||||
};
|
||||
|
||||
const handlePaymentCancel = () => {
|
||||
console.log('CreditPackages, payment cancelled');
|
||||
setPaymentDialog({
|
||||
isOpen: false,
|
||||
clientSecret: null,
|
||||
packageId: null,
|
||||
});
|
||||
};
|
||||
|
||||
const getPackageInfo = (packageId: string) => {
|
||||
return CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">Credit Balance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="animate-pulse">...</span>
|
||||
) : (
|
||||
credits?.toLocaleString() || 0
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-medium">Credit Packages</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Purchase additional credits to use our services
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{CREDIT_PACKAGES.map((pkg) => (
|
||||
<Card key={pkg.id} className={cn(`relative ${pkg.popular ? 'border-primary' : ''}`,
|
||||
'shadow-none border-1 border-border')}>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-3.5 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="default" className="bg-primary text-primary-foreground">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <CardHeader className="text-center">
|
||||
<CardTitle className="text-lg capitalize">{pkg.id}</CardTitle>
|
||||
</CardHeader> */}
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* Price and Credits - Left/Right Layout */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="text-left">
|
||||
<div className="text-2xl font-semibold flex items-center gap-2">
|
||||
<CoinsIcon className="h-4 w-4 text-muted-foreground" /> {pkg.credits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{formatPrice(pkg.price, 'USD')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground text-left py-2 flex items-center gap-2">
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
{pkg.description}
|
||||
</div>
|
||||
|
||||
{/* purchase button */}
|
||||
<Button
|
||||
onClick={() => handlePurchase(pkg.id)}
|
||||
disabled={loadingPackage === pkg.id}
|
||||
className="w-full cursor-pointer"
|
||||
variant={pkg.popular ? 'default' : 'outline'}
|
||||
>
|
||||
{loadingPackage === pkg.id ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Purchase'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment Dialog */}
|
||||
<Dialog open={paymentDialog.isOpen} onOpenChange={handlePaymentCancel}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Complete Your Purchase</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentDialog.clientSecret && paymentDialog.packageId && (
|
||||
<StripePaymentForm
|
||||
clientSecret={paymentDialog.clientSecret}
|
||||
packageId={paymentDialog.packageId}
|
||||
packageInfo={getPackageInfo(paymentDialog.packageId)!}
|
||||
onPaymentSuccess={handlePaymentSuccess}
|
||||
onPaymentCancel={handlePaymentCancel}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
191
src/components/settings/credits/stripe-payment-form.tsx
Normal file
191
src/components/settings/credits/stripe-payment-form.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { confirmCreditPayment } from '@/actions/credits.action';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface StripePaymentFormProps {
|
||||
clientSecret: string;
|
||||
packageId: string;
|
||||
packageInfo: {
|
||||
credits: number;
|
||||
price: number;
|
||||
description: string;
|
||||
};
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentCancel: () => void;
|
||||
}
|
||||
|
||||
export function StripePaymentForm(props: StripePaymentFormProps) {
|
||||
const { resolvedTheme: theme } = useTheme();
|
||||
const stripePromise = useMemo(() => {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
|
||||
}
|
||||
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}, []);
|
||||
|
||||
const options = useMemo(() => ({
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: {
|
||||
theme: (theme === "dark" ? "night" : "stripe") as "night" | "stripe",
|
||||
},
|
||||
loader: 'auto' as const,
|
||||
}), [props.clientSecret, theme]);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={options}>
|
||||
<PaymentForm {...props} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentFormProps {
|
||||
clientSecret: string;
|
||||
packageId: string;
|
||||
packageInfo: {
|
||||
credits: number;
|
||||
price: number;
|
||||
description: string;
|
||||
};
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentCancel: () => void;
|
||||
}
|
||||
|
||||
function PaymentForm({
|
||||
clientSecret,
|
||||
packageId,
|
||||
packageInfo,
|
||||
onPaymentSuccess,
|
||||
onPaymentCancel,
|
||||
}: PaymentFormProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
console.error('Stripe or elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
// Confirm the payment using PaymentElement
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('PaymentForm, payment error:', error);
|
||||
throw new Error(error.message || "Payment failed");
|
||||
} else {
|
||||
// The payment was successful
|
||||
const paymentIntent = await stripe.retrievePaymentIntent(clientSecret);
|
||||
if (paymentIntent.paymentIntent) {
|
||||
const result = await confirmCreditPayment({
|
||||
packageId,
|
||||
paymentIntentId: paymentIntent.paymentIntent.id,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
console.log('PaymentForm, payment success');
|
||||
toast.success(`${packageInfo.credits} credits have been added to your account.`);
|
||||
onPaymentSuccess();
|
||||
} else {
|
||||
console.error('PaymentForm, payment error:', result?.data?.error);
|
||||
throw new Error(
|
||||
result?.data?.error ||
|
||||
result?.serverError ||
|
||||
'Failed to confirm payment'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error('PaymentForm, no payment intent found');
|
||||
throw new Error("No payment intent found");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('PaymentForm, payment error:', error);
|
||||
toast.error('Purchase credits failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CoinsIcon className="h-4 w-4" />
|
||||
<div className="text-2xl font-bold">
|
||||
{packageInfo.credits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{formatPrice(packageInfo.price, 'USD')}
|
||||
</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
We use Stripe, a trusted global payment provider, to process your payment.
|
||||
For your security, your payment details are handled directly by Stripe and never touch our servers.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<PaymentElement />
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPaymentCancel}
|
||||
disabled={processing}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !stripe || !elements}
|
||||
className="px-8 cursor-pointer"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Pay {formatPrice(packageInfo.price, 'USD')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import type { NestedMenuItem } from '@/types';
|
||||
import {
|
||||
BellIcon,
|
||||
CircleUserRoundIcon,
|
||||
CoinsIcon,
|
||||
CreditCardIcon,
|
||||
LayoutDashboardIcon,
|
||||
LockKeyholeIcon,
|
||||
@ -66,6 +67,12 @@ export function getSidebarLinks(): NestedMenuItem[] {
|
||||
href: Routes.SettingsBilling,
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: t('settings.credits.title'),
|
||||
icon: <CoinsIcon className="size-4 shrink-0" />,
|
||||
href: Routes.SettingsCredits,
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: t('settings.security.title'),
|
||||
icon: <LockKeyholeIcon className="size-4 shrink-0" />,
|
||||
|
@ -1,12 +1,37 @@
|
||||
export const PLACEHOLDER_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg==';
|
||||
|
||||
// credit package definition (example)
|
||||
// credit package definition (price in cents)
|
||||
export const CREDIT_PACKAGES = [
|
||||
{ id: 'package-1', credits: 1000, price: 10 },
|
||||
{ id: 'package-2', credits: 2500, price: 20 },
|
||||
{ id: 'package-3', credits: 5000, price: 30 },
|
||||
];
|
||||
{
|
||||
id: 'basic',
|
||||
credits: 100,
|
||||
price: 990, // 9.90 USD in cents
|
||||
popular: false,
|
||||
description: 'Perfect for getting started',
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
credits: 200,
|
||||
price: 1490, // 14.90 USD in cents
|
||||
popular: true,
|
||||
description: 'Most popular package',
|
||||
},
|
||||
{
|
||||
id: 'premium',
|
||||
credits: 500,
|
||||
price: 3990, // 39.90 USD in cents
|
||||
popular: false,
|
||||
description: 'Best value for heavy users',
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
credits: 1000,
|
||||
price: 6990, // 69.90 USD in cents
|
||||
popular: false,
|
||||
description: 'Tailored for enterprises',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// free monthly credits (10% of the smallest package)
|
||||
export const FREE_MONTHLY_CREDITS = 50;
|
||||
|
@ -2,8 +2,11 @@ import { websiteConfig } from '@/config/website';
|
||||
import { StripeProvider } from './provider/stripe';
|
||||
import type {
|
||||
CheckoutResult,
|
||||
ConfirmPaymentIntentParams,
|
||||
CreateCheckoutParams,
|
||||
CreatePaymentIntentParams,
|
||||
CreatePortalParams,
|
||||
PaymentIntentResult,
|
||||
PaymentProvider,
|
||||
PortalResult,
|
||||
Subscription,
|
||||
@ -92,3 +95,27 @@ export const getSubscriptions = async (
|
||||
const provider = getPaymentProvider();
|
||||
return provider.getSubscriptions(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a payment intent
|
||||
* @param params Parameters for creating the payment intent
|
||||
* @returns Payment intent result
|
||||
*/
|
||||
export const createPaymentIntent = async (
|
||||
params: CreatePaymentIntentParams
|
||||
): Promise<PaymentIntentResult> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.createPaymentIntent(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm a payment intent
|
||||
* @param params Parameters for confirming the payment intent
|
||||
* @returns True if successful
|
||||
*/
|
||||
export const confirmPaymentIntent = async (
|
||||
params: ConfirmPaymentIntentParams
|
||||
): Promise<boolean> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.confirmPaymentIntent(params);
|
||||
};
|
||||
|
@ -7,12 +7,17 @@ import {
|
||||
findPriceInPlan,
|
||||
} from '@/lib/price-plan';
|
||||
import { sendNotification } from '@/notification/notification';
|
||||
import { addCredits } from '@/lib/credits';
|
||||
import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { Stripe } from 'stripe';
|
||||
import {
|
||||
type CheckoutResult,
|
||||
type ConfirmPaymentIntentParams,
|
||||
type CreateCheckoutParams,
|
||||
type CreatePaymentIntentParams,
|
||||
type CreatePortalParams,
|
||||
type PaymentIntentResult,
|
||||
type PaymentProvider,
|
||||
type PaymentStatus,
|
||||
PaymentTypes,
|
||||
@ -397,6 +402,12 @@ export class StripeProvider implements PaymentProvider {
|
||||
await this.onOnetimePayment(session);
|
||||
}
|
||||
}
|
||||
} else if (eventType.startsWith('payment_intent.')) {
|
||||
// Handle payment intent events
|
||||
if (eventType === 'payment_intent.succeeded') {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
await this.onPaymentIntentSucceeded(paymentIntent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('handle webhook event error:', error);
|
||||
@ -632,6 +643,97 @@ export class StripeProvider implements PaymentProvider {
|
||||
await sendNotification(session.id, customerId, userId, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payment intent succeeded event
|
||||
* @param paymentIntent Stripe payment intent
|
||||
*/
|
||||
private async onPaymentIntentSucceeded(
|
||||
paymentIntent: Stripe.PaymentIntent
|
||||
): Promise<void> {
|
||||
console.log(`>> Handle payment intent succeeded: ${paymentIntent.id}`);
|
||||
|
||||
// Get metadata from payment intent
|
||||
const { packageId, userId, credits } = paymentIntent.metadata;
|
||||
|
||||
if (!packageId || !userId || !credits) {
|
||||
console.warn(
|
||||
`<< Missing metadata for payment intent ${paymentIntent.id}: packageId=${packageId}, userId=${userId}, credits=${credits}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add credits to user account using existing addCredits method
|
||||
await addCredits({
|
||||
userId,
|
||||
amount: parseInt(credits),
|
||||
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
|
||||
description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`,
|
||||
paymentId: paymentIntent.id,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`<< Error processing payment intent ${paymentIntent.id}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment intent
|
||||
* @param params Parameters for creating the payment intent
|
||||
* @returns Payment intent result
|
||||
*/
|
||||
public async createPaymentIntent(
|
||||
params: CreatePaymentIntentParams
|
||||
): Promise<PaymentIntentResult> {
|
||||
const { amount, currency, metadata } = params;
|
||||
|
||||
try {
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
metadata,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: paymentIntent.id,
|
||||
clientSecret: paymentIntent.client_secret!,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create payment intent error:', error);
|
||||
throw new Error('Failed to create payment intent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a payment intent
|
||||
* @param params Parameters for confirming the payment intent
|
||||
* @returns True if successful
|
||||
*/
|
||||
public async confirmPaymentIntent(
|
||||
params: ConfirmPaymentIntentParams
|
||||
): Promise<boolean> {
|
||||
const { paymentIntentId } = params;
|
||||
|
||||
try {
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
|
||||
return paymentIntent.status === 'succeeded';
|
||||
} catch (error) {
|
||||
console.error('Confirm payment intent error:', error);
|
||||
throw new Error('Failed to confirm payment intent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Stripe subscription interval to our own interval types
|
||||
* @param subscription Stripe subscription
|
||||
|
@ -159,6 +159,30 @@ export interface getSubscriptionsParams {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a payment intent
|
||||
*/
|
||||
export interface CreatePaymentIntentParams {
|
||||
amount: number;
|
||||
currency: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a payment intent
|
||||
*/
|
||||
export interface PaymentIntentResult {
|
||||
id: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for confirming a payment intent
|
||||
*/
|
||||
export interface ConfirmPaymentIntentParams {
|
||||
paymentIntentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment provider interface
|
||||
*/
|
||||
@ -178,6 +202,16 @@ export interface PaymentProvider {
|
||||
*/
|
||||
getSubscriptions(params: getSubscriptionsParams): Promise<Subscription[]>;
|
||||
|
||||
/**
|
||||
* Create a payment intent
|
||||
*/
|
||||
createPaymentIntent(params: CreatePaymentIntentParams): Promise<PaymentIntentResult>;
|
||||
|
||||
/**
|
||||
* Confirm a payment intent
|
||||
*/
|
||||
confirmPaymentIntent(params: ConfirmPaymentIntentParams): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Handle webhook events
|
||||
*/
|
||||
|
@ -33,6 +33,7 @@ export enum Routes {
|
||||
AdminUsers = '/admin/users',
|
||||
SettingsProfile = '/settings/profile',
|
||||
SettingsBilling = '/settings/billing',
|
||||
SettingsCredits = '/settings/credits',
|
||||
SettingsSecurity = '/settings/security',
|
||||
SettingsNotifications = '/settings/notifications',
|
||||
|
||||
@ -76,6 +77,7 @@ export const protectedRoutes = [
|
||||
Routes.AdminUsers,
|
||||
Routes.SettingsProfile,
|
||||
Routes.SettingsBilling,
|
||||
Routes.SettingsCredits,
|
||||
Routes.SettingsSecurity,
|
||||
Routes.SettingsNotifications,
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user