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:
javayhu 2025-07-05 22:30:22 +08:00
parent 98421afab8
commit fe2b1bbe39
14 changed files with 764 additions and 5 deletions

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
];