From 8ff1408450e19c5b8baf5c8052ecf78037ba045b Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 15 Jul 2026 13:13:02 +0800 Subject: [PATCH] fix(pay): preserve order note JSON, shared coupon burn, plate free VIP cashier - Order_Repository: status note merges into JSON as status_note instead of wiping coupon/topic_pay meta - Extract aksrt_burn_order_coupon / aksrt_coupon_usage_exists for content + topic - Money plate follow (incl. VIP free) always opens multi-gateway cashier for 0-order ledger --- assets/js/forum-governance.js | 7 +- docs/zibll-gap-matrix.md | 2 +- inc/bootstrap.php | 1 + inc/classes/class-aksrt-content-pay.php | 31 +------- inc/classes/class-aksrt-order-repository.php | 29 +++++++- inc/classes/class-aksrt-plate-follow-pay.php | 3 + inc/classes/class-aksrt-topic-pay.php | 37 ++-------- inc/helpers/aksrt-coupon-order.php | 74 ++++++++++++++++++++ 8 files changed, 119 insertions(+), 65 deletions(-) create mode 100644 inc/helpers/aksrt-coupon-order.php diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index 3d2d7e3e..12b43db7 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -693,8 +693,9 @@ payHint = payHint || {}; var modo = String(payHint.modo || 'money'); - // Cash: multi-gateway cashier (no Credits×100 bypass). - if (modo === 'money' && !payHint.is_free) { + // Cash (paid or VIP free): multi-gateway cashier — free VIP still creates 0-order for ledger. + // No Credits×100 bypass. + if (modo === 'money') { button.disabled = false; if (typeof window.aksrtOpenCashier === 'function') { window.aksrtOpenCashier(plateId, button, '', '', 'plate_follow'); @@ -706,7 +707,7 @@ return; } - // Points / VIP free: keep AJAX purchase path. + // Points / points VIP free: keep AJAX purchase path. var confirmMsg = '确认付费关注该版块?'; if (payHint.is_free) { confirmMsg = '确认使用 VIP 免费关注该版块?'; diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 8cf6e551..99692f72 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -100,7 +100,7 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 3 users.php submenus | done | | 4 SPA strip | done (ops routes redirect to native) | | 5+ behavior parity | done — native ops shell + plate follow-pay + topic pay matrix + multi-gateway topic cashier (`AKSRT_Topic_Pay` /pay/topic) + topic coupon burn (P1) + plate follow VIP1/2 + multi-gateway (`AKSRT_Plate_Follow_Pay` /pay/plate-follow) + cash creator revenue (content/topic/plate) + sales report/download logs + hidecontent + users commerce + order filters/clear-closed + post pay_type filter + bulk content-pay edit + income/rebate user filter | -| 5 remaining | optional polish only (plate coupons, guest topic/plate purchase — out of scope) | +| 5 remaining | optional polish only (plate coupons, guest topic/plate purchase — out of scope); order note JSON preserve + shared coupon burn helper done | ## Dual-UI rule diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 89c8e8dd..73278505 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -40,6 +40,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-i18n-switch.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-posts-renderer.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-resource.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-pay.php'; +require_once AKSRT_DIR . '/inc/helpers/aksrt-coupon-order.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-credits.php'; require_once AKSRT_DIR . '/inc/helpers/menu-decoration.php'; require_once AKSRT_DIR . '/inc/helpers/class-aksrt-nav-walker.php'; diff --git a/inc/classes/class-aksrt-content-pay.php b/inc/classes/class-aksrt-content-pay.php index 090cf0b3..ae890b79 100644 --- a/inc/classes/class-aksrt-content-pay.php +++ b/inc/classes/class-aksrt-content-pay.php @@ -246,21 +246,9 @@ class AKSRT_Content_Pay { $buyer_id = (int) ($order['user_id'] ?? 0); - // Coupon burn. - $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; - $coupon_code = sanitize_text_field((string) ($coupon['code'] ?? $meta['coupon_code'] ?? '')); - $coupon_discount = (float) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? 0)); - if ($coupon_code !== '' && class_exists('AKSRT_Coupon') && !$this->coupon_usage_exists($order_number)) { - $coupon_api = AKSRT_Coupon::get_instance(); - $coupon_api->use_coupon($coupon_code); - $validated = $coupon_api->validate($coupon_code, max(0.01, $coupon_discount), ['user_id' => $buyer_id]); - $coupon_id = (int) ($validated['coupon']['id'] ?? $coupon['id'] ?? 0); - if ($coupon_id <= 0 && !empty($coupon['id'])) { - $coupon_id = (int) $coupon['id']; - } - if ($coupon_id > 0) { - $coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, max(0.0, $coupon_discount)); - } + // Coupon burn (shared helper; idempotent via coupon_usage.order_number). + if (function_exists('aksrt_burn_order_coupon')) { + aksrt_burn_order_coupon($order_number, $meta, $buyer_id); } // Affiliate commission (does not reduce buyer pay). @@ -307,19 +295,6 @@ class AKSRT_Content_Pay { return is_array($decoded) ? $decoded : []; } - private function coupon_usage_exists(string $order_number): bool { - global $wpdb; - $table = AKSRT_DB_Migrator::table('coupon_usage'); - if ($table === '' || $order_number === '') { - return false; - } - $found = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM {$table} WHERE order_number = %s LIMIT 1", - $order_number - )); - return !empty($found); - } - private function rebate_log_exists(string $order_number, int $referrer_id): bool { global $wpdb; $table = AKSRT_DB_Migrator::table('rebate_log'); diff --git a/inc/classes/class-aksrt-order-repository.php b/inc/classes/class-aksrt-order-repository.php index 9fa4b06c..bb347b0a 100644 --- a/inc/classes/class-aksrt-order-repository.php +++ b/inc/classes/class-aksrt-order-repository.php @@ -316,7 +316,17 @@ class AKSRT_Order_Repository { } if (!empty($extra['note'])) { - $update_data['note'] = sanitize_textarea_field($extra['note']); + // Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note. + $status_note = sanitize_textarea_field((string) $extra['note']); + $raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; + $decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null; + if (is_array($decoded_note)) { + $decoded_note['status_note'] = $status_note; + $merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $update_data['note'] = is_string($merged) ? $merged : $status_note; + } else { + $update_data['note'] = $status_note; + } $format[] = '%s'; } @@ -331,6 +341,23 @@ class AKSRT_Order_Repository { 'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''), 'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''), ]); + if (isset($update_data['note'])) { + // Keep pre-update JSON payload for listeners (coupon burn etc.); + // only attach status_note if callers mutated note. + $pre_meta = []; + $pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; + if ($pre_raw !== '') { + $pre_decoded = json_decode($pre_raw, true); + if (is_array($pre_decoded)) { + $pre_meta = $pre_decoded; + } + } + if ($pre_meta !== []) { + $paid_order['meta_payload'] = $pre_meta; + // Prefer merged DB note (JSON + status_note) for downstream reads. + $paid_order['note'] = $update_data['note']; + } + } /** * 订单到账。 diff --git a/inc/classes/class-aksrt-plate-follow-pay.php b/inc/classes/class-aksrt-plate-follow-pay.php index 65edb0db..6a67b122 100644 --- a/inc/classes/class-aksrt-plate-follow-pay.php +++ b/inc/classes/class-aksrt-plate-follow-pay.php @@ -616,6 +616,9 @@ class AKSRT_Plate_Follow_Pay { } private function decode_order_note_meta(array $order): array { + if (isset($order['meta_payload']) && is_array($order['meta_payload']) && $order['meta_payload'] !== []) { + return $order['meta_payload']; + } $note = (string) ($order['note'] ?? ''); if ($note === '') { return []; diff --git a/inc/classes/class-aksrt-topic-pay.php b/inc/classes/class-aksrt-topic-pay.php index d1061417..b7dcb768 100644 --- a/inc/classes/class-aksrt-topic-pay.php +++ b/inc/classes/class-aksrt-topic-pay.php @@ -680,39 +680,9 @@ class AKSRT_Topic_Pay { } $buyer_id = (int) ($order['user_id'] ?? 0); - $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; - $coupon_code = sanitize_text_field((string) ($coupon['code'] ?? $meta['coupon_code'] ?? '')); - $coupon_discount = (float) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? 0)); - if ($coupon_code === '' || !class_exists('AKSRT_Coupon') || $this->coupon_usage_exists($order_number)) { - return; + if (function_exists('aksrt_burn_order_coupon')) { + aksrt_burn_order_coupon($order_number, $meta, $buyer_id); } - - $coupon_api = AKSRT_Coupon::get_instance(); - $coupon_api->use_coupon($coupon_code); - $validated = $coupon_api->validate($coupon_code, max(0.01, $coupon_discount), ['user_id' => $buyer_id]); - $coupon_id = (int) ($validated['coupon']['id'] ?? $coupon['id'] ?? 0); - if ($coupon_id <= 0 && !empty($coupon['id'])) { - $coupon_id = (int) $coupon['id']; - } - if ($coupon_id > 0) { - $coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, max(0.0, $coupon_discount)); - } - } - - private function coupon_usage_exists(string $order_number): bool { - if ($order_number === '' || !class_exists('AKSRT_DB_Migrator')) { - return false; - } - global $wpdb; - $table = AKSRT_DB_Migrator::table('coupon_usage'); - if ($table === '') { - return false; - } - $found = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM {$table} WHERE order_number = %s LIMIT 1", - $order_number - )); - return !empty($found); } /** @@ -847,6 +817,9 @@ class AKSRT_Topic_Pay { } private function decode_order_note_meta(array $order): array { + if (isset($order['meta_payload']) && is_array($order['meta_payload']) && $order['meta_payload'] !== []) { + return $order['meta_payload']; + } $note = (string) ($order['note'] ?? ''); if ($note === '') { return []; diff --git a/inc/helpers/aksrt-coupon-order.php b/inc/helpers/aksrt-coupon-order.php new file mode 100644 index 00000000..3df04d57 --- /dev/null +++ b/inc/helpers/aksrt-coupon-order.php @@ -0,0 +1,74 @@ +get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE order_number = %s LIMIT 1", + $order_number + )); + return !empty($found); + } +} + +if (!function_exists('aksrt_burn_order_coupon')) { + /** + * Burn coupon for a paid cash order (use_coupon + log_usage). + * + * @param array $meta Order note JSON (coupon_code / coupon / coupon_discount). + * @return bool True when a burn was attempted (or already burned). + */ + function aksrt_burn_order_coupon(string $order_number, array $meta, int $buyer_id = 0): bool { + if ($order_number === '' || !class_exists('AKSRT_Coupon')) { + return false; + } + if (aksrt_coupon_usage_exists($order_number)) { + return true; + } + + $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; + $coupon_code = sanitize_text_field((string) ($coupon['code'] ?? $meta['coupon_code'] ?? '')); + $coupon_discount = (float) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? 0)); + if ($coupon_code === '') { + return false; + } + + $coupon_api = AKSRT_Coupon::get_instance(); + $coupon_api->use_coupon($coupon_code); + + // Prefer snapshot id: post-increment validate may fail on usage_limit edge. + $coupon_id = (int) ($coupon['id'] ?? 0); + if ($coupon_id <= 0) { + $validated = $coupon_api->validate($coupon_code, max(0.01, $coupon_discount), [ + 'user_id' => max(0, $buyer_id), + ]); + $coupon_id = (int) ($validated['coupon']['id'] ?? 0); + } + + if ($coupon_id > 0) { + $coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, max(0.0, $coupon_discount)); + } + + return true; + } +}