feat: implement credit checkout session and enhance credit package management
- Added new credit checkout session functionality to facilitate credit purchases. - Introduced credit package configurations in env.example for better management. - Updated English and Chinese JSON files with new messages for checkout processes. - Refactored existing components to utilize the new credit checkout session and streamline the credit purchasing workflow. - Removed obsolete payment intent handling to simplify the codebase.
This commit is contained in:
parent
adb9b80572
commit
a7738f0cbf
@ -72,6 +72,14 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=""
|
||||
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
|
||||
# Lifetime plan - one-time payment
|
||||
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
|
||||
# Credit package - basic
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=""
|
||||
# Credit package - standard
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD=""
|
||||
# Credit package - premium
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM=""
|
||||
# Credit package - enterprise
|
||||
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurations
|
||||
|
@ -602,7 +602,10 @@
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"cancel": "Cancel",
|
||||
"purchaseFailed": "Purchase credits failed",
|
||||
"pay": "Pay"
|
||||
"checkoutFailed": "Failed to create checkout session",
|
||||
"loading": "Loading...",
|
||||
"pay": "Pay",
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"tabs": {
|
||||
"balance": "Balance",
|
||||
|
@ -603,7 +603,10 @@
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"cancel": "取消",
|
||||
"purchaseFailed": "购买积分失败",
|
||||
"pay": "支付"
|
||||
"checkoutFailed": "创建支付会话失败",
|
||||
"loading": "加载中...",
|
||||
"pay": "支付",
|
||||
"notConfigured": "未配置"
|
||||
},
|
||||
"tabs": {
|
||||
"balance": "积分余额",
|
||||
|
@ -64,7 +64,7 @@ export const createCheckoutAction = actionClient
|
||||
if (!plan) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Plan not found',
|
||||
error: 'Price plan not found',
|
||||
};
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ export const createCheckoutAction = actionClient
|
||||
|
||||
// Create the checkout session with localized URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
'/settings/billing?session_id={CHECKOUT_SESSION_ID}',
|
||||
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.Pricing, locale);
|
||||
|
124
src/actions/create-credit-checkout-session.ts
Normal file
124
src/actions/create-credit-checkout-session.ts
Normal file
@ -0,0 +1,124 @@
|
||||
'use server';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { getCreditPackageByIdInServer } from '@/credits/server';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { createCreditCheckout } from '@/payment';
|
||||
import type { CreateCreditCheckoutParams } from '@/payment/types';
|
||||
import { Routes } from '@/routes';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Credit checkout schema for validation
|
||||
// metadata is optional, and may contain referral information if you need
|
||||
const creditCheckoutSchema = z.object({
|
||||
userId: z.string().min(1, { message: 'User ID is required' }),
|
||||
packageId: z.string().min(1, { message: 'Package ID is required' }),
|
||||
priceId: z.string().min(1, { message: 'Price ID is required' }),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a checkout session for a credit package
|
||||
*/
|
||||
export const createCreditCheckoutSession = actionClient
|
||||
.schema(creditCheckoutSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { userId, packageId, priceId, metadata } = parsedInput;
|
||||
|
||||
// Get the current user session for authorization
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
console.warn(
|
||||
`unauthorized request to create credit checkout session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
// Only allow users to create their own checkout session
|
||||
if (session.user.id !== userId) {
|
||||
console.warn(
|
||||
`current user ${session.user.id} is not authorized to create credit checkout session for user ${userId}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authorized to do this action',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the current locale from the request
|
||||
const locale = await getLocale();
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = getCreditPackageByIdInServer(packageId);
|
||||
if (!creditPackage) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Credit package not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata to identify this as a credit purchase
|
||||
const customMetadata: Record<string, string> = {
|
||||
...metadata,
|
||||
type: 'credit_purchase',
|
||||
packageId,
|
||||
credits: creditPackage.credits.toString(),
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
};
|
||||
|
||||
// https://datafa.st/docs/stripe-checkout-api
|
||||
// if datafast analytics is enabled, add the revenue attribution to the metadata
|
||||
if (websiteConfig.features.enableDatafastRevenueTrack) {
|
||||
const cookieStore = await cookies();
|
||||
customMetadata.datafast_visitor_id =
|
||||
cookieStore.get('datafast_visitor_id')?.value ?? '';
|
||||
customMetadata.datafast_session_id =
|
||||
cookieStore.get('datafast_session_id')?.value ?? '';
|
||||
}
|
||||
|
||||
// Create checkout session with credit-specific URLs
|
||||
const successUrl = getUrlWithLocale(
|
||||
`${Routes.SettingsCredits}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
locale
|
||||
);
|
||||
const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale);
|
||||
|
||||
const params: CreateCreditCheckoutParams = {
|
||||
packageId,
|
||||
priceId,
|
||||
customerEmail: session.user.email,
|
||||
metadata: customMetadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
const result = await createCreditCheckout(params);
|
||||
// console.log('create credit checkout session result:', result);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create credit checkout session error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create checkout session',
|
||||
};
|
||||
}
|
||||
});
|
@ -6,11 +6,8 @@ import {
|
||||
consumeCredits,
|
||||
getUserCredits,
|
||||
} from '@/credits/credits';
|
||||
import { getCreditPackageByIdInServer } from '@/credits/server';
|
||||
import { getSession } from '@/lib/server';
|
||||
import { confirmPaymentIntent, createPaymentIntent } from '@/payment';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
const actionClient = createSafeActionClient();
|
||||
@ -62,92 +59,3 @@ 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: 'Unauthorized' };
|
||||
|
||||
const { packageId } = parsedInput;
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = getCreditPackageByIdInServer(packageId);
|
||||
if (!creditPackage) {
|
||||
return { success: false, error: 'Invalid credit package' };
|
||||
}
|
||||
|
||||
const customMetadata: Record<string, string> = {
|
||||
packageId,
|
||||
price: creditPackage.price.toString(),
|
||||
credits: creditPackage.credits.toString(),
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
};
|
||||
|
||||
try {
|
||||
// Create payment intent
|
||||
const paymentIntent = await createPaymentIntent({
|
||||
amount: creditPackage.price,
|
||||
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: 'Unauthorized' };
|
||||
|
||||
const { packageId, paymentIntentId } = parsedInput;
|
||||
|
||||
// Find the credit package
|
||||
const creditPackage = getCreditPackageByIdInServer(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' };
|
||||
}
|
||||
});
|
||||
|
149
src/components/settings/credits/credit-checkout-button.tsx
Normal file
149
src/components/settings/credits/credit-checkout-button.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { createCreditCheckoutSession } from '@/actions/create-credit-checkout-session';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreditCheckoutButtonProps {
|
||||
userId: string;
|
||||
packageId: string;
|
||||
priceId: string;
|
||||
metadata?: Record<string, string>;
|
||||
variant?:
|
||||
| 'default'
|
||||
| 'outline'
|
||||
| 'destructive'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'link'
|
||||
| null;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon' | null;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit Checkout Button
|
||||
*
|
||||
* This client component creates a Stripe checkout session for credit purchases
|
||||
* and redirects to it. It's used to initiate the credit purchase process.
|
||||
*
|
||||
* NOTICE: Login is required when using this button.
|
||||
*/
|
||||
export function CreditCheckoutButton({
|
||||
userId,
|
||||
packageId,
|
||||
priceId,
|
||||
metadata,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
}: CreditCheckoutButtonProps) {
|
||||
const t = useTranslations('Dashboard.settings.credits.packages');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Get the credit package to find the priceId
|
||||
// const creditPackages = getCreditPackages();
|
||||
// const creditPackage = creditPackages[packageId];
|
||||
|
||||
// if (!creditPackage) {
|
||||
// toast.error('Invalid credit package');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!creditPackage.price.priceId) {
|
||||
// toast.error(t('notConfigured'));
|
||||
// return;
|
||||
// }
|
||||
|
||||
const mergedMetadata = metadata ? { ...metadata } : {};
|
||||
|
||||
// add promotekit_referral to metadata if enabled promotekit affiliate
|
||||
if (websiteConfig.features.enablePromotekitAffiliate) {
|
||||
const promotekitReferral =
|
||||
typeof window !== 'undefined'
|
||||
? (window as any).promotekit_referral
|
||||
: undefined;
|
||||
if (promotekitReferral) {
|
||||
console.log(
|
||||
'create credit checkout button, promotekitReferral:',
|
||||
promotekitReferral
|
||||
);
|
||||
mergedMetadata.promotekit_referral = promotekitReferral;
|
||||
}
|
||||
}
|
||||
|
||||
// add affonso_referral to metadata if enabled affonso affiliate
|
||||
if (websiteConfig.features.enableAffonsoAffiliate) {
|
||||
const affonsoReferral =
|
||||
typeof document !== 'undefined'
|
||||
? (() => {
|
||||
const match = document.cookie.match(
|
||||
/(?:^|; )affonso_referral=([^;]*)/
|
||||
);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
})()
|
||||
: null;
|
||||
if (affonsoReferral) {
|
||||
console.log(
|
||||
'create credit checkout button, affonsoReferral:',
|
||||
affonsoReferral
|
||||
);
|
||||
mergedMetadata.affonso_referral = affonsoReferral;
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkout session using server action
|
||||
const result = await createCreditCheckoutSession({
|
||||
userId,
|
||||
packageId,
|
||||
priceId,
|
||||
metadata:
|
||||
Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
|
||||
});
|
||||
|
||||
// Redirect to checkout page
|
||||
if (result?.data?.success && result.data.data?.url) {
|
||||
window.location.href = result.data.data?.url;
|
||||
} else {
|
||||
console.error('Create credit checkout session error, result:', result);
|
||||
toast.error(t('checkoutFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create credit checkout session error:', error);
|
||||
toast.error(t('checkoutFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
{t('loading')}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -1,11 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createCreditPaymentIntent,
|
||||
getCreditsAction,
|
||||
} from '@/actions/credits.action';
|
||||
import { getCreditsAction } from '@/actions/credits.action';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -13,22 +9,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import type { CreditPackage } from '@/credits/types';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTransactionStore } from '@/stores/transaction-store';
|
||||
import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { StripePaymentForm } from './stripe-payment-form';
|
||||
import { CreditCheckoutButton } from './credit-checkout-button';
|
||||
|
||||
/**
|
||||
* Credit packages component
|
||||
@ -37,19 +29,11 @@ import { StripePaymentForm } from './stripe-payment-form';
|
||||
export function CreditPackages() {
|
||||
const t = useTranslations('Dashboard.settings.credits.packages');
|
||||
const [loadingCredits, setLoadingCredits] = useState(true);
|
||||
const [loadingPackage, setLoadingPackage] = useState<string | null>(null);
|
||||
const [credits, setCredits] = useState<number | null>(null);
|
||||
const { refreshTrigger } = useTransactionStore();
|
||||
|
||||
const [paymentDialog, setPaymentDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
packageId: string | null;
|
||||
clientSecret: string | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
packageId: null,
|
||||
clientSecret: null,
|
||||
});
|
||||
const currentUser = useCurrentUser();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useLocaleRouter();
|
||||
|
||||
const creditPackages = Object.values(getCreditPackages());
|
||||
|
||||
@ -73,61 +57,28 @@ export function CreditPackages() {
|
||||
}
|
||||
};
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
const sessionId = searchParams.get('session_id');
|
||||
if (sessionId) {
|
||||
// Show success toast
|
||||
toast.success(t('creditsAdded'));
|
||||
|
||||
// Refresh credits data to show updated balance
|
||||
fetchCredits();
|
||||
|
||||
// Clean up URL parameters
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('session_id');
|
||||
router.replace(url.pathname + url.search);
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Initial fetch and listen for transaction updates
|
||||
useEffect(() => {
|
||||
fetchCredits();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
const handlePurchase = async (packageId: string) => {
|
||||
try {
|
||||
setLoadingPackage(packageId);
|
||||
const result = await createCreditPaymentIntent({ packageId });
|
||||
if (result?.data?.success && result?.data?.clientSecret) {
|
||||
setPaymentDialog({
|
||||
isOpen: true,
|
||||
packageId,
|
||||
clientSecret: result.data.clientSecret,
|
||||
});
|
||||
} else {
|
||||
const errorMessage =
|
||||
result?.data?.error || t('failedToCreatePaymentIntent');
|
||||
console.error(
|
||||
'CreditPackages, failed to create payment intent:',
|
||||
errorMessage
|
||||
);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('CreditPackages, failed to initiate payment:', error);
|
||||
toast.error(t('failedToInitiatePayment'));
|
||||
} finally {
|
||||
setLoadingPackage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentSuccess = () => {
|
||||
console.log('CreditPackages, payment success');
|
||||
setPaymentDialog({
|
||||
isOpen: false,
|
||||
packageId: null,
|
||||
clientSecret: null,
|
||||
});
|
||||
toast.success(t('creditsAdded'));
|
||||
};
|
||||
|
||||
const handlePaymentCancel = () => {
|
||||
console.log('CreditPackages, payment cancelled');
|
||||
setPaymentDialog({
|
||||
isOpen: false,
|
||||
packageId: null,
|
||||
clientSecret: null,
|
||||
});
|
||||
};
|
||||
|
||||
const getPackageInfo = (packageId: string): CreditPackage | undefined => {
|
||||
return creditPackages.find((pkg) => pkg.id === packageId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="w-full">
|
||||
@ -161,15 +112,15 @@ export function CreditPackages() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{creditPackages.map((pkg) => (
|
||||
{creditPackages.map((creditPackage) => (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
key={creditPackage.id}
|
||||
className={cn(
|
||||
`relative ${pkg.popular ? 'border-primary' : ''}`,
|
||||
`relative ${creditPackage.popular ? 'border-primary' : ''}`,
|
||||
'shadow-none border-1 border-border'
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
{creditPackage.popular && (
|
||||
<div className="absolute -top-3.5 left-1/2 transform -translate-x-1/2">
|
||||
<Badge
|
||||
variant="default"
|
||||
@ -186,64 +137,43 @@ export function CreditPackages() {
|
||||
<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()}
|
||||
{creditPackage.credits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
{formatPrice(pkg.price, 'USD')}
|
||||
{formatPrice(
|
||||
creditPackage.price.amount,
|
||||
creditPackage.price.currency
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground text-left py-2 flex items-center gap-2">
|
||||
<CircleCheckBigIcon className="h-4 w-4 text-green-500" />
|
||||
{pkg.description}
|
||||
{creditPackage.description}
|
||||
</div>
|
||||
|
||||
{/* purchase button */}
|
||||
<Button
|
||||
onClick={() => handlePurchase(pkg.id)}
|
||||
disabled={loadingPackage === pkg.id}
|
||||
{/* purchase button using checkout */}
|
||||
<CreditCheckoutButton
|
||||
userId={currentUser?.id ?? ''}
|
||||
packageId={creditPackage.id}
|
||||
priceId={creditPackage.price.priceId}
|
||||
className="w-full cursor-pointer"
|
||||
variant={pkg.popular ? 'default' : 'outline'}
|
||||
variant={creditPackage.popular ? 'default' : 'outline'}
|
||||
disabled={!creditPackage.price.priceId}
|
||||
>
|
||||
{loadingPackage === pkg.id ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('processing')}
|
||||
</>
|
||||
) : (
|
||||
t('purchase')
|
||||
)}
|
||||
</Button>
|
||||
{!creditPackage.price.priceId
|
||||
? t('notConfigured')
|
||||
: t('purchase')}
|
||||
</CreditCheckoutButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment Dialog */}
|
||||
<Dialog open={paymentDialog.isOpen} onOpenChange={handlePaymentCancel}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('completePurchase')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentDialog.clientSecret &&
|
||||
paymentDialog.packageId &&
|
||||
getPackageInfo(paymentDialog.packageId) && (
|
||||
<StripePaymentForm
|
||||
clientSecret={paymentDialog.clientSecret}
|
||||
packageId={paymentDialog.packageId}
|
||||
packageInfo={getPackageInfo(paymentDialog.packageId)!}
|
||||
onPaymentSuccess={handlePaymentSuccess}
|
||||
onPaymentCancel={handlePaymentCancel}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,196 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { confirmCreditPayment } from '@/actions/credits.action';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CreditPackage } from '@/credits/types';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { useTransactionStore } from '@/stores/transaction-store';
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface StripePaymentFormProps {
|
||||
clientSecret: string;
|
||||
packageId: string;
|
||||
packageInfo: CreditPackage;
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* StripePaymentForm is a component that displays a payment form for a credit package.
|
||||
* It uses the Stripe Elements API to display a payment form.
|
||||
*
|
||||
* @param props - The props for the StripePaymentForm component.
|
||||
* @returns The StripePaymentForm component.
|
||||
*/
|
||||
export function StripePaymentForm(props: StripePaymentFormProps) {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
|
||||
}
|
||||
|
||||
const stripePromise = useMemo(() => {
|
||||
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
}, []);
|
||||
|
||||
const { resolvedTheme: theme } = useTheme();
|
||||
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: CreditPackage;
|
||||
onPaymentSuccess: () => void;
|
||||
onPaymentCancel: () => void;
|
||||
}
|
||||
|
||||
function PaymentForm({
|
||||
clientSecret,
|
||||
packageId,
|
||||
packageInfo,
|
||||
onPaymentSuccess,
|
||||
onPaymentCancel,
|
||||
}: PaymentFormProps) {
|
||||
const t = useTranslations('Dashboard.settings.credits.packages');
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { triggerRefresh } = useTransactionStore();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 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');
|
||||
// Trigger refresh for transaction-dependent UI components
|
||||
triggerRefresh();
|
||||
|
||||
// Show success toast
|
||||
onPaymentSuccess();
|
||||
// toast.success(`${packageInfo.credits} credits have been added to your account.`);
|
||||
} else {
|
||||
console.error('PaymentForm, payment error:', result?.data?.error);
|
||||
throw new Error(result?.data?.error || '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(t('purchaseFailed'));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="space-y-2">
|
||||
<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>
|
||||
{/* <div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CircleCheckBigIcon className="h-4 w-4 text-green-500" />
|
||||
{packageInfo.description}
|
||||
</div> */}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</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"
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || !stripe || !elements}
|
||||
className="px-8 cursor-pointer"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
{t('processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('pay')} {/* {formatPrice(packageInfo.price, 'USD')} */}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -143,27 +143,47 @@ export const websiteConfig: WebsiteConfig = {
|
||||
packages: {
|
||||
basic: {
|
||||
id: 'basic',
|
||||
credits: 100,
|
||||
price: 990,
|
||||
popular: false,
|
||||
credits: 100,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC!,
|
||||
amount: 990,
|
||||
currency: 'USD',
|
||||
allowPromotionCode: true,
|
||||
},
|
||||
},
|
||||
standard: {
|
||||
id: 'standard',
|
||||
credits: 200,
|
||||
price: 1490,
|
||||
popular: true,
|
||||
credits: 200,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!,
|
||||
amount: 1490,
|
||||
currency: 'USD',
|
||||
allowPromotionCode: true,
|
||||
},
|
||||
},
|
||||
premium: {
|
||||
id: 'premium',
|
||||
credits: 500,
|
||||
price: 3990,
|
||||
popular: false,
|
||||
credits: 500,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!,
|
||||
amount: 3990,
|
||||
currency: 'USD',
|
||||
allowPromotionCode: true,
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
id: 'enterprise',
|
||||
credits: 1000,
|
||||
price: 6990,
|
||||
popular: false,
|
||||
credits: 1000,
|
||||
price: {
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!,
|
||||
amount: 6990,
|
||||
currency: 'USD',
|
||||
allowPromotionCode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -9,13 +9,20 @@ export enum CREDIT_TRANSACTION_TYPE {
|
||||
EXPIRE = 'EXPIRE', // Credits expired
|
||||
}
|
||||
|
||||
export interface CreditPackagePrice {
|
||||
priceId: string; // Stripe price ID (not product id)
|
||||
amount: number; // Price amount in currency units (dollars, euros, etc.)
|
||||
currency: string; // Currency code (e.g., USD)
|
||||
allowPromotionCode?: boolean; // Whether to allow promotion code for this price
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit package
|
||||
*/
|
||||
export interface CreditPackage {
|
||||
id: string; // Unique identifier for the package
|
||||
credits: number; // Number of credits in the package
|
||||
price: number; // Price of the package in cents
|
||||
price: CreditPackagePrice; // Price of the package
|
||||
popular: boolean; // Whether the package is popular
|
||||
name?: string; // Display name of the package
|
||||
description?: string; // Description of the package
|
||||
|
@ -2,11 +2,9 @@ import { websiteConfig } from '@/config/website';
|
||||
import { StripeProvider } from './provider/stripe';
|
||||
import type {
|
||||
CheckoutResult,
|
||||
ConfirmPaymentIntentParams,
|
||||
CreateCheckoutParams,
|
||||
CreatePaymentIntentParams,
|
||||
CreateCreditCheckoutParams,
|
||||
CreatePortalParams,
|
||||
PaymentIntentResult,
|
||||
PaymentProvider,
|
||||
PortalResult,
|
||||
Subscription,
|
||||
@ -59,6 +57,18 @@ export const createCheckout = async (
|
||||
return provider.createCheckout(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a checkout session for a credit package
|
||||
* @param params Parameters for creating the checkout session
|
||||
* @returns Checkout result
|
||||
*/
|
||||
export const createCreditCheckout = async (
|
||||
params: CreateCreditCheckoutParams
|
||||
): Promise<CheckoutResult> => {
|
||||
const provider = getPaymentProvider();
|
||||
return provider.createCreditCheckout(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
* @param params Parameters for creating the portal
|
||||
@ -95,27 +105,3 @@ 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);
|
||||
};
|
||||
|
@ -1,23 +1,18 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getDb } from '@/db';
|
||||
import { payment, session, user } from '@/db/schema';
|
||||
import {
|
||||
findPlanByPlanId,
|
||||
findPlanByPriceId,
|
||||
findPriceInPlan,
|
||||
} from '@/lib/price-plan';
|
||||
import { sendNotification } from '@/notification/notification';
|
||||
import { addCredits } from '@/credits/credits';
|
||||
import { getCreditPackageByIdInServer } from '@/credits/server';
|
||||
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
|
||||
import { getDb } from '@/db';
|
||||
import { payment, user } from '@/db/schema';
|
||||
import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan';
|
||||
import { sendNotification } from '@/notification/notification';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { Stripe } from 'stripe';
|
||||
import {
|
||||
type CheckoutResult,
|
||||
type ConfirmPaymentIntentParams,
|
||||
type CreateCheckoutParams,
|
||||
type CreatePaymentIntentParams,
|
||||
type CreateCreditCheckoutParams,
|
||||
type CreatePortalParams,
|
||||
type PaymentIntentResult,
|
||||
type PaymentProvider,
|
||||
type PaymentStatus,
|
||||
PaymentTypes,
|
||||
@ -284,6 +279,104 @@ export class StripeProvider implements PaymentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkout session for a plan
|
||||
* @param params Parameters for creating the checkout session
|
||||
* @returns Checkout result
|
||||
*/
|
||||
public async createCreditCheckout(
|
||||
params: CreateCreditCheckoutParams
|
||||
): Promise<CheckoutResult> {
|
||||
const {
|
||||
packageId,
|
||||
priceId,
|
||||
customerEmail,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
metadata,
|
||||
locale,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// Get credit package
|
||||
const creditPackage = getCreditPackageByIdInServer(packageId);
|
||||
if (!creditPackage) {
|
||||
throw new Error(`Credit package with ID ${packageId} not found`);
|
||||
}
|
||||
|
||||
// Get priceId from credit package
|
||||
const priceId = creditPackage.price.priceId;
|
||||
if (!priceId) {
|
||||
throw new Error(`Price ID not found for credit package ${packageId}`);
|
||||
}
|
||||
|
||||
// Get userName from metadata if available
|
||||
const userName = metadata?.userName;
|
||||
|
||||
// Create or get customer
|
||||
const customerId = await this.createOrGetCustomer(
|
||||
customerEmail,
|
||||
userName
|
||||
);
|
||||
|
||||
// Add planId and priceId to metadata, so we can get it in the webhook event
|
||||
const customMetadata = {
|
||||
...metadata,
|
||||
packageId,
|
||||
priceId,
|
||||
};
|
||||
|
||||
// Set up the line items
|
||||
const lineItems = [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Create checkout session parameters
|
||||
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
|
||||
line_items: lineItems,
|
||||
mode: 'payment',
|
||||
success_url: successUrl ?? '',
|
||||
cancel_url: cancelUrl ?? '',
|
||||
metadata: customMetadata,
|
||||
allow_promotion_codes: creditPackage.price.allowPromotionCode ?? false,
|
||||
};
|
||||
|
||||
// Add customer to checkout session
|
||||
checkoutParams.customer = customerId;
|
||||
|
||||
// Add locale if provided
|
||||
if (locale) {
|
||||
checkoutParams.locale = this.mapLocaleToStripeLocale(
|
||||
locale
|
||||
) as Stripe.Checkout.SessionCreateParams.Locale;
|
||||
}
|
||||
|
||||
// Add payment intent data for one-time payments
|
||||
checkoutParams.payment_intent_data = {
|
||||
metadata: customMetadata,
|
||||
};
|
||||
// Automatically create an invoice for the one-time payment
|
||||
checkoutParams.invoice_creation = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Create the checkout session
|
||||
const session =
|
||||
await this.stripe.checkout.sessions.create(checkoutParams);
|
||||
|
||||
return {
|
||||
url: session.url!,
|
||||
id: session.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Create credit checkout session error:', error);
|
||||
throw new Error('Failed to create credit checkout session');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
* @param params Parameters for creating the portal
|
||||
@ -399,7 +492,11 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
// Only process one-time payments (likely for lifetime plan)
|
||||
if (session.mode === 'payment') {
|
||||
await this.onOnetimePayment(session);
|
||||
if (session.metadata?.type === 'credit_purchase') {
|
||||
await this.onCreditPurchase(session);
|
||||
} else {
|
||||
await this.onOnetimePayment(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (eventType.startsWith('payment_intent.')) {
|
||||
@ -610,37 +707,107 @@ export class StripeProvider implements PaymentProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a one-time payment record
|
||||
const now = new Date();
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.insert(payment)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
priceId: priceId,
|
||||
type: PaymentTypes.ONE_TIME,
|
||||
userId: userId,
|
||||
customerId: customerId,
|
||||
status: 'completed', // One-time payments are always completed
|
||||
periodStart: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({ id: payment.id });
|
||||
try {
|
||||
// Create a one-time payment record
|
||||
const now = new Date();
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.insert(payment)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
priceId: priceId,
|
||||
type: PaymentTypes.ONE_TIME,
|
||||
userId: userId,
|
||||
customerId: customerId,
|
||||
status: 'completed', // One-time payments are always completed
|
||||
periodStart: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({ id: payment.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
console.warn(
|
||||
`<< Failed to create one-time payment record for user ${userId}`
|
||||
if (result.length === 0) {
|
||||
console.warn(
|
||||
`<< Failed to create one-time payment record for user ${userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
|
||||
);
|
||||
|
||||
// Send notification
|
||||
const amount = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await sendNotification(session.id, customerId, userId, amount);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`<< onOnetimePayment error for session ${session.id}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credit purchase
|
||||
* @param session Stripe checkout session
|
||||
*/
|
||||
private async onCreditPurchase(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId = session.customer as string;
|
||||
console.log(`>> Handle credit purchase for customer ${customerId}`);
|
||||
|
||||
// get userId from session metadata, we add it in the createCheckout session
|
||||
const userId = session.metadata?.userId;
|
||||
if (!userId) {
|
||||
console.warn(`<< No userId found for checkout session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
|
||||
);
|
||||
|
||||
// Send notification
|
||||
const amount = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await sendNotification(session.id, customerId, userId, amount);
|
||||
// get packageId from session metadata
|
||||
const packageId = session.metadata?.packageId;
|
||||
if (!packageId) {
|
||||
console.warn(`<< No packageId found for checkout session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// get priceId from session metadata, not from line items
|
||||
// const priceId = session.line_items?.data[0]?.price?.id;
|
||||
const priceId = session.metadata?.priceId;
|
||||
if (!priceId) {
|
||||
console.warn(`<< No priceId found for checkout session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// get credits from session metadata
|
||||
const credits = session.metadata?.credits;
|
||||
if (!credits) {
|
||||
console.warn(`<< No credits found for checkout session ${session.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add credits to user account using existing addCredits method
|
||||
const amount = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await addCredits({
|
||||
userId,
|
||||
amount: Number.parseInt(credits),
|
||||
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
|
||||
description: `Credit package purchase: ${packageId} - ${credits} credits for $${amount}`,
|
||||
paymentId: session.id,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`<< Added ${credits} credits to user ${userId} for $${amount}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`<< onCreditPurchase error for session ${session.id}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -666,7 +833,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
// Add credits to user account using existing addCredits method
|
||||
await addCredits({
|
||||
userId,
|
||||
amount: parseInt(credits),
|
||||
amount: Number.parseInt(credits),
|
||||
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
|
||||
description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`,
|
||||
paymentId: paymentIntent.id,
|
||||
@ -684,56 +851,6 @@ export class StripeProvider implements PaymentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -128,6 +128,19 @@ export interface CreateCheckoutParams {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a credit checkout session
|
||||
*/
|
||||
export interface CreateCreditCheckoutParams {
|
||||
packageId: string;
|
||||
priceId: string;
|
||||
customerEmail: string;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
metadata?: Record<string, string>;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a checkout session
|
||||
*/
|
||||
@ -159,30 +172,6 @@ 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
|
||||
*/
|
||||
@ -192,6 +181,11 @@ export interface PaymentProvider {
|
||||
*/
|
||||
createCheckout(params: CreateCheckoutParams): Promise<CheckoutResult>;
|
||||
|
||||
/**
|
||||
* Create a credit checkout session
|
||||
*/
|
||||
createCreditCheckout(params: CreateCreditCheckoutParams): Promise<CheckoutResult>;
|
||||
|
||||
/**
|
||||
* Create a customer portal session
|
||||
*/
|
||||
@ -202,16 +196,6 @@ 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
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user