From 964a2aa6d9b155990160a3457ec21df1aee0c38e Mon Sep 17 00:00:00 2001 From: miwei Date: Wed, 4 Mar 2026 10:58:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Stripe=20=E6=94=B9=E7=94=A8=20PaymentIn?= =?UTF-8?q?tent=20+=20Payment=20Element=EF=BC=8Ciframe=20=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E4=BB=98=E5=AE=9D=E5=BC=B9=E7=AA=97=E6=94=AF?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.en.md | 4 +- README.md | 4 +- package.json | 2 + pnpm-lock.yaml | 42 +++- src/__tests__/lib/stripe/provider.test.ts | 161 ++++++------- src/app/api/user/route.ts | 3 + src/app/pay/page.tsx | 23 +- src/app/pay/result/page.tsx | 51 ++++- src/app/pay/stripe-popup/page.tsx | 267 ++++++++++++++++++++++ src/components/PaymentForm.tsx | 135 ++++++----- src/components/PaymentQRCode.tsx | 258 ++++++++++++++++----- src/lib/order/service.ts | 5 +- src/lib/payment/types.ts | 2 +- src/lib/stripe/provider.ts | 71 +++--- 14 files changed, 749 insertions(+), 279 deletions(-) create mode 100644 src/app/pay/stripe-popup/page.tsx diff --git a/README.en.md b/README.en.md index 872e695..bf2381d 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index f24a6b0..648a46b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 48ad98c..fcc9c96 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dependencies": { "@prisma/adapter-pg": "7.4.1", "@prisma/client": "^7.4.2", + "@stripe/react-stripe-js": "^5.6.1", + "@stripe/stripe-js": "^8.9.0", "next": "16.1.6", "pg": "^8.19.0", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ef098..12696ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ 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/react-stripe-js': + specifier: ^5.6.1 + version: 5.6.1(@stripe/stripe-js@8.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.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 +880,17 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stripe/react-stripe-js@5.6.1': + resolution: {integrity: sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA==} + peerDependencies: + '@stripe/stripe-js': '>=8.0.0 <9.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@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 +3630,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stripe/react-stripe-js@5.6.1(@stripe/stripe-js@8.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@stripe/stripe-js': 8.9.0 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@stripe/stripe-js@8.9.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4402,8 +4428,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) @@ -4425,7 +4451,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4436,22 +4462,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4462,7 +4488,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/src/__tests__/lib/stripe/provider.test.ts b/src/__tests__/lib/stripe/provider.test.ts index 68c9389..988c1a4 100644 --- a/src/__tests__/lib/stripe/provider.test.ts +++ b/src/__tests__/lib/stripe/provider.test.ts @@ -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) { - 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'); }); }); }); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 0f13465..a32d41f 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -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) { diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index ecdfbea..7a14098 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -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; 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({ - 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() { )} - {step === 'form' && ( + {step === 'form' && config.enabledPaymentTypes.length === 0 && ( +
+
+ + 加载中... + +
+ )} + + {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} /> )} diff --git a/src/app/pay/result/page.tsx b/src/app/pay/result/page.tsx index b2dc203..3925862 100644 --- a/src/app/pay/result/page.tsx +++ b/src/app/pay/result/page.tsx @@ -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(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 (
@@ -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() {

{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}

+ {isInPopup && ( +
+

此窗口将在 3 秒后自动关闭

+ +
+ )} ) : isPending ? ( <>

等待支付

订单尚未完成支付

+ {isInPopup && ( + + )} ) : ( <> @@ -85,6 +125,15 @@ function ResultContent() { ? '订单已被取消' : '请联系管理员处理'}

+ {isInPopup && ( + + )} )} diff --git a/src/app/pay/stripe-popup/page.tsx b/src/app/pay/stripe-popup/page.tsx new file mode 100644 index 0000000..7c08955 --- /dev/null +++ b/src/app/pay/stripe-popup/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState, useCallback, Suspense } from 'react'; + +// Methods that can be confirmed directly without Payment Element +const DIRECT_CONFIRM_METHODS: Record = { + alipay: 'confirmAlipayPayment', +}; + +function StripePopupContent() { + const searchParams = useSearchParams(); + const orderId = searchParams.get('order_id') || ''; + const clientSecret = searchParams.get('client_secret') || ''; + const pk = searchParams.get('pk') || ''; + 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 hasMissingParams = !orderId || !clientSecret || !pk; + + 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 directConfirmMethod = DIRECT_CONFIRM_METHODS[method]; + + 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]); + + // Initialize Stripe and auto-confirm for direct methods (e.g. Alipay) + useEffect(() => { + if (hasMissingParams) return; + let cancelled = false; + import('@stripe/stripe-js').then(({ loadStripe }) => { + loadStripe(pk).then((stripe) => { + if (cancelled || !stripe) { + if (!cancelled) { + setStripeError('支付组件加载失败,请关闭窗口重试'); + setStripeLoaded(true); + } + return; + } + + if (directConfirmMethod) { + // Direct confirm (e.g. Alipay) — immediately redirect, no Payment Element needed + const confirmFn = (stripe as unknown as Record)[directConfirmMethod]; + if (typeof confirmFn === 'function') { + confirmFn.call(stripe, clientSecret, { + return_url: buildReturnUrl(), + }).then((result: { error?: { message?: string } }) => { + 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; }; + }, [pk, clientSecret, isDark, directConfirmMethod, hasMissingParams, buildReturnUrl]); + + // Mount Payment Element (only for non-direct 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]); + + // Missing params — show error (after all hooks) + if (hasMissingParams) { + return ( +
+
+

