Files
wordpress-theme-aksrt/tests/admin/card-pass-order-numbers.test.cjs
T
kate522 ba8d4b1b90 卡密充值与兑换接入统一订单号与支付流水号
此前卡密核销虽已建订单,但订单号硬编码 CARD{卡ID}、渠道交易号硬编码
card_pass_{卡ID},均不受后台前缀控制;且核销产生的资金流水没有挂到
订单上(order_number 为 NULL),订单详情页的「支付流水号」恒为「—」。

ChargeCard:
- 新增 allocate_redeem_order_number():走 OrderRepository 统一分配器,
  消费后台「订单前缀」(payment_order_prefix)。
- 新增 card_pass_transaction_number():BusinessNumber::candidate(flow_prefix),
  消费后台「流水号前缀」(payment_flow_prefix)。
- 两条兑换路径(redeem_asset_card / redeem_vip_card)替换硬编码取号;
  create_redeem_order 接收调用方传入的交易号。
- 账本重放(replayed)时回取已落库订单号,不再把本次新分配的号回给前端。

FinancialFlowService:
- record_card_pass_payment():会员卡(0 元、无账本参与)直接落一条
  与订单关联、零分录的 order_payment 流水;record() 新增
  allow_empty_entries 通道(prepare_entries 拒绝 delta=0,占位分录不可行)。
- record_order_event() 新增凭证结算模式:豁免「金额必须 > 0」与
  「资产类订单不得记现金流水」两条门槛(卡密核销既无第三方现金流入,
  订单还可能为 0 元或以积分计价)。
- attach_order_to_ledger_flow():账本提交后把流水补挂到订单上,
  event_type 归一为 order_payment,使「支付流水号」可反查。
- is_successful_order_payment() 认可 card_pass 凭证结算;
  is_external_recharge() 把 card_pass 与 balance/points 一并列为平台内部
  结算方式,不再为卡密充值伪造现金出账分录(现金在售卡时已确认)。

关键取舍:随机订单号不得进入 LedgerCommand 的 metadata —— payload_hash
覆盖 metadata,会破坏账本幂等重放(实测撞 uniq_operation_key 后报
aksrt_ledger_idempotency_conflict),因此订单关联改在账本提交后补挂。

测试:新增 tests/admin/card-pass-order-numbers.test.cjs 钉住上述约束;
容器内三类卡密实测通过(订单号/渠道交易号/支付流水号三项均带后台前缀,
流水行分别携带 balance:in / points:in / 零分录),重放幂等恢复正常。
2026-09-17 14:15:55 +08:00

