5 Commits

Author SHA1 Message Date
eriol touwa
d461880a9e Merge pull request #2 from dexcoder6/fix/stripe-popup-security
fix: Stripe 弹窗安全加固 + 清理未使用依赖
2026-03-04 18:11:14 +08:00
erio
69cf0d00d1 fix: 添加 packageManager 字段修复 CI pnpm 版本检测 2026-03-04 18:10:24 +08:00
miwei
d7d91857c7 fix: Stripe 弹窗安全加固 + 清理未使用依赖
安全修复:
- client_secret 和 publishableKey 不再通过 URL 传递,改用 postMessage
  弹窗发送 STRIPE_POPUP_READY 信号,父页面响应 STRIPE_POPUP_INIT 传递敏感数据
  校验 event.origin 防止跨域消息伪造
- confirmAlipayPayment 改为显式调用,移除动态方法查找
- handleStripeSubmit 中 returnUrl 清理残留 query params

依赖清理:
- 移除未使用的 @stripe/react-stripe-js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 15:27:51 +08:00
eriol touwa
84f38f985f Merge pull request #1 from dexcoder6/feat/stripe-embedded-popup
feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
2026-03-04 14:43:30 +08:00
miwei
964a2aa6d9 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>
2026-03-04 11:11:46 +08:00
14 changed files with 753 additions and 271 deletions

View File

@@ -146,7 +146,7 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> Subscribe to: `checkout.session.completed`, `checkout.session.expired`
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
### Business Rules
@@ -310,7 +310,7 @@ User submits recharge amount
User completes payment
├─ EasyPay → QR code / H5 redirect
└─ Stripe → Checkout Session
└─ Stripe → Payment Element (PaymentIntent)
Payment callback (signature verified) → Order PAID

View File

@@ -146,7 +146,7 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> 需订阅事件:`checkout.session.completed`、`checkout.session.expired`
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
### 业务规则
@@ -310,7 +310,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
用户完成支付
├─ EasyPay → 扫码 / H5 跳转
└─ Stripe → Checkout Session
└─ Stripe → Payment Element (PaymentIntent)
支付回调(签名验证)→ 订单 PAID

View File

