feat: integrate Stripe payment with bugfixes and active timeout cancellation
- Add Stripe payment provider with Checkout Session flow - Payment provider abstraction layer (EasyPay + Stripe unified interface) - Stripe webhook with proper raw body handling and signature verification - Frontend: Stripe button with URL validation, anti-duplicate click, noopener - Active timeout cancellation: query platform before expiring, recover paid orders - Singleton Stripe client, idempotency keys, Math.round for amounts - Handle async_payment events, return null for unknown webhook events - Set Checkout Session expires_at aligned with order timeout - Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op) - Enable stripe in frontend payment type list
This commit is contained in:
@@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
|
||||
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||
}
|
||||
|
||||
function assertEasyPayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (
|
||||
!env.EASY_PAY_PID ||
|
||||
!env.EASY_PAY_PKEY ||
|
||||
!env.EASY_PAY_API_BASE ||
|
||||
!env.EASY_PAY_NOTIFY_URL ||
|
||||
!env.EASY_PAY_RETURN_URL
|
||||
) {
|
||||
throw new Error(
|
||||
'EasyPay environment variables (EASY_PAY_PID, EASY_PAY_PKEY, EASY_PAY_API_BASE, EASY_PAY_NOTIFY_URL, EASY_PAY_RETURN_URL) are required',
|
||||
);
|
||||
}
|
||||
return env as typeof env & {
|
||||
EASY_PAY_PID: string;
|
||||
EASY_PAY_PKEY: string;
|
||||
EASY_PAY_API_BASE: string;
|
||||
EASY_PAY_NOTIFY_URL: string;
|
||||
EASY_PAY_RETURN_URL: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params: Record<string, string> = {
|
||||
pid: env.EASY_PAY_PID,
|
||||
type: opts.paymentType,
|
||||
@@ -57,7 +78,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
|
||||
const data = await response.json() as EasyPayCreateResponse;
|
||||
const data = (await response.json()) as EasyPayCreateResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
@@ -65,10 +86,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
}
|
||||
|
||||
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as EasyPayQueryResponse;
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
@@ -76,7 +97,7 @@ export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryRespon
|
||||
}
|
||||
|
||||
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params = new URLSearchParams({
|
||||
pid: env.EASY_PAY_PID,
|
||||
key: env.EASY_PAY_PKEY,
|
||||
@@ -89,7 +110,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const data = await response.json() as EasyPayRefundResponse;
|
||||
const data = (await response.json()) as EasyPayRefundResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
87
src/lib/easy-pay/provider.ts
Normal file
87
src/lib/easy-pay/provider.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import { createPayment, queryOrder, refund } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export class EasyPayProvider implements PaymentProvider {
|
||||
readonly name = 'easy-pay';
|
||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const result = await createPayment({
|
||||
outTradeNo: request.orderId,
|
||||
amount: request.amount.toFixed(2),
|
||||
paymentType: request.paymentType as 'alipay' | 'wxpay',
|
||||
clientIp: request.clientIp || '127.0.0.1',
|
||||
productName: request.subject,
|
||||
});
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no,
|
||||
payUrl: result.payurl,
|
||||
qrCode: result.qrcode,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await queryOrder(tradeNo);
|
||||
return {
|
||||
tradeNo: result.trade_no,
|
||||
status: result.status === 1 ? 'paid' : 'pending',
|
||||
amount: parseFloat(result.money),
|
||||
paidAt: result.endtime ? new Date(result.endtime) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const paramsForSign: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
|
||||
paramsForSign[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.EASY_PAY_PKEY || !verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
|
||||
throw new Error('EasyPay notification signature verification failed');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.money || '0'),
|
||||
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
await refund(request.tradeNo, request.orderId, request.amount.toFixed(2));
|
||||
return {
|
||||
refundId: `${request.tradeNo}-refund`,
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(): Promise<void> {
|
||||
// EasyPay does not support cancelling payments
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import crypto from 'crypto';
|
||||
|
||||
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
Reference in New Issue
Block a user