89 lines
5.5 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.
'use strict';
/**
* 卡密核销的订单号 / 渠道交易号 / 支付流水号契约。
*
* 背景(已修复的缺陷):
* 卡密充值(balance/credits)与兑换(vip)虽已建订单,但订单号硬编码为
* `CARD{卡ID}`、渠道交易号硬编码为 `card_pass_{卡ID}`,均不受后台
* 「订单前缀 / 流水号前缀」控制;且卡密核销产生的资金流水没有挂到订单上
* (order_number 为 NULL),订单详情页的「支付流水号」永远显示「—」。
*
* 处置:
* - 订单号走 OrderRepository::allocate_order_number()(消费 payment_order_prefix);
* - 渠道交易号走 BusinessNumber::candidate(flow_prefix)(消费 payment_flow_prefix);
* - 余额/积分卡的流水由账本落库后用 attach_order_to_ledger_flow() 补挂订单号,
* 并把 event_type 归一为 order_payment;会员卡(0 元、无账本参与)由
* record_card_pass_payment() 直接落一条零分录的 order_payment 流水。
*
* 本契约额外钉住两条容易回退的坑:
* 1) **账本命令必须保持确定性** —— LedgerCommand::payload_hash() 覆盖 metadata,
* 把随机订单号塞进 credit() 的 metadata 会让重放从「幂等返回」变成
* `aksrt_ledger_idempotency_conflict`(实测复现过),因此 metadata 只允许 card_type。
* 2) **会员卡的零分录流水是刻意设计** —— prepare_entries() 拒绝 delta=0,
* 0 元凭证核销既无现金也无资产分录,只能以「零分录流水行」表达,
* record() 为此提供 allow_empty_entries 通道;不得用占位分录伪造。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const card = fs.readFileSync(path.join(root, 'src/Payment/ChargeCard.php'), 'utf8');
const flows = fs.readFileSync(path.join(root, 'src/Finance/FinancialFlowService.php'), 'utf8');
test('卡密订单号不再硬编码 CARD 前缀,改走统一分配器', () => {
assert.doesNotMatch(card, /'CARD'\s*\.\s*\$card_id/);
assert.match(card, /allocate_redeem_order_number\(\)/);
assert.match(card, /allocate_order_number\(\)/);
// 两条兑换路径都必须先取号并在失败时中断
const assetPart = card.slice(card.indexOf('private function redeem_asset_card'), card.indexOf('private function redeem_vip_card'));
const vipPart = card.slice(card.indexOf('private function redeem_vip_card'), card.indexOf('private function create_redeem_order'));
for (const [name, part] of [['redeem_asset_card', assetPart], ['redeem_vip_card', vipPart]]) {
assert.match(part, /\$order_number\s*=\s*\$this->allocate_redeem_order_number\(\);/, name);
assert.match(part, /is_wp_error\(\$order_number\)/, name);
}
});
test('渠道交易号不再硬编码 card_pass_ 前缀,改走流水号前缀', () => {
assert.doesNotMatch(card, /'card_pass_'\s*\.\s*\$card_id/);
assert.match(card, /card_pass_transaction_number\(\)/);
assert.match(card, /BusinessNumber::normalize_prefix\(\$settings\['flow_prefix'\]/);
assert.match(card, /BusinessNumber::candidate\(\$prefix\)/);
// create_redeem_order 只接收调用方传入的交易号,不再自行拼前缀
assert.match(card, /'payment_transaction_id'\s*=>\s*\$order_status === Order::STATUS_PAID \? \$transaction_number : ''/);
});
test('账本命令保持确定性:credit() 的 metadata 不得携带随机订单号', () => {
const assetPart = card.slice(card.indexOf('private function redeem_asset_card'), card.indexOf('private function redeem_vip_card'));
// metadata 只允许 card_type;order_number 一旦进 metadata,payload_hash 随机化,
// 重复请求会撞 uniq_operation_key 后走 replay、hash 不一致即报幂等冲突。
assert.match(assetPart, /\['card_type'\s*=>\s*\$type\]/);
assert.doesNotMatch(assetPart, /\['card_type'\s*=>\s*\$type,[^\]]*order_number/);
// 重放路径必须回取已落库订单号,而不是把本次新分配的号回给前端
assert.match(assetPart, /if\s*\(\$result->replayed\)/);
assert.match(assetPart, /get_by_request_key\(\$operation_key\)/);
});
test('余额/积分卡:账本提交后补挂订单号,会员卡:直接落零分录支付流水', () => {
assert.match(flows, /public function attach_order_to_ledger_flow\(int \$ledger_transaction_id, string \$order_number/);
// 只挂到已支付且支付方式为卡密的订单,且不得重复覆盖
const attach = flows.slice(flows.indexOf('public function attach_order_to_ledger_flow'));
assert.match(attach, /'card_pass'/);
assert.match(attach, /order_number IS NULL OR order_number = ''/);
assert.match(attach, /event_type = 'order_payment'/);
assert.match(flows, /public function record_card_pass_payment\(array \$order, bool \$caller_transaction = false\)/);
assert.match(flows, /\$voucher_settled = false\)/);
// 凭证结算模式豁免两条门槛,且不生成现金分录
assert.match(flows, /if \(\$voucher_settled\) \{[\s\S]*?\$metadata\['voucher'\] = 'card_pass';/);
assert.match(flows, /private function record\(array \$flow, array \$entries, bool \$caller_transaction, bool \$allow_empty_entries = false\)/);
assert.match(flows, /\(\$entries === \[\] && !\$allow_empty_entries\)/);
// is_external_recharge 不把卡密当外部收款渠道(核销当下没有现金流入)
assert.match(flows, /in_array\(\$method, \['', 'balance', 'points', 'card_pass'\], true\)/);
// is_successful_order_payment 认可卡密凭证结算
assert.match(flows, /'card_pass'\)\s*\{\s*return true;/);
});