WeChat/Alipay/Epay reject reusing the same merchant order number when params change. Allocate attempt-suffixed out_trade_no on resume pay, map callbacks back to the local order, and best-effort close prior trades. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
459 lines
19 KiB
PHP
459 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Shop;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class CheckoutService {
|
|
|
|
private Shop $shop;
|
|
|
|
public function __construct(Shop $shop) {
|
|
$this->shop = $shop;
|
|
}
|
|
|
|
public function preview_checkout(int $user_id, array $payload): array {
|
|
if ($user_id <= 0) {
|
|
return ['success' => false, 'message' => __('请先登录后再结算。', 'aksrt')];
|
|
}
|
|
|
|
$items = $this->normalize_checkout_items($payload['items'] ?? []);
|
|
if (empty($items)) {
|
|
return ['success' => false, 'message' => __('请选择至少一个商品。', 'aksrt')];
|
|
}
|
|
|
|
$product_views = [];
|
|
$subtotal = 0.0;
|
|
foreach ($items as $item) {
|
|
$product = $this->shop->get_product_detail_view((int) $item['product_id']);
|
|
if (empty($product) || empty($product['purchase_enabled'])) {
|
|
return ['success' => false, 'message' => __('存在不可购买商品,请刷新后重试。', 'aksrt')];
|
|
}
|
|
|
|
if ((int) $product['stock'] < (int) $item['quantity']) {
|
|
return ['success' => false, 'message' => sprintf(__('商品 %s 库存不足。', 'aksrt'), $product['title'])];
|
|
}
|
|
|
|
$line_total = round(((float) $product['price']) * (int) $item['quantity'], 2);
|
|
|
|
// R69: Apply discount engine (shop_discount taxonomy campaigns)
|
|
$discount_result = ['discount_amount' => 0, 'gifts' => [], 'applied' => []];
|
|
if (class_exists('\AKSRT\Payment\Discount')) {
|
|
$discount_result = \AKSRT\Payment\Discount::get_instance()->calculate(
|
|
(float) $product['price'],
|
|
(int) $item['quantity'],
|
|
(int) $product['id'],
|
|
$user_id
|
|
);
|
|
$line_total = $discount_result['final_price'];
|
|
}
|
|
|
|
$subtotal += $line_total;
|
|
$product_views[] = [
|
|
'product_id' => (int) $product['id'],
|
|
'title' => $product['title'],
|
|
'price' => (float) $product['price'],
|
|
'quantity' => (int) $item['quantity'],
|
|
'line_total' => $line_total,
|
|
'image' => $product['image'],
|
|
'shipping' => $product['shipping'],
|
|
'discount_amount' => (float) ($discount_result['discount_amount'] ?? 0),
|
|
'gifts' => (array) ($discount_result['gifts'] ?? []),
|
|
'applied_discounts' => (array) ($discount_result['applied'] ?? []),
|
|
];
|
|
}
|
|
|
|
$shipping_method = sanitize_key((string) ($payload['shipping_method'] ?? 'express'));
|
|
$shipping_profile = $this->resolve_shipping_quote($product_views, $shipping_method);
|
|
|
|
$coupon_code = sanitize_text_field((string) ($payload['coupon_code'] ?? ''));
|
|
$product_ids = array_values(array_unique(array_map(static function ($row) {
|
|
return (int) ($row['product_id'] ?? $row['id'] ?? 0);
|
|
}, $product_views)));
|
|
$coupon_result = $this->resolve_coupon_discount($coupon_code, $subtotal + $shipping_profile['fee'], $product_ids);
|
|
if (!$coupon_result['success']) {
|
|
return $coupon_result;
|
|
}
|
|
|
|
$total = max(0, round($subtotal + $shipping_profile['fee'] - $coupon_result['discount'], 2));
|
|
$address = $this->resolve_checkout_address($user_id, (int) ($payload['address_id'] ?? 0));
|
|
$payment_method = $this->resolve_payment_method((string) ($payload['payment_method'] ?? 'balance'));
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'items' => $product_views,
|
|
'subtotal' => round($subtotal, 2),
|
|
'shipping_fee' => $shipping_profile['fee'],
|
|
'shipping_method' => $shipping_profile['method'],
|
|
'discount' => $coupon_result['discount'],
|
|
'coupon' => $coupon_result['coupon'],
|
|
'address' => $address,
|
|
'payment_method' => $payment_method,
|
|
'total' => $total,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function create_checkout_order(int $user_id, array $payload): array {
|
|
$preview = $this->preview_checkout($user_id, $payload);
|
|
if (empty($preview['success'])) {
|
|
return $preview;
|
|
}
|
|
|
|
$data = $preview['data'];
|
|
$primary_item = $data['items'][0] ?? null;
|
|
if (!$primary_item || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
return ['success' => false, 'message' => __('订单创建失败。', 'aksrt')];
|
|
}
|
|
|
|
$payment_method = sanitize_key((string) ($data['payment_method']['value'] ?? 'balance'));
|
|
$meta_payload = [
|
|
'customer_note' => sanitize_textarea_field((string) ($payload['customer_note'] ?? '')),
|
|
'items' => $data['items'],
|
|
'coupon' => $data['coupon'],
|
|
'shipping_fee' => $data['shipping_fee'],
|
|
'shipping_method' => $data['shipping_method'],
|
|
'address' => $data['address'],
|
|
'payment_method' => $data['payment_method'],
|
|
'discount' => $data['discount'],
|
|
];
|
|
|
|
$expiry_minutes = 30;
|
|
if (class_exists('\AKSRT\Settings\Settings')) {
|
|
$order_settings = \AKSRT\Settings\Settings::get_order_settings();
|
|
$expiry_minutes = max(5, min(1440, (int) ($order_settings['expiry'] ?? 30)));
|
|
}
|
|
|
|
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
|
'user_id' => $user_id,
|
|
'product_type' => 'shop_product',
|
|
'product_id' => (int) $primary_item['product_id'],
|
|
'amount' => (float) $data['total'],
|
|
'currency' => 'CNY',
|
|
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
|
'payment_method' => $payment_method,
|
|
'expires_at' => date('Y-m-d H:i:s', strtotime('+' . $expiry_minutes . ' minutes')),
|
|
'note' => wp_json_encode($meta_payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
]);
|
|
|
|
if ($order_number === '') {
|
|
return ['success' => false, 'message' => __('订单创建失败。', 'aksrt')];
|
|
}
|
|
|
|
$this->shop->save_order_items($order_number, $data['items']);
|
|
|
|
if (!empty($data['coupon']['code']) && class_exists('\AKSRT\Payment\Coupon')) {
|
|
$coupon_instance = \AKSRT\Payment\Coupon::get_instance();
|
|
$coupon_instance->use_coupon($data['coupon']['code']);
|
|
// R69: Log coupon usage for per-user tracking
|
|
$coupon_row = $coupon_instance->validate($data['coupon']['code']);
|
|
$coupon_id = (int) ($coupon_row['coupon']['id'] ?? 0);
|
|
if ($coupon_id > 0) {
|
|
$coupon_instance->log_usage(
|
|
$coupon_id,
|
|
$user_id,
|
|
$order_number,
|
|
(float) ($data['discount'] ?? 0)
|
|
);
|
|
}
|
|
}
|
|
|
|
$detail = $this->shop->get_order_detail_view($user_id, $order_number);
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'order_number' => $order_number,
|
|
'order' => $detail,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function pay_order(int $user_id, string $order_number, string $payment_method): array {
|
|
if ($user_id <= 0 || $order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
|
}
|
|
|
|
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
|
$order = $repo->get_user_order($user_id, $order_number);
|
|
if (!$order) {
|
|
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
|
}
|
|
|
|
$status = (string) ($order['status'] ?? '');
|
|
if (!in_array($status, [
|
|
\AKSRT\Payment\Order::STATUS_CREATED,
|
|
\AKSRT\Payment\Order::STATUS_PENDING,
|
|
], true)) {
|
|
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
|
}
|
|
|
|
// Payment attempts are bound to the method selected when the order was created.
|
|
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
|
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
|
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
|
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
|
}
|
|
|
|
$gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
|
|
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
|
|
|
if (!$gateway) {
|
|
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
|
|
}
|
|
|
|
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
|
|
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
|
}
|
|
|
|
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
|
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
|
: false;
|
|
if ($lock === false) {
|
|
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
|
|
}
|
|
|
|
try {
|
|
// 携带订单号与摘要,供网关生成支付单。
|
|
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
|
|
|
// Points payment: synchronous deduction via \AKSRT\Credits\Credits
|
|
if ($payment_method === 'points') {
|
|
return $this->pay_with_points($user_id, $order_number, $order);
|
|
}
|
|
|
|
// External gateways: allocate a fresh out_trade_no so re-pay works when
|
|
// the provider rejects "same order number with changed params".
|
|
$needs_attempt = !in_array($payment_method, ['balance', 'points'], true);
|
|
if ($needs_attempt && class_exists('\AKSRT\Payment\OrderRepository')) {
|
|
$attempt = $repo->allocate_payment_attempt($order_number);
|
|
if ($attempt === null) {
|
|
return ['success' => false, 'message' => __('无法生成支付单号,请稍后重试。', 'aksrt')];
|
|
}
|
|
$order = $repo->get_user_order($user_id, $order_number) ?: $order;
|
|
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
|
|
// Close previous provider trade when possible (WeChat/Alipay) so the new attempt is clean.
|
|
if (!empty($attempt['previous'])
|
|
&& $attempt['previous'] !== $attempt['out_trade_no']
|
|
&& method_exists($gateway, 'close_trade')) {
|
|
try {
|
|
$gateway->close_trade((string) $attempt['previous']);
|
|
} catch (\Throwable $e) {
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
$payment_result = $gateway->create_payment($order);
|
|
if (empty($payment_result['success'])) {
|
|
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
|
|
}
|
|
|
|
// 跳转/扫码型网关(支付宝/微信/PayPal/Stripe):返回支付凭据,订单保持待支付,
|
|
// 由 /payment/{gateway}/notify|return 回调确认到账;此处绝不预先标记已支付。
|
|
$code_url = $payment_result['payment_data']['code_url'] ?? '';
|
|
if (!empty($payment_result['payment_url']) || $code_url !== '') {
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'redirect' => true,
|
|
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
|
'qr_content' => (string) $code_url,
|
|
'order_number' => $order_number,
|
|
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
|
|
],
|
|
];
|
|
}
|
|
|
|
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
|
|
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
|
'payment_method' => $payment_method,
|
|
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
|
|
'note' => __('用户在前台完成支付。', 'aksrt'),
|
|
'require_transition' => true,
|
|
]);
|
|
if (!$paid) {
|
|
$compensation = $gateway->refund(
|
|
$order_number,
|
|
(float) ($order['amount'] ?? 0),
|
|
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
|
|
);
|
|
if (empty($compensation['success'])) {
|
|
error_log(sprintf(
|
|
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
|
|
$order_number,
|
|
$payment_method,
|
|
(string) ($compensation['message'] ?? 'unknown error')
|
|
));
|
|
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
|
|
}
|
|
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
|
|
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
|
|
],
|
|
];
|
|
} finally {
|
|
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
|
}
|
|
}
|
|
|
|
private function pay_with_points(int $user_id, string $order_number, array $order): array {
|
|
if (!class_exists('\AKSRT\Credits\Credits')) {
|
|
return ['success' => false, 'message' => __('积分系统不可用。', 'aksrt')];
|
|
}
|
|
|
|
$amount = (int) round((float) ($order['amount'] ?? 0));
|
|
if ($amount <= 0) {
|
|
return ['success' => false, 'message' => __('订单金额无效。', 'aksrt')];
|
|
}
|
|
|
|
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
|
if ($balance < $amount) {
|
|
return ['success' => false, 'message' => sprintf(__('积分不足,需要 %d 积分,当前余额 %d。', 'aksrt'), $amount, $balance)];
|
|
}
|
|
|
|
$deducted = \AKSRT\Credits\Credits::deduct(
|
|
$user_id,
|
|
$amount,
|
|
'shop_points_pay',
|
|
['order' => $order_number],
|
|
sprintf(__('积分购买订单 %s', 'aksrt'), $order_number)
|
|
);
|
|
if (is_wp_error($deducted)) {
|
|
return ['success' => false, 'message' => $deducted->get_error_message() ?: __('积分扣除失败。', 'aksrt')];
|
|
}
|
|
|
|
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
|
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
|
'payment_method' => 'points',
|
|
'payment_transaction_id' => 'points_' . $order_number,
|
|
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
|
'require_transition' => true,
|
|
]);
|
|
if (!$paid) {
|
|
$compensation = \AKSRT\Credits\Credits::add(
|
|
$user_id,
|
|
$amount,
|
|
'shop_points_pay_rollback',
|
|
['order' => $order_number],
|
|
sprintf(__('订单 %s 支付失败退回积分', 'aksrt'), $order_number)
|
|
);
|
|
if (is_wp_error($compensation)) {
|
|
error_log(sprintf(
|
|
'[AKSRT Payment] points rollback failed order=%s: %s',
|
|
$order_number,
|
|
$compensation->get_error_message()
|
|
));
|
|
return ['success' => false, 'message' => __('订单状态已变化,积分补偿失败,请联系管理员处理。', 'aksrt')];
|
|
}
|
|
return ['success' => false, 'message' => __('订单状态已变化,积分已退回。', 'aksrt')];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
|
|
'message' => __('积分支付成功。', 'aksrt'),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function normalize_checkout_items($items): array {
|
|
if (!is_array($items)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
foreach ($items as $item) {
|
|
$product_id = (int) ($item['product_id'] ?? 0);
|
|
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
|
if ($product_id <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'product_id' => $product_id,
|
|
'quantity' => $quantity,
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function resolve_coupon_discount(string $coupon_code, float $order_amount, array $product_ids = []): array {
|
|
if ($coupon_code === '') {
|
|
return ['success' => true, 'discount' => 0.0, 'coupon' => []];
|
|
}
|
|
|
|
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
|
return ['success' => false, 'message' => __('优惠券系统不可用。', 'aksrt')];
|
|
}
|
|
|
|
$result = \AKSRT\Payment\Coupon::get_instance()->validate($coupon_code, $order_amount, [
|
|
'user_id' => get_current_user_id(),
|
|
'product_ids' => array_values(array_filter(array_map('intval', $product_ids))),
|
|
'context' => 'shop',
|
|
]);
|
|
if (empty($result['valid'])) {
|
|
return ['success' => false, 'message' => $result['message'] ?? __('优惠券不可用。', 'aksrt')];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'discount' => (float) ($result['discount'] ?? 0),
|
|
'coupon' => [
|
|
'code' => (string) ($result['coupon']['code'] ?? $coupon_code),
|
|
'type' => (string) ($result['coupon']['type'] ?? ''),
|
|
'value' => (float) ($result['coupon']['value'] ?? 0),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function resolve_shipping_quote(array $items, string $shipping_method): array {
|
|
$methods = $this->shop->get_shipping_method_options();
|
|
$method = $methods[$shipping_method] ?? $methods['express'];
|
|
|
|
$base_fee = (float) ($method['fee'] ?? 0);
|
|
foreach ($items as $item) {
|
|
$base_fee += (float) ($item['shipping']['fee'] ?? 0);
|
|
}
|
|
|
|
return [
|
|
'method' => $method,
|
|
'fee' => round($base_fee, 2),
|
|
];
|
|
}
|
|
|
|
private function resolve_checkout_address(int $user_id, int $address_id): array {
|
|
$addresses = $this->shop->get_address_book($user_id);
|
|
foreach ($addresses as $address) {
|
|
if ((int) ($address['id'] ?? 0) === $address_id) {
|
|
return $address;
|
|
}
|
|
}
|
|
|
|
return $addresses[0] ?? [];
|
|
}
|
|
|
|
private function resolve_payment_method(string $value): array {
|
|
$methods = $this->shop->get_payment_method_options();
|
|
return $methods[$value] ?? reset($methods);
|
|
}
|
|
|
|
public function get_checkout_hint(array $product): array {
|
|
return [
|
|
'delivery' => $product['shipping']['delivery_note'] ?: __('付款后自动进入订单中心,可继续查看物流和售后进度。', 'aksrt'),
|
|
'service' => $product['after_sale']['policy'] ?: __('支持在订单中心发起售后申请。', 'aksrt'),
|
|
];
|
|
}
|
|
}
|