@@ -2,6 +2,7 @@
"name": "sub2apipay",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -16,6 +17,7 @@
"dependencies": {
"@prisma/adapter-pg": "7.4.1",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"next": "16.1.6",
"pg": "^8.19.0",
"qrcode": "^1.5.4",

9
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@prisma/client':
specifier: ^7.4.2
version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@stripe/stripe-js':
specifier: ^8.9.0
version: 8.9.0
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -874,6 +877,10 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/stripe-js@8.9.0':
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -3613,6 +3620,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@stripe/stripe-js@8.9.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1

View File

@@ -9,18 +9,18 @@ vi.mock('@/lib/config', () => ({
}),
}));
const mockSessionCreate = vi.fn();
const mockSessionRetrieve = vi.fn();
const mockPaymentIntentCreate = vi.fn();
const mockPaymentIntentRetrieve = vi.fn();
const mockPaymentIntentCancel = vi.fn();
const mockRefundCreate = vi.fn();
const mockWebhooksConstructEvent = vi.fn();
vi.mock('stripe', () => {
const StripeMock = function (this: Record<string, unknown>) {
this.checkout = {
sessions: {
create: mockSessionCreate,
retrieve: mockSessionRetrieve,
},
this.paymentIntents = {
create: mockPaymentIntentCreate,
retrieve: mockPaymentIntentRetrieve,
cancel: mockPaymentIntentCancel,
};
this.refunds = {
create: mockRefundCreate,
@@ -54,10 +54,10 @@ describe('StripeProvider', () => {
});
describe('createPayment', () => {
it('should create a checkout session and return checkoutUrl', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_abc123',
url: 'https://checkout.stripe.com/pay/cs_test_abc123',
it('should create a PaymentIntent and return clientSecret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_abc123',
client_secret: 'pi_test_abc123_secret_xyz',
});
const request: CreatePaymentRequest = {
@@ -70,34 +70,26 @@ describe('StripeProvider', () => {
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_abc123');
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123');
expect(mockSessionCreate).toHaveBeenCalledWith(
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.clientSecret).toBe('pi_test_abc123_secret_xyz');
expect(mockPaymentIntentCreate).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'payment',
payment_method_types: ['card'],
amount: 9999,
currency: 'cny',
automatic_payment_methods: { enabled: true },
metadata: { orderId: 'order-001' },
expires_at: expect.any(Number),
line_items: [
expect.objectContaining({
price_data: expect.objectContaining({
currency: 'cny',
unit_amount: 9999,
}),
quantity: 1,
}),
],
description: 'Sub2API Balance Recharge 99.99 CNY',
}),
expect.objectContaining({
idempotencyKey: 'checkout-order-001',
idempotencyKey: 'pi-order-001',
}),
);
});
it('should handle session with null url', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_no_url',
url: null,
it('should handle null client_secret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_no_secret',
client_secret: null,
});
const request: CreatePaymentRequest = {
@@ -108,61 +100,58 @@ describe('StripeProvider', () => {
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_no_url');
expect(result.checkoutUrl).toBeUndefined();
expect(result.tradeNo).toBe('pi_test_no_secret');
expect(result.clientSecret).toBeUndefined();
});
});
describe('queryOrder', () => {
it('should return paid status for paid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_status: 'paid',
amount_total: 9999,
it('should return paid status for succeeded PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_abc123',
status: 'succeeded',
amount: 9999,
});
const result = await provider.queryOrder('cs_test_abc123');
expect(result.tradeNo).toBe('cs_test_abc123');
const result = await provider.queryOrder('pi_test_abc123');
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.status).toBe('paid');
expect(result.amount).toBe(99.99);
});
it('should return failed status for expired session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_expired',
payment_status: 'unpaid',
status: 'expired',
amount_total: 5000,
it('should return failed status for canceled PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_canceled',
status: 'canceled',
amount: 5000,
});
const result = await provider.queryOrder('cs_test_expired');
const result = await provider.queryOrder('pi_test_canceled');
expect(result.status).toBe('failed');
expect(result.amount).toBe(50);
});
it('should return pending status for unpaid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_pending',
payment_status: 'unpaid',
status: 'open',
amount_total: 1000,
it('should return pending status for requires_payment_method', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_pending',
status: 'requires_payment_method',
amount: 1000,
});
const result = await provider.queryOrder('cs_test_pending');
const result = await provider.queryOrder('pi_test_pending');
expect(result.status).toBe('pending');
});
});
describe('verifyNotification', () => {
it('should verify and parse checkout.session.completed event', async () => {
it('should verify and parse payment_intent.succeeded event', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'cs_test_abc123',
id: 'pi_test_abc123',
metadata: { orderId: 'order-001' },
amount_total: 9999,
payment_status: 'paid',
amount: 9999,
},
},
};
@@ -172,21 +161,20 @@ describe('StripeProvider', () => {
const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' });
expect(result).not.toBeNull();
expect(result!.tradeNo).toBe('cs_test_abc123');
expect(result!.tradeNo).toBe('pi_test_abc123');
expect(result!.orderId).toBe('order-001');
expect(result!.amount).toBe(99.99);
expect(result!.status).toBe('success');
});
it('should return failed status for unpaid session', async () => {
it('should return failed status for payment_intent.payment_failed', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'cs_test_unpaid',
id: 'pi_test_failed',
metadata: { orderId: 'order-002' },
amount_total: 5000,
payment_status: 'unpaid',
amount: 5000,
},
},
};
@@ -210,19 +198,14 @@ describe('StripeProvider', () => {
});
describe('refund', () => {
it('should refund via payment intent from session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: 'pi_test_payment_intent',
});
it('should refund directly using PaymentIntent ID', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_001',
status: 'succeeded',
});
const request: RefundRequest = {
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-001',
amount: 50,
reason: 'customer request',
@@ -232,50 +215,34 @@ describe('StripeProvider', () => {
expect(result.refundId).toBe('re_test_refund_001');
expect(result.status).toBe('success');
expect(mockRefundCreate).toHaveBeenCalledWith({
payment_intent: 'pi_test_payment_intent',
payment_intent: 'pi_test_abc123',
amount: 5000,
reason: 'requested_by_customer',
});
});
it('should handle payment intent as object', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: { id: 'pi_test_obj_intent', amount: 10000 },
});
it('should handle pending refund status', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_002',
status: 'pending',
});
const result = await provider.refund({
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-002',
amount: 100,
});
expect(result.status).toBe('pending');
expect(mockRefundCreate).toHaveBeenCalledWith(
expect.objectContaining({
payment_intent: 'pi_test_obj_intent',
}),
);
});
});
it('should throw if no payment intent found', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_no_pi',
payment_intent: null,
});
describe('cancelPayment', () => {
it('should cancel a PaymentIntent', async () => {
mockPaymentIntentCancel.mockResolvedValue({ id: 'pi_test_abc123', status: 'canceled' });
await expect(
provider.refund({
tradeNo: 'cs_test_no_pi',
orderId: 'order-003',
amount: 20,
}),
).rejects.toThrow('No payment intent found');
await provider.cancelPayment('pi_test_abc123');
expect(mockPaymentIntentCancel).toHaveBeenCalledWith('pi_test_abc123');
});
});
});

