Files
wordpress-theme-aksrt/inc/classes/shop/class-aksrt-shop-checkout-service.php
T
kate522 2ead893923 chore(pay): close residual zibll edge contracts for content pay
Expand coupon scope to content categories, document points_deduction as runtime-off like zibll, reject card_pass on content cashier, and pass shop/content context into coupon validate.
2026-07-14 22:12:12 +08:00

379 lines
15 KiB
PHP

<?php
/**
* Shop checkout service.
*
* Extracted from AKSRT_Shop to isolate checkout preview, order
* creation, and payment orchestration into a cohesive unit.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Shop_Checkout_Service {
private AKSRT_Shop $shop;
public function __construct(AKSRT_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_Discount')) {
$discount_result = AKSRT_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_Order_Repository')) {
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')) {
$order_settings = AKSRT_Settings::get_order_settings();
$expiry_minutes = max(5, min(1440, (int) ($order_settings['expiry'] ?? 30)));
}
$order_number = AKSRT_Order_Repository::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_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_Coupon')) {
$coupon_instance = AKSRT_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_Order_Repository')) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$repo = AKSRT_Order_Repository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
// 未显式指定时,使用订单创建时选定的支付方式。
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance'));
$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')];
}
// 携带订单号与摘要,供网关生成支付单。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
// Points payment: synchronous deduction via AKSRT_Credits
if ($payment_method === 'points') {
return $this->pay_with_points($user_id, $order_number, $order);
}
$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 履约)。
$repo->update_status($order_number, AKSRT_Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
}
private function pay_with_points(int $user_id, string $order_number, array $order): array {
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统不可用。', 'aksrt')];
}
$amount = (int) round((float) ($order['amount'] ?? 0));
if ($amount <= 0) {
return ['success' => false, 'message' => __('订单金额无效。', 'aksrt')];
}
$balance = AKSRT_Credits::balance($user_id);
if ($balance < $amount) {
return ['success' => false, 'message' => sprintf(__('积分不足,需要 %d 积分,当前余额 %d。', 'aksrt'), $amount, $balance)];
}
$deducted = AKSRT_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_Order_Repository::get_instance();
$repo->update_status($order_number, AKSRT_Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
]);
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_Coupon')) {
return ['success' => false, 'message' => __('优惠券系统不可用。', 'aksrt')];
}
$result = AKSRT_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'),
];
}
}