参数缺失

+

请从支付页面正常打开此窗口

+
+
+ ); + } + + // For direct confirm methods, show a loading/redirecting state + if (directConfirmMethod) { + return ( +
+
+
+
{'\u00A5'}{amount.toFixed(2)}
+

+ 订单号: {orderId} +

+
+ {stripeError ? ( +
+
+ {stripeError} +
+ +
+ ) : ( +
+
+ + 正在跳转到支付页面... + +
+ )} +
+
+ ); + } + + return ( +
+
+
+
{'\u00A5'}{amount.toFixed(2)}
+

+ 订单号: {orderId} +

+
+ + {!stripeLoaded ? ( +
+
+ + 正在加载支付表单... + +
+ ) : stripeSuccess ? ( +
+
{'\u2713'}
+

+ 支付成功,窗口即将自动关闭... +

+ +
+ ) : ( + <> + {stripeError && ( +
+ {stripeError} +
+ )} +
+ + + )} +
+
+ ); +} + +export default function StripePopupPage() { + return ( + +
加载中...
+
+ } + > + + + ); +} diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index 6dec01b..a9c0942 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -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({ 充值金额
- {QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => ( + {QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => ( - ); - })} -
+ + ); + })} +
- {/* 当前选中渠道额度不足时的提示 */} - {(() => { - const limitInfo = methodLimits?.[paymentType]; - if (!limitInfo || limitInfo.available) return null; - return ( -

- 所选支付方式今日额度已满,请切换到其他支付方式 -

- ); - })()} -
+ {/* 当前选中渠道额度不足时的提示 */} + {(() => { + const limitInfo = methodLimits?.[paymentType]; + if (!limitInfo || limitInfo.available) return null; + return ( +

+ 所选支付方式今日额度已满,请切换到其他支付方式 +

+ ); + })()} +
+ )} {/* Fee Detail */} {feeRate > 0 && selectedAmount > 0 && ( diff --git a/src/components/PaymentQRCode.tsx b/src/components/PaymentQRCode.tsx index 5bc15c0..76adc68 100644 --- a/src/components/PaymentQRCode.tsx +++ b/src/components/PaymentQRCode.tsx @@ -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,123 @@ 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.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); + const popupUrl = new URL(window.location.href); + popupUrl.pathname = '/pay/stripe-popup'; + popupUrl.search = ''; + popupUrl.searchParams.set('order_id', orderId); + popupUrl.searchParams.set('client_secret', clientSecret); + popupUrl.searchParams.set('pk', stripePublishableKey); + 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); + } + }; + useEffect(() => { const updateTimer = () => { const now = Date.now(); @@ -173,7 +298,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 +338,72 @@ export default function PaymentQRCode({ {!expired && ( <> {isStripe ? ( - <> - - {stripeOpened && ( - +
+ {!clientSecret || !stripePublishableKey ? ( +
+

+ 支付初始化失败,请返回重试 +

+
+ ) : !stripeLoaded ? ( +
+
+ + 正在加载支付表单... + +
+ ) : stripeError && !stripeLib ? ( +
+ {stripeError} +
+ ) : ( + <> +
+ {stripeError && ( +
+ {stripeError} +
+ )} + {stripeSuccess ? ( +
+
{'\u2713'}
+

+ 支付成功,正在处理订单... +

+
+ ) : ( + + )} + {popupBlocked && ( +
+ 弹出窗口被浏览器拦截,请允许本站弹出窗口后重试 +
+ )} + )} -

- {!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'} -

- +
) : ( <> {qrDataUrl && ( diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index fad0bd7..dcb5008 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -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 { 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 { 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 { 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 { const stripe = this.getClient(); - await stripe.checkout.sessions.expire(tradeNo); + await stripe.paymentIntents.cancel(tradeNo); } }