View File

@@ -29,6 +29,9 @@ export async function GET(request: NextRequest) {
methodLimits,
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey: env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
},
});
} catch (error) {

View File

@@ -18,7 +18,7 @@ interface OrderResult {
paymentType: 'alipay' | 'wxpay' | 'stripe';
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
clientSecret?: string | null;
expiresAt: string;
}
@@ -30,6 +30,7 @@ interface AppConfig {
methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
stripePublishableKey?: string | null;
}
function PayContent() {
@@ -59,7 +60,7 @@ function PayContent() {
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
enabledPaymentTypes: [],
minAmount: 1,
maxAmount: 1000,
maxDailyAmount: 0,
@@ -108,6 +109,7 @@ function PayContent() {
methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
});
}
} else if (cfgRes.status === 404) {
@@ -261,7 +263,7 @@ function PayContent() {
paymentType: data.paymentType || paymentType,
payUrl: data.payUrl,
qrCode: data.qrCode,
checkoutUrl: data.checkoutUrl,
clientSecret: data.clientSecret,
expiresAt: data.expiresAt,
});
@@ -377,7 +379,16 @@ function PayContent() {
</div>
)}
{step === 'form' && (
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</span>
</div>
)}
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (
@@ -465,7 +476,8 @@ function PayContent() {
token={token || undefined}
payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl}
clientSecret={orderResult.clientSecret}
stripePublishableKey={config.stripePublishableKey}
paymentType={orderResult.paymentType}
amount={orderResult.amount}
payAmount={orderResult.payAmount}
@@ -473,6 +485,7 @@ function PayContent() {
onStatusChange={handleStatusChange}
onBack={handleBack}
dark={isDark}
isEmbedded={isEmbedded}
/>
)}

View File

