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:
javayhu 2025-07-09 00:22:07 +08:00
parent adb9b80572
commit a7738f0cbf
14 changed files with 611 additions and 568 deletions

View File

@ -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

View File

@ -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",

View File

@ -603,7 +603,10 @@
"creditsAdded": "积分已添加到您的账户",
"cancel": "取消",
"purchaseFailed": "购买积分失败",
"pay": "支付"
"checkoutFailed": "创建支付会话失败",
"loading": "加载中...",
"pay": "支付",
"notConfigured": "未配置"
},
"tabs": {
"balance": "积分余额",

View File

@ -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);

View 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',
};
}
});

View File

@ -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' };
}
});

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
},
},
},
},

View File

@ -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

View File

@ -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);
};

View File

@ -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

View File

@ -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
*/