Files
wordpress-theme-aksrt/src/Finance/FinancialFlowService.php
T
kate522 aad83072a8 refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀
订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
2026-09-18 22:14:27 +08:00

485 lines
20 KiB
PHP
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.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Finance;
use AKSRT\Ledger\LedgerCommand;
defined('ABSPATH') || exit;
final class FinancialFlowService {
private const MAX_NUMBER_ATTEMPTS = 5;
private static ?self $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Persist an asset-ledger event inside the caller-owned transaction.
*
* @param array<int,array<string,mixed>> $entries
* @return string|\WP_Error
*/
public function record_ledger(LedgerCommand $command, int $transaction_id, array $entries, string $created_at) {
$order_number = $command->ref_type === 'order' ? $command->ref_key : (string) ($command->metadata['order_number'] ?? '');
$order = $order_number !== '' ? $this->resolve_order($order_number) : [];
$withdrawal_number = '';
if ($command->ref_type === 'withdraw') {
$withdrawal_number = $this->resolve_withdrawal_number($command, $transaction_id);
}
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
$flow_entries = [];
foreach ($entries as $entry) {
$delta = (int) ($entry['delta_minor'] ?? 0);
$flow_entries[] = [
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
'asset' => sanitize_key((string) ($entry['asset'] ?? '')),
'direction' => $delta > 0 ? 'in' : 'out',
'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
'delta_minor' => $delta,
'balance_after_minor' => max(0, (int) ($entry['balance_after_minor'] ?? 0)),
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
'remark' => (string) ($entry['remark'] ?? ''),
];
}
$event_type = $command->reason;
$metadata = $command->metadata;
if ($order !== [] && $this->is_successful_order_payment($order, $flow_entries)) {
$event_type = 'order_payment';
$metadata['ledger_reason'] = $command->reason;
if ($this->is_external_recharge($order, $flow_entries)) {
try {
$cash_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('充值支付流水金额无效', 'aksrt'), ['status' => 500]);
}
$flow_entries[] = [
'user_id' => max(0, (int) ($order['user_id'] ?? 0)),
'asset' => 'cash',
'direction' => 'out',
'entry_type' => 'payment',
'delta_minor' => -$cash_minor,
'counterparty_user_id' => 0,
'remark' => __('充值订单支付', 'aksrt'),
];
}
}
return $this->record([
'operation_key' => $command->operation_key,
'event_type' => $event_type,
'ref_type' => $command->ref_type,
'ref_key' => $command->ref_key,
'order_number' => $order_number,
'withdrawal_number' => $withdrawal_number,
'ledger_transaction_id' => $transaction_id,
'provider_transaction_id' => $provider_transaction_id,
'metadata' => $metadata,
'created_at' => $created_at,
], $flow_entries, true);
}
/** @return string|\WP_Error */
public function record_order_payment(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction);
}
/** @return string|\WP_Error */
public function record_order_refund(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
}
/**
* 记录卡密核销产生的订单支付流水。
*
* 卡密是平台自发的预付凭证:核销那一刻既没有第三方现金流入,也不产生账本资产分录,
* 订单金额还可能为 0(会员卡)或以积分计价(积分卡)。因此这里只落一条与订单关联的
* order_payment 流水,不生成任何现金/资产分录,让订单在详情页具备可追溯的「账本流水号」。
*
* 余额卡与积分卡的核销走账本(LedgerService::credit),其流水由 record_ledger() 落库;
* 本方法供会员卡这类无账本参与的卡种调用。
*
* @param array<string,mixed> $order
* @return string|\WP_Error
*/
public function record_card_pass_payment(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction, true);
}
/**
* 把账本流水挂到卡密核销订单上(订单号 + 渠道交易号 + 事件类型归一)。
*
* 卡密核销的资产入账走账本,但订单号是随机生成的、不能进 LedgerCommand
* (payload_hash 覆盖 metadata,塞入随机号会破坏账本幂等重放)。因此账本
* 提交之后再调用本方法补挂关联;同时把 event_type 归一为 order_payment,
* 让订单详情页的「账本流水号」能反查到它。
*
* @return bool 是否发生了更新
*/
public function attach_order_to_ledger_flow(int $ledger_transaction_id, string $order_number, string $provider_transaction_id = ''): bool {
global $wpdb;
if ($ledger_transaction_id <= 0 || $order_number === '') {
return false;
}
$order_number = substr(sanitize_text_field($order_number), 0, 32);
$order = $wpdb->get_row($wpdb->prepare(
"SELECT status, payment_method, payment_transaction_id FROM {$this->order_table()} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($order)
|| sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID
|| sanitize_key((string) ($order['payment_method'] ?? '')) !== 'card_pass'
) {
return false;
}
if ($provider_transaction_id === '') {
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->flow_table()}
SET order_number = %s,
provider_transaction_id = CASE WHEN provider_transaction_id IS NULL OR provider_transaction_id = '' THEN %s ELSE provider_transaction_id END,
event_type = 'order_payment'
WHERE ledger_transaction_id = %d AND (order_number IS NULL OR order_number = '')",
$order_number,
substr(sanitize_text_field($provider_transaction_id), 0, 128),
$ledger_transaction_id
));
return $updated === 1;
}
private function order_table(): string {
return \AKSRT\Core\DBMigrator::table('order');
}
/** @return string|\WP_Error */
public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
$user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
$number = sanitize_text_field((string) ($withdrawal['withdrawal_number'] ?? ''));
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($withdrawal['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('提现流水金额无效', 'aksrt'), ['status' => 500]);
}
return $this->record([
'operation_key' => $operation_key,
'event_type' => 'withdraw_payout',
'ref_type' => 'withdraw',
'ref_key' => $number,
'withdrawal_number' => $number,
'metadata' => ['method' => sanitize_key((string) ($withdrawal['method'] ?? ''))],
'created_at' => $created_at,
], [[
'user_id' => $user_id,
'asset' => 'cash',
'direction' => 'in',
'entry_type' => 'payout',
'delta_minor' => $amount_minor,
'remark' => __('提现打款完成', 'aksrt'),
]], $caller_transaction);
}
/**
* @param array<string,mixed> $flow
* @param array<int,array<string,mixed>> $entries
* @param bool $allow_empty_entries 卡密核销等「无现金/资产分录」的凭证型流水,允许分录为空。
* @return string|\WP_Error
*/
private function record(array $flow, array $entries, bool $caller_transaction, bool $allow_empty_entries = false) {
global $wpdb;
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
$event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
if ($operation_key === '' || $event_type === '' || ($entries === [] && !$allow_empty_entries)) {
return new \WP_Error('aksrt_flow_invalid', __('资金流水参数无效', 'aksrt'), ['status' => 500]);
}
$prepared_entries = $this->prepare_entries($entries);
if (is_wp_error($prepared_entries)) {
return $prepared_entries;
}
$existing = $this->number_for_operation($operation_key);
if ($existing !== '') {
return $existing;
}
$owns_transaction = !$caller_transaction;
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_flow_db', __('无法开启资金流水事务', 'aksrt'), ['status' => 500]);
}
$flow_id = 0;
$flow_number = '';
for ($attempt = 1; $attempt <= self::MAX_NUMBER_ATTEMPTS; $attempt++) {
$flow_number = BusinessNumber::allocate();
$metadata = is_array($flow['metadata'] ?? null) ? $flow['metadata'] : [];
$inserted = $wpdb->insert($this->flow_table(), [
'flow_number' => $flow_number,
'operation_key' => $operation_key,
'event_type' => $event_type,
'ref_type' => ($flow['ref_type'] ?? '') !== '' ? sanitize_key((string) $flow['ref_type']) : null,
'ref_key' => ($flow['ref_key'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['ref_key']), 0, 191) : null,
'order_number' => ($flow['order_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['order_number']), 0, 32) : null,
'withdrawal_number' => ($flow['withdrawal_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['withdrawal_number']), 0, 32) : null,
'ledger_transaction_id' => max(0, (int) ($flow['ledger_transaction_id'] ?? 0)) ?: null,
'provider_transaction_id' => ($flow['provider_transaction_id'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['provider_transaction_id']), 0, 128) : null,
'metadata' => $metadata !== [] ? wp_json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'created_at' => (string) ($flow['created_at'] ?? self::utc_now()),
], ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
if ($inserted === 1) {
$flow_id = (int) $wpdb->insert_id;
break;
}
$existing = $this->number_for_operation($operation_key);
if ($existing !== '') {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
return $existing;
}
}
if ($flow_id <= 0) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
return new \WP_Error('aksrt_flow_number', __('无法生成唯一流水号', 'aksrt'), ['status' => 500]);
}
foreach ($prepared_entries as $index => $entry) {
$inserted = $wpdb->insert($this->entry_table(), [
'flow_id' => $flow_id,
'line_no' => $index + 1,
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
'asset' => $entry['asset'],
'direction' => $entry['direction'],
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
'delta_minor' => $entry['delta_minor'],
'balance_after_minor' => $entry['balance_after_minor'],
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
], ['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s']);
if ($inserted !== 1) {
if ($owns_transaction) {
$wpdb->query('ROLLBACK');
}
return new \WP_Error('aksrt_flow_entry', __('资金流水分录写入失败', 'aksrt'), ['status' => 500]);
}
}
if ($owns_transaction && $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_flow_db', __('资金流水提交失败', 'aksrt'), ['status' => 500]);
}
return $flow_number;
}
/**
* Validate every entry before the flow head is inserted.
*
* @param array<int,array<string,mixed>> $entries
* @return array<int,array<string,mixed>>|\WP_Error
*/
private function prepare_entries(array $entries) {
$prepared = [];
foreach (array_values($entries) as $entry) {
$delta = (int) ($entry['delta_minor'] ?? 0);
$asset = sanitize_key((string) ($entry['asset'] ?? ''));
$direction = sanitize_key((string) ($entry['direction'] ?? ($delta > 0 ? 'in' : 'out')));
if (!in_array($asset, ['cash', 'balance', 'points'], true) || $delta === 0) {
return new \WP_Error('aksrt_flow_entry', __('资金流水分录无效', 'aksrt'), ['status' => 500]);
}
if (!in_array($direction, ['in', 'out'], true) || ($delta > 0) !== ($direction === 'in')) {
return new \WP_Error('aksrt_flow_entry', __('资金流水方向无效', 'aksrt'), ['status' => 500]);
}
$prepared[] = [
'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
'asset' => $asset,
'direction' => $direction,
'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
'delta_minor' => $delta,
'balance_after_minor' => array_key_exists('balance_after_minor', $entry) ? max(0, (int) $entry['balance_after_minor']) : null,
'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
];
}
return $prepared;
}
public function number_for_operation(string $operation_key): string {
global $wpdb;
return (string) $wpdb->get_var($wpdb->prepare(
"SELECT flow_number FROM {$this->flow_table()} WHERE operation_key = %s LIMIT 1",
$operation_key
));
}
public function number_for_ledger_transaction(int $transaction_id): string {
global $wpdb;
if ($transaction_id <= 0) {
return '';
}
return (string) $wpdb->get_var($wpdb->prepare(
"SELECT flow_number FROM {$this->flow_table()} WHERE ledger_transaction_id = %d LIMIT 1",
$transaction_id
));
}
/**
* @param array<string,mixed> $order
* @param bool $voucher_settled 凭证结算(卡密核销):订单可能为 0 元或以积分计价,
* 且没有任何现金/资产分录,故跳过金额与资产口径校验。
*/
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction, bool $voucher_settled = false) {
$order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
$user_id = max(0, (int) ($order['user_id'] ?? 0));
if ($order_number === '') {
return new \WP_Error('aksrt_flow_order', __('订单流水参数无效', 'aksrt'), ['status' => 500]);
}
$existing = $this->number_for_order_event($order_number, $event_type);
if ($existing !== '') {
return $existing;
}
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$metadata = [
'payment_method' => $payment_method,
'currency' => $currency,
];
$entries = [];
if ($voucher_settled) {
// 卡密核销:凭证本身即支付凭据,订单金额可能为 0、也可能以积分计价,
// 因此不做金额校验、也不生成现金分录;流水行即该订单的支付凭证
// (订单详情页经 FinancialFlowQuery::order_payment_number() 反查得到)。
$metadata['voucher'] = 'card_pass';
} else {
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
}
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
$entries[] = [
'user_id' => $user_id,
'asset' => 'cash',
'direction' => $sign > 0 ? 'in' : 'out',
'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
'delta_minor' => $sign * $amount_minor,
'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
];
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic(
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
[$order_number]
);
return $this->record([
'operation_key' => $operation_key,
'event_type' => $event_type,
'ref_type' => 'order',
'ref_key' => $order_number,
'order_number' => $order_number,
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
'metadata' => $metadata,
'created_at' => self::utc_now(),
], $entries, $caller_transaction, $voucher_settled);
}
private function number_for_order_event(string $order_number, string $event_type): string {
global $wpdb;
return (string) $wpdb->get_var($wpdb->prepare(
"SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = %s ORDER BY id ASC LIMIT 1",
$order_number,
$event_type
));
}
private function resolve_withdrawal_number(LedgerCommand $command, int $transaction_id): string {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('withdrawal');
$withdrawal_id = max(0, (int) ($command->metadata['withdrawal_id'] ?? 0));
if ($withdrawal_id <= 0 && ctype_digit($command->ref_key)) {
$withdrawal_id = (int) $command->ref_key;
}
if ($withdrawal_id > 0) {
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE id = %d LIMIT 1", $withdrawal_id));
}
return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE ledger_transaction_id = %d LIMIT 1", $transaction_id));
}
/** @return array<string,mixed> */
private function resolve_order(string $order_number): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$row = $wpdb->get_row($wpdb->prepare(
"SELECT order_number, user_id, product_type, amount, currency, status, payment_method, payment_transaction_id FROM {$table} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
return is_array($row) ? $row : [];
}
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
private function is_successful_order_payment(array $order, array $entries): bool {
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
return false;
}
// card_pass:卡密即支付凭据,订单已支付就说明凭证已结算,不依赖现金或资产分录为凭。
// 余额卡/积分卡的核销因此也能落成 order_payment 流水,与普通订单同口径。
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass') {
return true;
}
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
foreach ($entries as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
return true;
}
}
return $this->is_external_recharge($order, $entries);
}
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
private function is_external_recharge(array $order, array $entries): bool {
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
// 这些方式都在平台内部结算,核销当下没有第三方现金流入:balance / points 以已有资产支付,
// card_pass 以平台自发凭证支付(现金在售卡时已确认),故都不生成现金分录。
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| in_array($method, ['', 'balance', 'points', 'card_pass'], true)
) {
return false;
}
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
foreach ($entries as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (string) ($entry['asset'] ?? '') === 'balance' && (int) ($entry['delta_minor'] ?? 0) > 0) {
return true;
}
}
return false;
}
private function flow_table(): string {
return \AKSRT\Core\DBMigrator::table('financial_flow');
}
private function entry_table(): string {
return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
}
private static function utc_now(): string {
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
}
}