diff --git a/src/Admin/Native/PayOrders.php b/src/Admin/Native/PayOrders.php index e8385a05..e0b62317 100644 --- a/src/Admin/Native/PayOrders.php +++ b/src/Admin/Native/PayOrders.php @@ -30,6 +30,7 @@ class PayOrders { add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']); add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']); add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']); + add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order']); add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']); } @@ -254,6 +255,15 @@ class PayOrders { echo '' . esc_html((string) ($row['created_at'] ?? '')) . '
' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . ''; echo ''; echo '' . esc_html__('详情', 'aksrt') . ''; + // 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。 + if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_refund_order_' . $order_number); + submit_button(__('退款', 'aksrt'), 'small', '', false); + echo '
'; + } $this->render_order_quick_status_buttons($order_number, $status_key); if ($status_key === 'closed') { echo '
'; @@ -631,6 +641,47 @@ class PayOrders { exit; } + /** + * 订单列表「退款」:把已支付订单推到已退款。退款必须落到一条进行中的售后工单上, + * 工单号由服务端按订单号反查,不经客户端传递。 + */ + public function handle_refund_order(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + + $order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : ''; + check_admin_referer('aksrt_pay_refund_order_' . $order_number); + + $code = 'order_refund_failed'; + if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) { + $repo = \AKSRT\Payment\OrderRepository::get_instance(); + $order = $repo->get($order_number); + if (!is_array($order)) { + $code = 'order_refund_missing'; + } elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { + $code = 'order_refund_not_paid'; + } else { + $ticket = class_exists('\AKSRT\Payment\AfterSaleService') + ? \AKSRT\Payment\AfterSaleService::get_instance()->find_active($order_number) + : null; + if (!is_array($ticket)) { + $code = 'order_refund_no_ticket'; + } else { + $ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [ + 'allow_refund' => true, + 'after_sale_id' => (int) ($ticket['id'] ?? 0), + 'note' => __('管理员在订单列表中标记退款', 'aksrt'), + ]); + $code = $ok ? 'order_refunded' : 'order_refund_failed'; + } + } + } + + wp_safe_redirect($this->page_url('aksrt_orders', ['aksrt_pay_msg' => $code])); + exit; + } + public function handle_update_order_note(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); @@ -769,6 +820,11 @@ class PayOrders { $map = [ 'order_updated' => __('订单状态已更新。', 'aksrt'), 'order_failed' => __('订单状态更新失败。', 'aksrt'), + 'order_refunded' => __('订单已标记为已退款。', 'aksrt'), + 'order_refund_failed' => __('订单标记退款失败。', 'aksrt'), + 'order_refund_missing' => __('订单不存在。', 'aksrt'), + 'order_refund_not_paid' => __('仅已支付订单可以标记退款。', 'aksrt'), + 'order_refund_no_ticket' => __('该订单没有进行中的售后工单,无法标记退款;请先在「售后服务」中处理。', 'aksrt'), 'order_note_updated' => __('订单备注已保存。', 'aksrt'), 'order_note_failed' => __('订单备注保存失败。', 'aksrt'), 'orders_cleared' => sprintf( diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php index df22a245..4aa8c66a 100644 --- a/src/Payment/AfterSaleService.php +++ b/src/Payment/AfterSaleService.php @@ -313,8 +313,8 @@ final class AfterSaleService { return $this->get($id); } - /** @param array> $entries */ - private function find_active(string $number): ?array { + /** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */ + public function find_active(string $number): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A); return is_array($row) ? $this->normalize($row) : null; diff --git a/tests/dev/render-after-sales-harness.php b/tests/dev/render-after-sales-harness.php index 2320d87a..79a60c6e 100644 --- a/tests/dev/render-after-sales-harness.php +++ b/tests/dev/render-after-sales-harness.php @@ -1,8 +1,9 @@ render page, write after-sales-rendered.html +// php render-after-sales-harness.php -> after-sales page -> after-sales-rendered.html +// php render-after-sales-harness.php orders -> orders list page -> orders-rendered.html // php render-after-sales-harness.php -> run one handler scenario, print JSON log namespace { define('ABSPATH', __DIR__ . '/'); @@ -54,9 +55,23 @@ function wp_die($m) { throw new Exception('wp_die: ' . $m); } function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; } function get_the_title($id) { return 'Product' . $id; } function is_admin() { return true; } +function paginate_links($args = []) { return ''; } +} + +namespace AKSRT\Ledger { + final class AssetAmount { + public static function balance($value): self { return new self(); } + public function decimal(): string { return '0.00'; } + } } namespace AKSRT\Payment { + class Gateway { + private static $i; + public static function get_instance(): self { return self::$i ?: self::$i = new self(); } + public function get_all(): array { return ['balance' => (object) [], 'points' => (object) []]; } + } + class AfterSaleService { public const STATUS_PENDING = 'pending'; public const STATUS_PROCESSING = 'processing'; @@ -77,6 +92,16 @@ namespace AKSRT\Payment { \HarnessLog::add('can_finalize_order_refund', ['id' => $after_sale_id, 'order' => $order_number, 'ticket_status' => is_array($row) ? (string) ($row['status'] ?? '') : null, 'ok' => $ok]); return $ok; } + public function find_active(string $order_number): ?array { + $found = null; + foreach (self::$rows as $row) { + if ((string) ($row['order_number'] ?? '') !== $order_number) continue; + if (!in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) continue; + if ($found === null || (int) ($row['id'] ?? 0) > (int) ($found['id'] ?? 0)) $found = $row; + } + \HarnessLog::add('find_active', ['order' => $order_number, 'ticket' => $found === null ? null : (int) ($found['id'] ?? 0)]); + return $found; + } public function set_status(int $id, string $status, string $note = '') { \HarnessLog::add('set_status', ['id' => $id, 'status' => $status, 'note' => $note]); if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) { @@ -96,10 +121,13 @@ namespace AKSRT\Payment { public static array $orders = []; public static function get_instance(): self { return self::$i ?: self::$i = new self(); } public function get($n): ?array { return self::$orders[(string) $n] ?? null; } + public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); } + public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); } public function update_status(string $order_number, string $new_status, array $extra = []): bool { \HarnessLog::add('update_status', ['order' => $order_number, 'status' => $new_status, 'extra' => $extra]); // Mirrors OrderRepository::update_status(): refund needs allow_refund, an active // after-sale id, and a valid paid -> refunded transition. + if ($new_status !== 'refunded') return true; if (empty($extra['allow_refund'])) return false; $order = self::$orders[$order_number] ?? null; if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false; @@ -121,8 +149,42 @@ namespace AKSRT\Core { public const ORDER_STATUS_PAID = 'paid'; public const ORDER_STATUS_REFUNDED = 'refunded'; public const ORDER_STATUS_CLOSED = 'closed'; + public const ORDER_PRODUCT_RESOURCE = 'resource'; public static function resolve_order_product_type(string $t, array $m): string { return $t; } - public static function order_product_type_labels(): array { return ['reading' => '付费阅读']; } + public const ORDER_PRODUCT_READING = 'reading'; + public const ORDER_PRODUCT_DOWNLOAD = 'download'; + public const ORDER_PRODUCT_IMAGE = 'image'; + public const ORDER_PRODUCT_VIDEO = 'video'; + public const ORDER_PRODUCT_VIP = 'vip'; + public const ORDER_PRODUCT_RECHARGE = 'recharge'; + public const ORDER_PRODUCT_POINTS = 'points'; + public static function order_product_type_labels(): array { + return [ + self::ORDER_PRODUCT_READING => '付费阅读', + self::ORDER_PRODUCT_DOWNLOAD => '付费下载', + self::ORDER_PRODUCT_IMAGE => '付费图片', + self::ORDER_PRODUCT_VIDEO => '付费视频', + self::ORDER_PRODUCT_VIP => '购买会员', + self::ORDER_PRODUCT_RECHARGE => '余额充值', + self::ORDER_PRODUCT_POINTS => '购买积分', + ]; + } + public static function order_status_labels(): array { + return [ + self::ORDER_STATUS_PENDING => '待支付', + self::ORDER_STATUS_PAID => '已支付', + self::ORDER_STATUS_REFUNDED => '已退款', + self::ORDER_STATUS_CLOSED => '已关闭', + ]; + } + public static function order_transitions(): array { + return [ + self::ORDER_STATUS_PENDING => [self::ORDER_STATUS_PAID, self::ORDER_STATUS_CLOSED], + self::ORDER_STATUS_PAID => [self::ORDER_STATUS_REFUNDED], + self::ORDER_STATUS_REFUNDED => [], + self::ORDER_STATUS_CLOSED => [], + ]; + } } } @@ -151,10 +213,22 @@ namespace { 'created_at' => '2026-10-02 11:00:00', 'reviewed_at' => '2026-10-03 09:00:00'], ]; \AKSRT\Payment\OrderRepository::$orders = [ - 'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'], - 'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'], + 'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'], + 'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '8.00', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-02 09:00:00', 'paid_at' => '2026-10-02 09:05:00'], + 'ORD102' => ['order_number' => 'ORD102', 'user_id' => 5, 'product_id' => 11, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '20.00', 'payment_method' => 'balance', 'status' => 'refunded', 'created_at' => '2026-10-03 09:00:00', 'paid_at' => '2026-10-03 09:05:00'], + 'ORD103' => ['order_number' => 'ORD103', 'user_id' => 6, 'product_id' => 12, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '5.00', 'payment_method' => 'balance', 'status' => 'pending', 'created_at' => '2026-10-04 09:00:00', 'paid_at' => ''], + 'ORD104' => ['order_number' => 'ORD104', 'user_id' => 7, 'product_id' => 13, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '7.00', 'payment_method' => 'balance', 'status' => 'closed', 'created_at' => '2026-10-05 09:00:00', 'paid_at' => '2026-10-05 09:05:00'], ]; + if ($scenario === 'orders') { + $_GET = ['page' => 'aksrt_orders']; + $pay = AKSRT\Admin\Native\PayOrders::get_instance(); + ob_start(); + $pay->render_orders_page(); + file_put_contents(__DIR__ . '/orders-rendered.html', ob_get_clean()); + exit(0); + } + if ($scenario !== '') { // The handler ends in exit(), so the call log is flushed on shutdown. register_shutdown_function(static function (): void { @@ -185,13 +259,39 @@ namespace { case 'bad_status': $_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => '']; break; + case 'refund_paid': + $_POST = ['order_number' => 'ORD100']; + break; + case 'refund_no_ticket': + unset(\AKSRT\Payment\AfterSaleService::$rows[5]); + $_POST = ['order_number' => 'ORD100']; + break; + case 'refund_inactive_ticket': + \AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed'; + $_POST = ['order_number' => 'ORD100']; + break; + case 'refund_not_paid': + \AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending'; + $_POST = ['order_number' => 'ORD100']; + break; + case 'refund_already_refunded': + \AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded'; + $_POST = ['order_number' => 'ORD100']; + break; + case 'refund_missing': + $_POST = ['order_number' => 'ORDNOPE']; + break; default: fwrite(STDERR, "unknown scenario: {$scenario}\n"); exit(2); } $pay = AKSRT\Admin\Native\PayOrders::get_instance(); - $pay->handle_after_sale_status(); + if (str_starts_with($scenario, 'refund')) { + $pay->handle_refund_order(); + } else { + $pay->handle_after_sale_status(); + } // Unreachable: the handler ends in exit(). } diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs index eddcd4ed..59d0ac8c 100644 --- a/tests/widget-system/financial-flow-contract.test.cjs +++ b/tests/widget-system/financial-flow-contract.test.cjs @@ -194,6 +194,29 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', ( assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/) }) +test('paid orders expose a refund button next to the detail link', () => { + const page = read('src/Admin/Native/PayOrders.php') + const service = read('src/Payment/AfterSaleService.php') + // 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态 + assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/) + assert.ok(page.includes("if (\$status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"), + 'refund button only renders for paid orders') + assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund') + assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [\$this, 'handle_refund_order'])"), + 'refund action registered') + assert.ok(page.includes('handle_refund_order'), 'refund handler exists') + // 工单号由服务端按订单号反查,客户端只能传订单号 + assert.ok(page.includes("AfterSaleService::get_instance()->find_active(\$order_number)"), + 'active after-sale application is resolved server-side') + assert.ok(!page.includes('after_sale_id\' => absint'), 'after-sale id is never taken from the request') + assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition') + // 各种失败都要给出可操作的提示,而不是静默失败 + for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) { + assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`) + } + assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed') +}) + test('order creation retries only on order-number unique key collisions', () => { const repository = read('src/Payment/OrderRepositoryCore.php') assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)