Files
wordpress-theme-aksrt/src/AKSRT/Shop/CheckoutService.php
T
kate522andClaude Fable 5 ed55d302e9 fix(payment): re-pay with fresh provider out_trade_no
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>
2026-07-26 13:28:45 +08:00

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'),
];
}
}