feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
Stripe 集成重构: - 从 Checkout Session 改为 PaymentIntent + Payment Element 模式 - 前端内联渲染 Stripe 支付表单,支持信用卡、支付宝等多种方式 - Webhook 事件改为 payment_intent.succeeded / payment_intent.payment_failed - provider/test 同步更新 iframe 嵌入模式 (ui_mode=embedded): - 支付宝等需跳转的方式改为弹出新窗口处理,避免 X-Frame-Options 冲破 iframe - 信用卡等无跳转方式仍在 iframe 内联完成 - 弹窗使用 confirmAlipayPayment 直接跳转,无需二次操作 - result 页面检测弹窗模式,支付成功后自动关闭窗口 Bug 修复: - 修复配置加载前支付方式闪烁(初始值改为空数组 + loading) - 修复桌面端 PaymentForm 缺少 methodLimits prop - 修复 stripeError 隐藏表单导致无法重试 - 快捷金额增加 1000/2000 选项,过滤低于 minAmount 的选项 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,50 +32,38 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const stripe = this.getClient();
|
||||
const env = getEnv();
|
||||
|
||||
const timeoutMinutes = Math.max(30, env.ORDER_TIMEOUT_MINUTES); // Stripe minimum is 30 minutes
|
||||
const amountInCents = Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber());
|
||||
|
||||
const session = await stripe.checkout.sessions.create(
|
||||
const pi = await stripe.paymentIntents.create(
|
||||
{
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'cny',
|
||||
product_data: { name: request.subject },
|
||||
unit_amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
amount: amountInCents,
|
||||
currency: 'cny',
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata: { orderId: request.orderId },
|
||||
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
|
||||
success_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=success`,
|
||||
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=cancelled`,
|
||||
description: request.subject,
|
||||
},
|
||||
{ idempotencyKey: `checkout-${request.orderId}` },
|
||||
{ idempotencyKey: `pi-${request.orderId}` },
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
checkoutUrl: session.url || undefined,
|
||||
tradeNo: pi.id,
|
||||
clientSecret: pi.client_secret || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const stripe = this.getClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(tradeNo);
|
||||
const pi = await stripe.paymentIntents.retrieve(tradeNo);
|
||||
|
||||
let status: QueryOrderResponse['status'] = 'pending';
|
||||
if (session.payment_status === 'paid') status = 'paid';
|
||||
else if (session.status === 'expired') status = 'failed';
|
||||
if (pi.status === 'succeeded') status = 'paid';
|
||||
else if (pi.status === 'canceled') status = 'failed';
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
tradeNo: pi.id,
|
||||
status,
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,23 +79,23 @@ export class StripeProvider implements PaymentProvider {
|
||||
env.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
status: session.payment_status === 'paid' ? 'success' : 'failed',
|
||||
tradeNo: pi.id,
|
||||
orderId: pi.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
status: 'success',
|
||||
rawData: event,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.async_payment_failed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (event.type === 'payment_intent.payment_failed') {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
tradeNo: pi.id,
|
||||
orderId: pi.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
status: 'failed',
|
||||
rawData: event,
|
||||
};
|
||||
@@ -120,12 +108,9 @@ export class StripeProvider implements PaymentProvider {
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const stripe = this.getClient();
|
||||
|
||||
// Retrieve checkout session to find the payment intent
|
||||
const session = await stripe.checkout.sessions.retrieve(request.tradeNo);
|
||||
if (!session.payment_intent) throw new Error('No payment intent found for session');
|
||||
|
||||
// tradeNo is now the PaymentIntent ID directly
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id,
|
||||
payment_intent: request.tradeNo,
|
||||
amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
reason: 'requested_by_customer',
|
||||
});
|
||||
@@ -138,6 +123,6 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
const stripe = this.getClient();
|
||||
await stripe.checkout.sessions.expire(tradeNo);
|
||||
await stripe.paymentIntents.cancel(tradeNo);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user