卡密充值与兑换接入统一订单号与支付流水号
此前卡密核销虽已建订单,但订单号硬编码 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 / 零分录),重放幂等恢复正常。
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
'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;/);
|
||||
});
|
||||
Reference in New Issue
Block a user