> $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 $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 $flow * @param array> $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> $entries * @return array>|\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 $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 */ 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 $order @param array> $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 $order @param array> $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'); } }