@@ -8,9 +8,18 @@ function ResultContent() {
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const isPopup = searchParams.get('popup') === '1';
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isInPopup, setIsInPopup] = useState(false);
// Detect if opened as a popup window (from stripe-popup or via popup=1 param)
useEffect(() => {
if (isPopup || window.opener) {
setIsInPopup(true);
}
}, [isPopup]);
useEffect(() => {
if (!outTradeNo) {
@@ -42,6 +51,17 @@ function ResultContent() {
};
}, [outTradeNo]);
// Auto-close popup window on success
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
useEffect(() => {
if (!isInPopup || !isSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, isSuccess]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
@@ -50,7 +70,6 @@ function ResultContent() {
);
}
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const isPending = status === 'PENDING';
return (
@@ -65,12 +84,33 @@ function ResultContent() {
<p className="mt-2 text-gray-500">
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
</p>
{isInPopup && (
<div className="mt-4 space-y-2">
<p className="text-sm text-gray-400"> 3 </p>
<button
type="button"
onClick={() => window.close()}
className="text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
)}
</>
) : isPending ? (
<>
<div className="text-6xl text-yellow-500"></div>
<h1 className="mt-4 text-xl font-bold text-yellow-600"></h1>
<p className="mt-2 text-gray-500"></p>
{isInPopup && (
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
)}
</>
) : (
<>
@@ -85,6 +125,15 @@ function ResultContent() {
? '订单已被取消'
: '请联系管理员处理'}
</p>
{isInPopup && (
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
)}
</>
)}

View File

@@ -0,0 +1,284 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, useCallback, Suspense } from 'react';
function StripePopupContent() {
const searchParams = useSearchParams();
const orderId = searchParams.get('order_id') || '';
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || '';
const isDark = theme === 'dark';
const isAlipay = method === 'alipay';
// Sensitive data received via postMessage from parent, NOT from URL
const [credentials, setCredentials] = useState<{
clientSecret: string;
publishableKey: string;
} | null>(null);
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
const buildReturnUrl = useCallback(() => {
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
returnUrl.searchParams.set('popup', '1');
return returnUrl.toString();
}, [orderId]);
// Listen for credentials from parent window via postMessage
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'STRIPE_POPUP_INIT') return;
const { clientSecret, publishableKey } = event.data;
if (clientSecret && publishableKey) {
setCredentials({ clientSecret, publishableKey });
}
};
window.addEventListener('message', handler);
// Signal parent that popup is ready to receive data
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
// Initialize Stripe once credentials are received
useEffect(() => {
if (!credentials) return;
let cancelled = false;
const { clientSecret, publishableKey } = credentials;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(publishableKey).then((stripe) => {
if (cancelled || !stripe) {
if (!cancelled) {
setStripeError('支付组件加载失败,请关闭窗口重试');
setStripeLoaded(true);
}
return;
}
if (isAlipay) {
// Alipay: confirm directly and redirect, no Payment Element needed
stripe.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
}).then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || '支付失败,请重试');
setStripeLoaded(true);
}
// If no error, the page has already been redirected
});
return;
}
// Fallback: create Elements for Payment Element flow
const elements = stripe.elements({
clientSecret,
appearance: {
theme: isDark ? 'night' : 'stripe',
variables: { borderRadius: '8px' },
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => { cancelled = true; };
}, [credentials, isDark, isAlipay, buildReturnUrl]);
// Mount Payment Element (only for non-alipay methods)
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
const existing = stripeLib.elements.getElement('payment');
if (existing) {
existing.mount(node);
} else {
stripeLib.elements.create('payment', { layout: 'tabs' }).mount(node);
}
},
[stripeLib],
);
const handleSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: buildReturnUrl(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
setStripeSubmitting(false);
}
};
// Auto-close after success
useEffect(() => {
if (!stripeSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 2000);
return () => clearTimeout(timer);
}, [stripeSuccess]);
// Waiting for credentials from parent
if (!credentials) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
</div>
</div>
</div>
);
}
// Alipay direct confirm: show loading/redirecting state
if (isAlipay) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<button
type="button"
onClick={() => window.close()}
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
</div>
)}
</div>
</div>
);
}
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
</div>
{!stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<div className="text-5xl text-green-600">{'\u2713'}</div>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</p>
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
) : (
<>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
)}
<div
ref={stripeContainerRef}
className={`rounded-lg border p-4 ${isDark ? 'border-slate-700 bg-slate-800' : 'border-gray-200 bg-white'}`}
/>
<button
type="button"
disabled={stripeSubmitting}
onClick={handleSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
)}
</button>
</>
)}
</div>
</div>
);
}
export default function StripePopupPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<StripePopupContent />
</Suspense>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo {
@@ -25,7 +25,7 @@ interface PaymentFormProps {
dark?: boolean;
}
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500];
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
const AMOUNT_TEXT_PATTERN = /^\d*(\.\d{0,2})?$/;
function hasValidCentPrecision(num: number): boolean {
@@ -48,6 +48,13 @@ export default function PaymentForm({
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
const [customAmount, setCustomAmount] = useState('');
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
useEffect(() => {
if (!enabledPaymentTypes.includes(paymentType)) {
setPaymentType(enabledPaymentTypes[0] || 'stripe');
}
}, [enabledPaymentTypes, paymentType]);
const handleQuickAmount = (val: number) => {
setAmount(val);
setCustomAmount(String(val));
@@ -159,7 +166,7 @@ export default function PaymentForm({
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => (
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
@@ -222,69 +229,71 @@ export default function PaymentForm({
);
})()}
{/* Payment Type */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
</label>
<div className="flex gap-3">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const isSelected = paymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
{/* Payment Type — only show when multiple types available */}
{enabledPaymentTypes.length > 1 && (
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
</label>
<div className="flex gap-3">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const isSelected = paymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
return (
<button
key={type}
type="button"
disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)}
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
className={[
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
isUnavailable
? dark
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')}
>
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
>
{meta.sublabel}
</span>
) : null}
return (
<button
key={type}
type="button"
disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)}
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
className={[
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
isUnavailable
? dark
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')}
>
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
>
{meta.sublabel}
</span>
) : null}
</span>
</span>
</span>
</button>
);
})}
</div>
</button>
);
})}
</div>
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
</p>
);
})()}
</div>
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
</p>
);
})()}
</div>
)}
{/* Fee Detail */}
{feeRate > 0 && selectedAmount > 0 && (

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import QRCode from 'qrcode';
interface PaymentQRCodeProps {
@@ -8,7 +8,8 @@ interface PaymentQRCodeProps {
token?: string;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
clientSecret?: string | null;
stripePublishableKey?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe';
amount: number;
payAmount?: number;
@@ -16,6 +17,7 @@ interface PaymentQRCodeProps {
onStatusChange: (status: string) => void;
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
@@ -26,21 +28,13 @@ const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
function isSafeCheckoutUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && parsed.hostname.endsWith('.stripe.com');
} catch {
return false;
}
}
export default function PaymentQRCode({
orderId,
token,
payUrl,
qrCode,
checkoutUrl,
clientSecret,
stripePublishableKey,
paymentType,
amount,
payAmount: payAmountProp,
@@ -48,6 +42,7 @@ export default function PaymentQRCode({
onStatusChange,
onBack,
dark = false,
isEmbedded = false,
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
@@ -55,9 +50,22 @@ export default function PaymentQRCode({
const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false);
const [stripeOpened, setStripeOpened] = useState(false);
const [cancelBlocked, setCancelBlocked] = useState(false);
// Stripe Payment Element state
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
// Track selected payment method in Payment Element (for embedded popup decision)
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
const [popupBlocked, setPopupBlocked] = useState(false);
const paymentMethodListenerAdded = useRef(false);
const qrPayload = useMemo(() => {
const value = (qrCode || payUrl || '').trim();
return value;
@@ -97,6 +105,135 @@ export default function PaymentQRCode({
};
}, [qrPayload]);
// Initialize Stripe Payment Element
const isStripe = paymentType === 'stripe';
useEffect(() => {
if (!isStripe || !clientSecret || !stripePublishableKey) return;
let cancelled = false;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(stripePublishableKey).then((stripe) => {
if (cancelled) return;
if (!stripe) {
setStripeError('支付组件加载失败,请刷新页面重试');
setStripeLoaded(true);
return;
}
const elements = stripe.elements({
clientSecret,
appearance: {
theme: dark ? 'night' : 'stripe',
variables: {
borderRadius: '8px',
},
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => {
cancelled = true;
};
}, [isStripe, clientSecret, stripePublishableKey, dark]);
// Mount Payment Element when container is available
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
let pe = stripeLib.elements.getElement('payment');
if (pe) {
pe.mount(node);
} else {
pe = stripeLib.elements.create('payment', { layout: 'tabs' });
pe.mount(node);
}
if (!paymentMethodListenerAdded.current) {
paymentMethodListenerAdded.current = true;
pe.on('change', (event: { value?: { type?: string } }) => {
if (event.value?.type) {
setStripePaymentMethod(event.value.type);
}
});
}
},
[stripeLib],
);
const handleStripeSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
if (isEmbedded && stripePaymentMethod === 'alipay') {
handleOpenPopup();
return;
}
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeSubmitting(false);
} else {
// Payment succeeded (or no redirect needed)
setStripeSuccess(true);
setStripeSubmitting(false);
// Polling will pick up the status change
}
};
const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false);
// Only pass display params in URL — sensitive data sent via postMessage
const popupUrl = new URL(window.location.href);
popupUrl.pathname = '/pay/stripe-popup';
popupUrl.search = '';
popupUrl.searchParams.set('order_id', orderId);
popupUrl.searchParams.set('amount', String(amount));
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
const popup = window.open(
popupUrl.toString(),
'stripe_payment',
'width=500,height=700,scrollbars=yes',
);
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
}
// Send sensitive data via postMessage after popup loads
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
popup.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
}, window.location.origin);
};
window.addEventListener('message', onReady);
};
useEffect(() => {
const updateTimer = () => {
const now = Date.now();
@@ -173,7 +310,6 @@ export default function PaymentQRCode({
}
};
const isStripe = paymentType === 'stripe';
const isWx = paymentType === 'wxpay';
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
@@ -214,48 +350,72 @@ export default function PaymentQRCode({
{!expired && (
<>
{isStripe ? (
<>
<button
type="button"
disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened}
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
setStripeOpened(true);
}
}}
className={[
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors',
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'}
</button>
{stripeOpened && (
<button
type="button"
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
}
}}
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')}
>
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'}
</button>
<div className="w-full max-w-md space-y-4">
{!clientSecret || !stripePublishableKey ? (
<div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
</p>
</div>
) : !stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</span>
</div>
) : stripeError && !stripeLib ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
) : (
<>
<div
ref={stripeContainerRef}
className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
/>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
)}
{stripeSuccess ? (
<div className="text-center">
<div className="text-4xl text-green-600">{'\u2713'}</div>
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</p>
</div>
) : (
<button
type="button"
disabled={stripeSubmitting}
onClick={handleStripeSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
)}
</button>
)}
{popupBlocked && (
<div className={['rounded-lg border p-3 text-sm', dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700'].join(' ')}>
</div>
)}
</>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl)
? '\u652F\u4ED8\u94FE\u63A5\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u8FD4\u56DE\u91CD\u8BD5'
: '\u5728\u65B0\u7A97\u53E3\u5B8C\u6210\u652F\u4ED8\u540E\uFF0C\u6B64\u9875\u9762\u5C06\u81EA\u52A8\u66F4\u65B0'}
</p>
</>
</div>
) : (
<>
{qrDataUrl && (

View File

@@ -31,7 +31,7 @@ export interface CreateOrderResult {
userBalance: number;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
clientSecret?: string | null;
expiresAt: Date;
}
@@ -170,7 +170,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
userBalance: user.balance,
payUrl: paymentResult.payUrl,
qrCode: paymentResult.qrCode,
checkoutUrl: paymentResult.checkoutUrl,
clientSecret: paymentResult.clientSecret,
expiresAt,
};
} catch (error) {
@@ -181,6 +181,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 支付网关配置缺失或调用失败,转成友好错误
const msg = error instanceof Error ? error.message : String(error);
console.error(`Payment gateway error (${input.paymentType}):`, error);
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
throw new OrderError('PAYMENT_GATEWAY_ERROR', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
}

View File

@@ -17,7 +17,7 @@ export interface CreatePaymentResponse {
tradeNo: string; // third-party transaction ID
payUrl?: string; // H5 payment URL (alipay/wxpay)
qrCode?: string; // QR code content
checkoutUrl?: string; // Stripe Checkout URL
clientSecret?: string; // Stripe PaymentIntent client secret (for embedded Payment Element)
}
/** Response from querying an order's payment status */

View File

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