367 lines
15 KiB
JavaScript
367 lines
15 KiB
JavaScript
import fs from 'fs';
|
||
import dayJs from 'dayjs';
|
||
import { AlipaySdk } from 'alipay-sdk';
|
||
import { compressTo32, decompressFrom32 } from 'oak-domain/lib/utils/uuid';
|
||
import { ExternalPayUtilException } from "../../../types/Exception";
|
||
import assert from "assert";
|
||
import { omit } from "oak-domain/lib/utils/lodash";
|
||
import AliPayDebug from './AliPay.debug';
|
||
import { ToYuan } from "oak-domain/lib/utils/money";
|
||
// WAIT_BUYER_PAY(交易创建,等待买家付款)、TRADE_CLOSED(未付款交易超时关闭,或支付完成后全额退款)、TRADE_SUCCESS(交易支付成功)、TRADE_FINISHED(交易结束,不可退款)
|
||
const TRADE_STATE_MATRIX = {
|
||
'TRADE_SUCCESS': 'paid',
|
||
'WAIT_BUYER_PAY': 'paying',
|
||
'TRADE_CLOSED': 'closed',
|
||
'TRADE_FINISHED': 'closed',
|
||
};
|
||
// REFUND_SUCCESS 退款处理成功; 未返回该字段表示退款请求未收到或者退款失败;
|
||
const REFUND_STATE_MATRIX = {
|
||
'REFUND_SUCCESS': "successful",
|
||
'REFUND_CLOSED': "failed",
|
||
"PROCESSING": 'refunding',
|
||
};
|
||
const PREPAY_TIMEOUT = 15 * 60 * 1000; // 默认订单15分钟过期
|
||
export default class AliPay extends AliPayDebug {
|
||
alipaySdk;
|
||
refundGapDays;
|
||
mchId;
|
||
appId;
|
||
publicKeyPath;
|
||
privateKeyPath;
|
||
payNotifyUrl;
|
||
refundNotifyUrl;
|
||
mode;
|
||
keyType;
|
||
gateway;
|
||
alipayRootCertPath;
|
||
alipayPublicCertPath;
|
||
appCertPath;
|
||
encryptKey;
|
||
needEncrypt;
|
||
static MAX_REFUND_DAYS_GAP = 365;
|
||
static MIN_REFUND_DAYS_GAP = 7;
|
||
static DEFAULT_REFUND_DAYS_GAP = 300;
|
||
constructor(apProduct) {
|
||
super(apProduct);
|
||
const { apAccount } = apProduct;
|
||
this.apProduct = apProduct;
|
||
// 这此不记也行,懒得改了
|
||
this.refundGapDays = typeof apProduct.refundGapDays === 'number' ? apProduct.refundGapDays : apAccount.refundGapDays;
|
||
if (typeof this.refundGapDays !== 'number') {
|
||
throw new Error('apProduct/apAccount的refundGapDays必须要设置');
|
||
}
|
||
this.mchId = apAccount.mchId;
|
||
this.appId = apAccount.appId;
|
||
this.publicKeyPath = apAccount.publicKeyPath;
|
||
this.privateKeyPath = apAccount.privateKeyPath;
|
||
this.payNotifyUrl = apAccount.aliPay.payNotifyUrl;
|
||
this.refundNotifyUrl = apAccount.aliPay.refundNotifyUrl;
|
||
this.mode = apAccount.mode;
|
||
this.keyType = apAccount.keyType;
|
||
this.gateway = apAccount.gateway;
|
||
this.alipayRootCertPath = apAccount.alipayRootCertPath;
|
||
this.alipayPublicCertPath = apAccount.alipayPublicCertPath;
|
||
this.appCertPath = apAccount.appCertPath;
|
||
this.encryptKey = apAccount.encryptKey;
|
||
this.needEncrypt = apAccount.needEncrypt || false;
|
||
const alipaySdkConfig = {
|
||
appId: this.appId,
|
||
// 密钥类型,请与生成的密钥格式保持一致,参考平台配置一节
|
||
keyType: this.keyType ? this.keyType : undefined,
|
||
gateway: apAccount.gateway ? apAccount.gateway : undefined,
|
||
// 设置应用私钥
|
||
privateKey: fs.readFileSync(this.privateKeyPath).toString(),
|
||
endpoint: apAccount.gateway ? apAccount.gateway.replace('/gateway.do', '') : undefined,
|
||
};
|
||
if (this.needEncrypt) {
|
||
alipaySdkConfig.encryptKey = this.encryptKey;
|
||
}
|
||
if (this.mode === 'publicKey') {
|
||
alipaySdkConfig.alipayPublicKey = fs.readFileSync(this.publicKeyPath).toString();
|
||
}
|
||
if (this.mode === 'certificate') {
|
||
alipaySdkConfig.alipayRootCertContent = fs.readFileSync(this.alipayRootCertPath);
|
||
alipaySdkConfig.alipayPublicCertContent = fs.readFileSync(this.alipayPublicCertPath);
|
||
alipaySdkConfig.appCertContent = fs.readFileSync(this.appCertPath);
|
||
}
|
||
this.alipaySdk = new AlipaySdk(alipaySdkConfig);
|
||
}
|
||
async refund(refund, context) {
|
||
const { id, price, pay, reason } = refund;
|
||
const serverUrl = context.composeAccessPath();
|
||
const endpoint = this.refundNotifyUrl;
|
||
const refundNotifyUrl = `${serverUrl}/endpoint/${endpoint}/${id}`;
|
||
const out_trade_no = compressTo32(pay.id);
|
||
const out_request_no = compressTo32(id);
|
||
const refundAmount = ToYuan(price);
|
||
const result = await this.alipaySdk.curl('POST', '/v3/alipay/trade/refund', {
|
||
body: {
|
||
out_trade_no: out_trade_no,
|
||
refund_amount: refundAmount,
|
||
out_request_no: out_request_no,
|
||
notify_url: refundNotifyUrl,
|
||
},
|
||
needEncrypt: this.needEncrypt,
|
||
});
|
||
// out_trade_no: '1749093519542',
|
||
// refund_fee: '0.01',
|
||
// gmt_refund_pay: '2025-06-05 11:38:47',
|
||
// trade_no: '2025060522001400570506333003',
|
||
// send_back_fee: '0.00',
|
||
// buyer_logon_id: 'enb***@sandbox.com',
|
||
// buyer_open_id: '057XBJUPmv-O8pJZYRH7AtgMTnmk4REw9lZKkcaTurFn542',
|
||
// fund_change: 'Y'
|
||
const { trade_no } = this.analyzeResult(result);
|
||
return {
|
||
externalId: trade_no,
|
||
};
|
||
}
|
||
async getRefundState(refund) {
|
||
const out_request_no = compressTo32(refund.id);
|
||
const out_trade_no = compressTo32(refund.payId);
|
||
const result = await this.alipaySdk.curl('POST', '/v3/alipay/trade/fastpay/refund/query', {
|
||
body: {
|
||
out_trade_no: out_trade_no,
|
||
out_request_no: out_request_no,
|
||
query_options: [
|
||
"refund_detail_item_list",
|
||
"gmt_refund_pay"
|
||
]
|
||
},
|
||
needEncrypt: this.needEncrypt,
|
||
});
|
||
// refund_detail_item_list: [ [Object] ],
|
||
// refund_status: 'REFUND_SUCCESS',
|
||
// out_trade_no: '1749093519542',
|
||
// total_amount: '0.01',
|
||
// refund_amount: '0.01',
|
||
// gmt_refund_pay: '2025-06-05 11:38:47',
|
||
// trade_no: '2025060522001400570506333003',
|
||
// send_back_fee: '0.01',
|
||
// out_request_no: '1749094724197'
|
||
const externalMeta = this.analyzeResult(result);
|
||
const { refund_status, gmt_refund_pay } = externalMeta;
|
||
/**
|
||
* 【退款状态。枚举值: REFUND_SUCCESS 退款处理成功; 未返回该字段表示退款请求未收到或者退款失败; 注:如果退款查询发起时间早于退款时间,或者间隔退款发起时间太短,可能出现退款查询时还没处理成功,后面又处理成功的情况,建议商户在退款发起后间隔10秒以上再发起退款查询请求
|
||
*/
|
||
let status = refund_status;
|
||
if (refund_status !== 'REFUND_SUCCESS') {
|
||
status = 'REFUND_CLOSED';
|
||
}
|
||
const iState = REFUND_STATE_MATRIX[status];
|
||
return [iState, gmt_refund_pay ? {
|
||
successAt: gmt_refund_pay ? dayJs(gmt_refund_pay).valueOf() : undefined,
|
||
externalMeta,
|
||
} : undefined];
|
||
}
|
||
analyzeResult(result) {
|
||
const { data, responseHttpStatus } = result;
|
||
if (responseHttpStatus !== 200) {
|
||
console.error(JSON.stringify(result));
|
||
throw new ExternalPayUtilException(result);
|
||
}
|
||
return data;
|
||
}
|
||
caclRefundDeadline(successTime) {
|
||
let gapDays = this.refundGapDays || AliPay.DEFAULT_REFUND_DAYS_GAP;
|
||
if (gapDays > AliPay.MAX_REFUND_DAYS_GAP) {
|
||
gapDays = AliPay.MAX_REFUND_DAYS_GAP;
|
||
}
|
||
if (gapDays < AliPay.MIN_REFUND_DAYS_GAP) {
|
||
gapDays = AliPay.MIN_REFUND_DAYS_GAP;
|
||
}
|
||
return dayJs(successTime).add(gapDays, 'day').subtract(12, 'hour').valueOf();
|
||
}
|
||
async prepay(pay, data, context) {
|
||
const applicationId = context.getApplicationId();
|
||
const userId = context.getCurrentUserId();
|
||
const serverUrl = context.composeAccessPath();
|
||
const endpoint = this.payNotifyUrl;
|
||
const payNotifyUrl = `${serverUrl}/endpoint${endpoint.startsWith('/') ? endpoint : '/' + endpoint}/${pay.id}`;
|
||
const out_trade_no = compressTo32(pay.id);
|
||
// 支付宝金额单位为元,需要转换
|
||
const totalAmount = ToYuan(pay.price);
|
||
const timeoutAt = Date.now() + PREPAY_TIMEOUT;
|
||
switch (this.apProduct.type) {
|
||
case 'native': {
|
||
const method = this.apProduct.config?.prepayMethod || 'GET';
|
||
const returnUrl = this.apProduct.config?.returnUrl;
|
||
const qrPayMode = this.apProduct.config?.qrPayMode || '2';
|
||
const qrCodeWidth = this.apProduct.config?.qrCodeWidth || 100;
|
||
const params = {
|
||
bizContent: {
|
||
subject: pay.order?.desc || (pay.orderId ? '订单支付' : '帐户充值'),
|
||
out_trade_no: out_trade_no,
|
||
total_amount: totalAmount,
|
||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||
notify_url: payNotifyUrl,
|
||
qr_pay_mode: qrPayMode,
|
||
qrcode_width: qrCodeWidth,
|
||
time_expire: dayJs(timeoutAt).format('YYYY-MM-DD HH:mm:ss'),
|
||
},
|
||
notify_url: payNotifyUrl,
|
||
needEncrypt: this.needEncrypt,
|
||
};
|
||
if (returnUrl) {
|
||
params.return_url = returnUrl;
|
||
}
|
||
const htmlUrl = this.alipaySdk.pageExecute('alipay.trade.page.pay', method, params);
|
||
// 电脑网站支付 下单不会返回trade_no, 等支付回调后再获取
|
||
data.externalId = '';
|
||
data.meta = {
|
||
...(pay.meta || {}),
|
||
htmlUrl: htmlUrl,
|
||
};
|
||
break;
|
||
}
|
||
default: {
|
||
throw new Error('本支付方法还没来的及实现呢');
|
||
}
|
||
}
|
||
// pay加一个过期时间,到期自动close
|
||
data.timeoutAt = timeoutAt + 1000 * 60;
|
||
}
|
||
async getState(pay) {
|
||
const outTradeNo = compressTo32(pay.id);
|
||
let result;
|
||
try {
|
||
result = await this.alipaySdk.curl('POST', '/v3/alipay/trade/query', {
|
||
body: {
|
||
out_trade_no: outTradeNo,
|
||
query_options: ['trade_settle_info'],
|
||
},
|
||
needEncrypt: this.needEncrypt,
|
||
});
|
||
}
|
||
catch (e) {
|
||
// 在电脑网站下单时,系统首先生成URL;用户扫码后才会创建交易订单。若未完成扫码而查询订单,提示'交易不存在'。
|
||
if (e.code === 'ACQ.TRADE_NOT_EXIST') {
|
||
return ['paying', {}];
|
||
}
|
||
throw e;
|
||
}
|
||
// WAIT_BUYER_PAY(交易创建,等待买家付款)、TRADE_CLOSED(未付款交易超时关闭,或支付完成后全额退款)、TRADE_SUCCESS(交易支付成功)、TRADE_FINISHED(交易结束,不可退款)
|
||
// out_trade_no: '1749093519542',
|
||
// total_amount: '0.01',
|
||
// buyer_user_type: 'PRIVATE',
|
||
// trade_status: 'TRADE_SUCCESS',
|
||
// trade_no: '2025060522001400570506333003',
|
||
// receipt_amount: '0.00',
|
||
// buyer_logon_id: 'enb***@sandbox.com',
|
||
// send_pay_date: '2025-06-05 11:20:42',
|
||
// point_amount: '0.00',
|
||
// buyer_open_id: '057XBJUPmv-O8pJZYRH7AtgMTnmk4REw9lZKkcaTurFn542',
|
||
// buyer_pay_amount: '0.00',
|
||
// invoice_amount: '0.00'
|
||
const { trade_status: tradeState, send_pay_date, trade_no } = this.analyzeResult(result);
|
||
const iState = TRADE_STATE_MATRIX[tradeState];
|
||
assert(iState);
|
||
const updateData = {
|
||
meta: {
|
||
...(pay.meta || {}),
|
||
getState: omit(result, ['mch_id', 'app_id', 'out_trade_no']),
|
||
},
|
||
};
|
||
if (!pay.externalId) {
|
||
updateData.externalId = trade_no;
|
||
}
|
||
if (iState === 'paid') {
|
||
updateData.successAt = dayJs(send_pay_date).valueOf();
|
||
}
|
||
return [iState, updateData];
|
||
}
|
||
async close(pay) {
|
||
const outTradeNo = compressTo32(pay.id);
|
||
try {
|
||
const result = await this.alipaySdk.curl('POST', '/v3/alipay/trade/close', {
|
||
body: {
|
||
out_trade_no: outTradeNo,
|
||
},
|
||
needEncrypt: this.needEncrypt,
|
||
});
|
||
this.analyzeResult(result);
|
||
}
|
||
catch (e) {
|
||
throw e;
|
||
}
|
||
}
|
||
async decodePayNotification(params, body) {
|
||
const { sign, sign_type } = body;
|
||
const result = body;
|
||
/**
|
||
* {
|
||
gmt_create: '2025-06-05 11:20:25',
|
||
charset: 'utf-8',
|
||
gmt_payment: '2025-06-05 11:20:42',
|
||
notify_time: '2025-06-05 11:20:44',
|
||
subject: '测试商品20250605111839',
|
||
sign: 'G5UXRw3+Kn8HMT+gNbNLFt/Weg+WNTIzCC+0MjtRjMxygRb9KXU71fXtnUUwVBUtAUbaQKGn4tz7mWum97GGWQtyPGfOZIfCeXZ59bAnnJWIczltwF/7rBlDFryZMSHzW8ghgJF/mmzqg77cucqbTi3kWAN6vmvSG+3hD6QDc3wPvFhHFjbFB+OKdg4Qfo1z68l4P+4DnrCmvtESDTyrAdfCk5HOu6zFz5wq/dO1huMCzsUx+cRK+dEGIY4IRfLkqq2QPMzy56iNIOsi64Ma1gbh3P4ngkDbt4xpwfWx+QLWM2fwbWyziJXiseOyxK9HXWknwPcdk1IcCejmpbpkog==',
|
||
buyer_id: '2088722068900570',
|
||
invoice_amount: '0.01',
|
||
version: '1.0',
|
||
notify_id: '2025060501222112043100570506479893',
|
||
fund_bill_list: '[{"amount":"0.01","fundChannel":"ALIPAYACCOUNT"}]',
|
||
notify_type: 'trade_status_sync',
|
||
out_trade_no: '1749093519542',
|
||
total_amount: '0.01',
|
||
trade_status: 'TRADE_SUCCESS',
|
||
trade_no: '2025060522001400570506333003',
|
||
auth_app_id: '2021000149620427',
|
||
receipt_amount: '0.01',
|
||
point_amount: '0.00',
|
||
buyer_pay_amount: '0.01',
|
||
app_id: '2021000149620427',
|
||
sign_type: 'RSA2',
|
||
seller_id: '2088721068920157'
|
||
}
|
||
*/
|
||
const { out_trade_no, app_id, trade_status, gmt_payment, trade_no, } = result;
|
||
const payId = decompressFrom32(out_trade_no);
|
||
assert(app_id === this.appId);
|
||
const iState = TRADE_STATE_MATRIX[trade_status];
|
||
assert(iState);
|
||
const extra = {
|
||
externalId: trade_no,
|
||
meta: {
|
||
decodePayNotification: omit(result, ['app_id', 'out_trade_no'])
|
||
},
|
||
};
|
||
if (iState === 'paid') {
|
||
extra.successAt = dayJs(gmt_payment).valueOf();
|
||
}
|
||
return {
|
||
payId,
|
||
iState,
|
||
extra,
|
||
};
|
||
}
|
||
async decodeRefundNotification(params, body) {
|
||
// 支付宝不支持退款回调通知,只能通过查询退款接口获取
|
||
const { sign, sign_type } = body;
|
||
const result = body;
|
||
const { out_refund_no, app_id, refund_status, success_time, } = result;
|
||
const refundId = decompressFrom32(out_refund_no);
|
||
assert(app_id === this.appId);
|
||
const iState = REFUND_STATE_MATRIX[refund_status];
|
||
assert(iState);
|
||
const extra = {
|
||
externalMeta: omit(result, ['app_id',]),
|
||
};
|
||
if (iState === 'successful') {
|
||
assert(success_time);
|
||
extra.successAt = dayJs(success_time).valueOf();
|
||
}
|
||
return {
|
||
refundId,
|
||
iState,
|
||
extra,
|
||
};
|
||
}
|
||
getRefundableAt(successAt) {
|
||
return this.caclRefundDeadline(successAt);
|
||
}
|
||
uploadShipInfo(pay) {
|
||
// assert(pay.entity === 'apProduct' && pay.apProduct?.shipping);
|
||
}
|
||
}
|