Files
wordpress-theme-aksrt/tests/widget-system/financial-flow-contract.test.cjs
T
kate522 83c164a427 feat(admin): 订单明细页已支付订单新增「退款」按钮
操作栏「详情」右侧为已支付订单渲染「退款」按钮:点击经 aksrt_pay_refund_order 把订单推到已退款。

退款沿既有的受闸路径执行:OrderRepository::update_status 要求 allow_refund,且 can_finalize_order_refund 要求该售后工单仍为待审核/处理中。因此工单号由服务端按订单号反查 find_active 得到,客户端只提交订单号;没有进行中的工单时整次操作中止并给出可操作提示,不会出现订单已退款但无工单依据的状态。

按钮仅在已支付订单上渲染——已退款是已支付的唯一后继状态,其他状态点击也只会在服务端被拒。附带清理 AfterSaleService::find_active 上方一处早已错配的 @param 文档注释(描述的是 \,与该函数签名无关)。
2026-10-08 21:14:56 +08:00

253 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../..')
const read = file => fs.readFileSync(path.join(root, file), 'utf8')
test('financial flow and withdrawal numbers use the new-only schema', () => {
const schema = read('src/Core/DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow \([\s\S]*?UNIQUE KEY uniq_flow_number \(flow_number\)/)
assert.match(schema, /UNIQUE KEY uniq_flow_operation \(operation_key\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow_entry \([\s\S]*?UNIQUE KEY uniq_flow_line \(flow_id, line_no\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_withdrawal \([\s\S]*?withdrawal_number VARCHAR\(32\) NOT NULL[\s\S]*?UNIQUE KEY uniq_withdrawal_number \(withdrawal_number\)/)
})
test('order, flow and withdrawal numbers share one prefix-free allocator', () => {
const jsSchema = read('assets/admin/src/schema/sections/payment/gateway.js')
const phpSchema = read('src/Admin/Schema/SchemaPayment.php')
const provider = read('src/Admin/SchemaProvider.php')
const runtime = read('src/Settings/PaymentRuntime.php')
const number = read('src/Finance/BusinessNumber.php')
// 三个前缀配置项及其校验/归一化链路必须彻底消失。
for (const source of [jsSchema, phpSchema]) {
assert.doesNotMatch(source, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(source, /business_prefix/)
}
assert.doesNotMatch(provider, /business_prefix|normalize_prefix/)
assert.doesNotMatch(runtime, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(runtime, /['"](prefix|flow_prefix|withdrawal_prefix)['"]\s*=>/)
assert.doesNotMatch(number, /normalize_prefix|MAX_PREFIX_LENGTH|candidate\(/)
// 唯一入口:无前缀的统一流水号分配器。
assert.match(number, /public static function allocate\(\?\\?DateTimeInterface/)
assert.match(number, /YmdHis/)
// 订单号、资金流水号、提现单号、卡密渠道交易号全部收敛到该分配器。
assert.match(read('src/Payment/OrderRepositoryCore.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Finance/FinancialFlowService.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/Withdrawal.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/ChargeCard.php'), /BusinessNumber::allocate\(\)/)
})
test('commission ledger and withdrawals retain their flow while order flows are removed', () => {
const ledger = read('src/Ledger/LedgerService.php')
const order = read('src/Payment/OrderRepositoryCore.php')
const withdrawal = read('src/Payment/Withdrawal.php')
assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
assert.doesNotMatch(order, /record_order_(?:payment|refund)/)
assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('COMMIT'\)/)
})
test('orders have no independent flow page or visible flow number', () => {
const menu = read('src/Admin/Native/PayMenus.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
assert.doesNotMatch(menu, /PayFlows|流水明细/)
assert.equal(fs.existsSync(path.join(root, 'src/Admin/Native/PayFlows.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false)
assert.match(payOrders, /订单明细/)
assert.doesNotMatch(payOrders, /账本流水号|资金流水号/)
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('订单号'/)
assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'|提现单号/)
// 前台订单详情只展示公开订单号,账本流水号不下发也不展示。
assert.match(read('assets/js/order-center.js'), /\["订单号", order\.order_number\]/)
assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)
assert.doesNotMatch(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
})
test('ledger history carries no financial flow coupling', () => {
const query = read('src/Ledger/LedgerQuery.php')
const records = [
read('inc/helpers/user-center-renderers/credits-transactions.php'),
read('inc/helpers/user-center-renderers/credits-wallet-ledger.php'),
].join('\n')
// 前台余额/积分记录只展示中文变动原因与变动额:提现单号既不查询也不下发,
// 账本历史不再为了它 LEFT JOIN financial_flow。
assert.doesNotMatch(query, /financial_flow|withdrawal_number/)
assert.doesNotMatch(records, /\$row\['ref_key'\]|\$row\['remark'\]|withdrawal_number|uc-transactions__remark/)
})
test('user withdrawal history uses one paginated ten-item endpoint', () => {
const controller = read('src/Payment/CommissionLedger.php')
const renderer = read('inc/helpers/user-center-renderers/credits-wallet.php')
const script = read('assets/js/credits.js')
const styles = read('assets/css/routes/user-center/credits.css')
assert.match(controller, /\$withdrawal_args\['per_page'\]\['default'\]\s*=\s*10/)
assert.match(controller, /\$withdrawal_args\['per_page'\]\['maximum'\]\s*=\s*10/)
assert.match(controller, /'total'\s*=>\s*\$total/)
assert.match(controller, /'max_pages'\s*=>\s*\$max_pages/)
assert.match(controller, /'user_id'\s*=>\s*\$user_id/)
assert.doesNotMatch(renderer, /get_user_withdrawals\(/)
assert.doesNotMatch(renderer, /withdraw_history/)
assert.match(renderer, /data-aksrt-withdraw-history-list/)
assert.match(script, /\/earnings\/withdrawals\?page=/)
assert.match(script, /&per_page=10/)
assert.match(script, /data-aksrt-withdraw-history-page/)
assert.match(script, /实际到账:/)
assert.match(script, /元)/)
assert.match(script, /appendText\(row, text\(item && item\.created_at\)/)
assert.doesNotMatch(script, /item\.note/)
assert.doesNotMatch(styles, /uc-withdraw-history__note/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?max-width: min\(860px/)
assert.match(styles, /\.uc-withdraw-history__item\s*\{[\s\S]*?display: flex/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?overflow: hidden/)
})
test('flow table is indexed for the admin listing and date range filters', () => {
const schema = read('src/Core/DBMigratorPart2.php')
// 后台默认视图是 WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n,外加同条件的
// COUNT(*),界面上的日期区间筛选也只有 created_at 可用;created_at 必须是索引前导列,
// 否则每次翻页都是全表扫描 + filesort。
assert.match(schema, /KEY idx_flow_created \(created_at\),/)
// 复合索引前导列是 event_type(筛选时可空),替代不了上面那条。
assert.match(schema, /KEY idx_flow_event_time \(event_type, created_at\),/)
})
test('flow retention and archive were deliberately removed', () => {
// 归档方案(归档表 + 每日清理 cron)已移除:一条 created_at 索引覆盖了它要解决的问题,
// 而它会在财务表上执行 DELETE,并需要额外的归档浏览入口才闭环。
assert.doesNotMatch(read('src/Core/DBMigratorPart2.php'), /financial_flow_archive/)
assert.doesNotMatch(read('src/Core/DBMigratorPart3.php'), /financial_flow_archive/)
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('order detail page drops tabs; after-sales becomes a standalone submenu', () => {
const page = read('src/Admin/Native/PayOrders.php')
const menus = read('src/Admin/Native/PayMenus.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除,售后 Tab 移出
assert.ok(!page.includes('render_workbench_tabs'), 'workbench tabs must be removed')
assert.ok(!page.includes("$_GET['tab']"), 'tab query param must not be read')
assert.ok(!page.includes('render_statistics_page'), 'statistics page must be removed')
assert.ok(!page.includes('stat_card'), 'stat cards must be removed')
assert.ok(!repository.includes('get_admin_order_stats'), 'dead stats query must be removed')
// 售后独立为子菜单「售后服务」(aksrt_after_sales),无兼容跳转
assert.ok(menus.includes("'slug' => 'aksrt_after_sales'"), 'after-sales submenu slug')
assert.ok(page.includes('render_after_sales_admin_page'), 'after-sales admin page entry')
assert.ok(page.includes("PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales'"), 'after-sales slug constant')
assert.ok(!page.includes('after-sales'), 'old tab slug must not remain')
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
assert.ok(page.includes('aksrt-as-field--wide'), 'description renders as a full-width row')
assert.ok(page.includes('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
// 「标记订单已退款」:勾选后确认时把关联订单推到已退款
assert.ok(page.includes('aksrt-mark-order-refunded'), 'refund checkbox inside expanded panel')
assert.ok(page.includes('标记订单已退款'), 'refund checkbox label')
assert.ok(page.includes('mark_order_refunded:markRefund?"1":""'), 'refund flag posted only when checked')
assert.ok(page.includes('var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'),
'a disabled (already refunded) checkbox submits nothing')
assert.ok(page.includes('aksrt-as-refund-flag'), 'refund checkbox keeps its own row in the panel')
// 订单退款必须早于售后状态更新:工单一离开 pending/processing,OrderRepository 的退款闸门就不再放行
assert.ok(page.indexOf('mark_related_order_refunded($id)') < page.indexOf('set_status($id'),
'order refund runs before the after-sale status update')
assert.ok(page.includes('DomainModel::ORDER_STATUS_REFUNDED'), 'refund marking targets the refunded order status')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
assert.ok(page.includes("'after_sale_id' => $after_sale_id"), 'refund is bound to this after-sale application')
assert.ok(page.includes('after_sale_order_refund_failed'), 'refund failure is reported to the admin')
assert.ok(page.includes('after_sale_updated_order_refunded'), 'refund success is reported to the admin')
const service = read('src/Payment/AfterSaleService.php')
assert.match(service, /public function get\(int \$id\)/, 'after-sale lookup is exposed for the admin handler')
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
assert.ok(page.includes('document.addEventListener("click"'), 'delegated click handler survives row re-render')
assert.ok(!page.includes('class="row-actions"'), 'no hover-only row-actions')
assert.ok(!page.includes('aksrt_after_sale_bulk'), 'bulk review handler removed')
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
assert.ok(page.includes('get_after_sale_settings()'), 'policy summary reads settings')
assert.ok(page.includes('预置原因'), 'policy summary shows reasons')
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
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+;/)
assert.match(repository, /for \(\$attempt = 1; \$attempt <= \$max_attempts; \$attempt\+\+\)/)
// 只对订单号唯一键冲突重试;request_key 等冲突属于调用方幂等语义,必须原样失败。
assert.match(repository, /stripos\(\(string\) \$wpdb->last_error, 'uniq_order_number'\)/)
// 调用方显式传入的合规订单号不得换号重试。
assert.match(repository, /\$max_attempts = \$caller_supplied \? 1 : self::ORDER_NUMBER_MAX_ATTEMPTS;/)
})
test('caller-supplied order numbers are constrained to allocator shapes', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
// 22 位是当前规则,20 位兼容扩位前的历史号码(导入旧数据 / 旧单重放)。
// 用精确字符串断言:正则字面量里转义 \d 与花括号极易写错,会静默失配。
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
// 非标量(数组 / 对象)不得进入 (string) 转换,否则会写入 "Array"。
assert.match(repository, /is_scalar\(\$data\['order_number'\]\)/)
// 不合规时必须回落到分配器,而不是原样写库。
assert.match(repository, /\$order_number = \$caller_supplied \? \$declared_number : \$this->allocate_order_number\(\);/)
// 弃用不受控号码时在 WP_DEBUG 下留痕。
assert.match(repository, /error_log\(sprintf\('\[AKSRT Order\] 弃用不合规的自带订单号:%s'/)
})
test('order cleanup no longer guards on financial records', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
assert.match(repository, /public function clear_closed_orders\(int \$days = 14\): int/)
assert.match(repository, /public function delete_order\(string \$order_number\): bool/)
for (const source of [repository, payOrders]) {
assert.doesNotMatch(source, /has_financial_records|count_closed_orders_with_records|financial_records_exists_sql/)
assert.doesNotMatch(source, /orders_clear_blocked|order_delete_blocked/)
}
})