订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。
- 删除 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 文件通过。
485 lines
20 KiB
PHP
485 lines
20 KiB
PHP
<?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');
|
||
}
|
||
|
||
}
|