<?php
declare(strict_types=1);
namespace ConfManager\Payment;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}


use ConfManager\Traits\Singleton;
use ConfManager\Security\AuditLog;
use ConfManager\Support\LocalTime;

/**
 * 決済管理（抽象レイヤー）
 *
 * 決済記録のCRUD・ステータス管理・入金消込を行う。
 * 銀行振込・Stripe等の決済手段はサブクラスで実装。
 *
 * @since 1.0.0
 */
class PaymentManager {

	use Singleton;

	private const TABLE = 'conf_payments';

	/**
	 * 有効なステータス
	 */
	/**
	 * ⚠️ `cancelled` は「申込が取り下げられたので請求も取り下げた」を表す
	 *
	 * 🔴 **読み手に条件を配らないための設計。** 決済側の集計・絞り込みはどれも
	 * `status = 'pending'` を**正の条件で**数えている。取り下げを表す状態が
	 * 無かったころは、申込を cancelled にしても決済が pending のまま残り、
	 * キャンセル済みが未入金として数え続けられていた。
	 * 各集計 SQL に「申込が cancelled でないこと」を JOIN で配る方式は採らない
	 * （1 箇所落ちるとそこだけが黙って食い違う）。
	 */
	private const VALID_STATUSES = [ 'pending', 'completed', 'refunded', 'partial_refund', 'failed', 'cancelled' ];

	/**
	 * 有効な決済手段
	 */
	private const VALID_METHODS = [ 'bank', 'stripe' ];

	/**
	 * 初期化
	 */
	protected function init(): void {}

	/**
	 * テーブル名
	 */
	private function table(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE;
	}

	/**
	 * 決済記録を作成する
	 *
	 * @param array $data {
	 *     @type int    $registration_id    登録ID（必須）
	 *     @type string $method             決済手段 bank|stripe（必須）
	 *     @type int    $amount             決済金額（必須）
	 *     @type int    $tax                消費税額（デフォルト: 0）
	 *     @type int    $fee                手数料（デフォルト: 0）
	 *     @type bool   $is_group_payment   団体一括決済フラグ
	 *     @type string $bank_transfer_name 振込名義
	 *     @type string $gateway_txn_id     外部決済トランザクションID
	 * }
	 * @return int|\WP_Error
	 */
	public function create( array $data ): int|\WP_Error {
		global $wpdb;

		$error = $this->validate( $data );
		if ( is_wp_error( $error ) ) {
			return $error;
		}

		$insert_data = [
			'registration_id'  => absint( $data['registration_id'] ),
			'method'           => sanitize_text_field( $data['method'] ),
			'amount'           => absint( $data['amount'] ),
			'tax'              => absint( $data['tax'] ?? 0 ),
			'fee'              => absint( $data['fee'] ?? 0 ),
			'is_group_payment' => ! empty( $data['is_group_payment'] ) ? 1 : 0,
			'status'           => 'pending',
		];

		if ( ! empty( $data['bank_transfer_name'] ) ) {
			$insert_data['bank_transfer_name'] = sanitize_text_field( $data['bank_transfer_name'] );
		}

		if ( ! empty( $data['gateway_txn_id'] ) ) {
			$insert_data['gateway_txn_id'] = sanitize_text_field( $data['gateway_txn_id'] );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		$result = $wpdb->insert( $this->table(), $insert_data );

		if ( false === $result ) {
			return new \WP_Error(
				'db_insert_error',
				__( 'Failed to create payment record.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		$payment_id = (int) $wpdb->insert_id;

		AuditLog::instance()->log( 'create_payment', 'payment', $payment_id, [
			'registration_id' => $data['registration_id'],
			'method'          => $data['method'],
			'amount'          => $data['amount'],
		] );

		return $payment_id;
	}

	/**
	 * 決済記録を取得する
	 *
	 * @param int $id 決済ID
	 * @return array|null
	 */
	public function get( int $id ): ?array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$id
			),
			ARRAY_A
		);

		return $row ?: null;
	}

	/**
	 * 登録IDで決済記録を取得する
	 *
	 * @param int $registration_id 登録ID
	 * @return array 決済記録一覧
	 */
	public function get_by_registration( int $registration_id ): array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE registration_id = %d ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$registration_id
			),
			ARRAY_A
		);

		return $rows ?: [];
	}

	/**
	 * 未入金（pending）の決済金額を再計算結果に同期する
	 *
	 * 参加区分の管理者修正により申込金額が変わった際、未消込の決済レコードのみ
	 * amount/tax を更新する。completed/refunded 等は触れない（入金済は別運用）。
	 *
	 * @param int $registration_id 登録ID
	 * @param int $amount          新しい税抜金額
	 * @param int $tax             新しい消費税額
	 * @return int 更新した決済レコード件数
	 */
	public function sync_pending_amount( int $registration_id, int $amount, int $tax ): int {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$updated = $wpdb->update(
			$this->table(),
			[
				'amount' => $amount,
				'tax'    => $tax,
			],
			[
				'registration_id' => $registration_id,
				'status'          => 'pending',
			]
		);

		return false === $updated ? 0 : (int) $updated;
	}

	/**
	 * 入金を確認する（消込）
	 *
	 * @param int      $id                 決済ID
	 * @param string   $bank_transfer_name 振込名義（銀行振込の場合）
	 * @param string   $bank_transfer_date 振込日（Y-m-d）
	 * @param int|null $paid_amount        実際の入金額（税込）。null の場合は請求額どおり入金されたものとして扱う
	 * @param string   $confirm_note       入金確認者による申し送り
	 * @return true|\WP_Error
	 */
	public function confirm_payment( int $id, string $bank_transfer_name = '', string $bank_transfer_date = '', ?int $paid_amount = null, string $confirm_note = '', string $paid_at = '' ): true|\WP_Error {
		global $wpdb;

		$payment = $this->get( $id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		if ( 'pending' !== $payment['status'] ) {
			return new \WP_Error( 'invalid_status', __( 'This payment has already been reconciled or is invalid.', 'conferencemanager' ) );
		}

		if ( null !== $paid_amount && $paid_amount < 0 ) {
			return new \WP_Error( 'invalid_amount', __( 'Paid amount must be 0 or more.', 'conferencemanager' ) );
		}

		// 請求額（税込）。入金額の指定が無い場合は請求額どおり入金されたものとする。
		$billed      = (int) $payment['amount'] + (int) $payment['tax'];
		$paid_amount = $paid_amount ?? $billed;

		// 入金日。
		//
		// ⚠️ **「入金確認をした日」ではなく「受け取り側の通帳に入った日」を記録する。**
		//    振込日（bank_transfer_date＝送る側が振り込んだ日）とも別物で、
		//    事務局が確認作業をした日とも通常ずれる。領収書・請求書に「入金日」として
		//    印字されるのはこの列なので、指定があればそれを使う。
		//
		// 入力は現地日付（Y-m-d）。paid_at は GMT 保存なので、その日の始まりを
		// GMT に直して入れる（LocalTime が解釈できない書式には '' を返す）。
		$paid_at_gmt = '' !== $paid_at ? LocalTime::day_start_to_gmt( $paid_at ) : '';
		if ( '' !== $paid_at && '' === $paid_at_gmt ) {
			return new \WP_Error( 'invalid_paid_at', __( 'Enter the payment date in YYYY-MM-DD format.', 'conferencemanager' ) );
		}

		$update_data = [
			'status'      => 'completed',
			'paid_amount' => $paid_amount,
			'paid_at'     => '' !== $paid_at_gmt ? $paid_at_gmt : current_time( 'mysql', true ),
		];

		if ( '' !== $bank_transfer_name ) {
			$update_data['bank_transfer_name'] = sanitize_text_field( $bank_transfer_name );
		}

		if ( '' !== $bank_transfer_date ) {
			$update_data['bank_transfer_date'] = sanitize_text_field( $bank_transfer_date );
		}

		if ( '' !== $confirm_note ) {
			$update_data['confirm_note'] = sanitize_textarea_field( $confirm_note );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update( $this->table(), $update_data, [ 'id' => $id ] );

		if ( false === $result ) {
			return new \WP_Error(
				'db_update_error',
				__( 'Failed to update payment confirmation.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		// 関連する申込のステータスも paid に更新。
		// ⚠️ 第 3 引数の false は必須。update_status() は paid になったとき
		// その申込の未消込の決済を消し込むので、true のままだと呼び合いになり、
		// **いま消し込んだ 1 件だけでなく同じ申込の未消込の決済が全部消し込まれる。**
		$reg_mgr = \ConfManager\Registration\RegistrationManager::instance();
		$reg_mgr->update_status( (int) $payment['registration_id'], 'paid', false );

		AuditLog::instance()->log( 'confirm_payment', 'payment', $id, [
			'registration_id'    => $payment['registration_id'],
			'amount'             => $payment['amount'],
			'billed_amount'      => $billed,
			'paid_amount'        => $paid_amount,
			'paid_diff'          => $paid_amount - $billed,
			'bank_transfer_name' => $bank_transfer_name,
		] );

		/**
		 * 入金確認後に発火
		 *
		 * @since 1.2.0
		 * @param int $id              決済ID
		 * @param int $registration_id 申込ID
		 */
		do_action( 'conf_manager_payment_confirmed', $id, (int) $payment['registration_id'] );

		return true;
	}

	/**
	 * 返金処理
	 *
	 * 返金は精算明細（payment_adjustments）に記録され、受領済み金額の全額に達した
	 * 場合のみ決済ステータスを refunded にし、申込をキャンセルする。
	 *
	 * @param int    $id          決済ID
	 * @param int    $amount      返金額（税込 / 0=全額）
	 * @param string $reason      返金理由
	 * @param string $method      返金手段 cash|bank|stripe|peatix
	 * @param string $occurred_at 実施日時（省略時は現在時刻）
	 * @return true|\WP_Error
	 */
	public function refund( int $id, int $amount = 0, string $reason = '', string $method = 'bank', string $occurred_at = '' ): true|\WP_Error {
		global $wpdb;

		$payment = $this->get( $id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		if ( 'completed' !== $payment['status'] ) {
			return new \WP_Error( 'invalid_status', __( 'Only paid payments can be refunded.', 'conferencemanager' ) );
		}

		// 返金の基準は「実際に受け取っている額（税込）」。
		// 旧実装は amount（税抜）を基準にしていたため、Stripe には税込で返金しつつ
		// DB には税抜額を記録するという不整合があった。
		$adjustments = PaymentAdjustmentManager::instance();
		$net_paid    = $adjustments->get_net_paid( $payment );

		if ( $net_paid <= 0 ) {
			return new \WP_Error( 'nothing_to_refund', __( 'There is no amount available to refund.', 'conferencemanager' ) );
		}

		$refund_amount = ( 0 === $amount || $amount >= $net_paid ) ? $net_paid : $amount;
		$is_full       = $refund_amount >= $net_paid;
		$new_status    = $is_full ? 'refunded' : 'partial_refund';

		// 返金は精算明細（唯一の記録先）に残す。方法・日時・対応者が必ず記録される。
		$adjustment = $adjustments->create( [
			'payment_id'  => $id,
			'type'        => PaymentAdjustmentManager::TYPE_REFUND,
			'amount'      => $refund_amount,
			'method'      => in_array( $method, PaymentAdjustmentManager::valid_methods(), true ) ? $method : 'bank',
			'occurred_at' => $occurred_at,
			'reason'      => $reason,
		] );

		if ( is_wp_error( $adjustment ) ) {
			return $adjustment;
		}

		// 互換のため payments 側にも累計返金額を保持する（過去データの表示用）
		$totals = $adjustments->get_totals( $id );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update(
			$this->table(),
			[
				'status'        => $new_status,
				'refund_amount' => $totals['refund'],
				'refund_reason' => sanitize_text_field( $reason ),
			],
			[ 'id' => $id ]
		);

		if ( false === $result ) {
			return new \WP_Error(
				'db_update_error',
				__( 'Refund processing failed.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		// 全額返金の場合、申込もキャンセル
		//
		// 🔴 $sync_payment = false。ここは既に決済側を refunded に書いたあとで、
		// 申込のキャンセルから決済側を触り返すと、同じ申込にぶら下がる
		// **兄弟の未消込の決済まで巻き込んで取り下げてしまう**。
		if ( $is_full ) {
			$reg_mgr = \ConfManager\Registration\RegistrationManager::instance();
			$reg_mgr->cancel( (int) $payment['registration_id'], false );
		}

		AuditLog::instance()->log( 'refund_payment', 'payment', $id, [
			'refund_amount' => $refund_amount,
			'net_paid'      => $net_paid,
			'new_status'    => $new_status,
			'method'        => $method,
			'reason'        => $reason,
		] );

		return true;
	}

	/**
	 * 請求を取り下げる（申込がキャンセルされたとき）
	 *
	 * 🔴 **未入金からしか入れない。** 入金済（completed / refunded /
	 * partial_refund）に触ってはいけない。申込を取り消しても**受け取った金は
	 * 自動では戻らない**ので、ここで返金扱いにすると「起きていない返金が
	 * 台帳に載る」（v1.6.5 で直した不具合）を作り直すことになる。
	 * 入金済が残った場合は監査ログにだけ残し、精算は運用者に委ねる。
	 *
	 * @param int    $id     決済ID
	 * @param string $reason 取り下げの理由（監査ログ用）
	 * @return true|\WP_Error
	 */
	public function cancel_payment( int $id, string $reason = '' ): true|\WP_Error {
		global $wpdb;

		$payment = $this->get( $id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		if ( 'pending' !== $payment['status'] ) {
			return new \WP_Error(
				'invalid_status',
				__( 'Only unpaid payments can be withdrawn.', 'conferencemanager' )
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update( $this->table(), [ 'status' => 'cancelled' ], [ 'id' => $id ] );

		if ( false === $result ) {
			return new \WP_Error( 'db_update_error', __( 'Failed to withdraw the payment.', 'conferencemanager' ), $wpdb->last_error );
		}

		AuditLog::instance()->log( 'cancel_payment', 'payment', $id, [
			'registration_id' => (int) $payment['registration_id'],
			'amount'          => $payment['amount'],
			'reason'          => $reason,
		] );

		return true;
	}

	/**
	 * 取り下げた請求を未入金に戻す（キャンセルした申込を復活させたとき）
	 *
	 * 🔴 **cancelled からしか戻せない。** 戻す側が無いと、いったんキャンセルして
	 * 復活させた申込の請求が消えたままになり、督促も集計もされなくなる。
	 *
	 * @param int $id 決済ID
	 * @return true|\WP_Error
	 */
	public function restore_cancelled_payment( int $id ): true|\WP_Error {
		global $wpdb;

		$payment = $this->get( $id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		if ( 'cancelled' !== $payment['status'] ) {
			return new \WP_Error(
				'invalid_status',
				__( 'Only withdrawn payments can be restored.', 'conferencemanager' )
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update( $this->table(), [ 'status' => 'pending' ], [ 'id' => $id ] );

		if ( false === $result ) {
			return new \WP_Error( 'db_update_error', __( 'Failed to restore the payment.', 'conferencemanager' ), $wpdb->last_error );
		}

		AuditLog::instance()->log( 'restore_cancelled_payment', 'payment', $id, [
			'registration_id' => (int) $payment['registration_id'],
		] );

		return true;
	}

	/**
	 * 記録済みの入金額を訂正する
	 *
	 * 消込時の入力誤りや、アップグレード時に請求額から推定した値が実額と
	 * 異なっていた場合に、実際に受け取った金額へ直す。
	 *
	 * これは「記録の訂正」であり金銭の授受を伴わないため、精算明細
	 * （返金・追加徴収）には記録せず、監査ログに新旧の値を残す。
	 *
	 * @param int    $id          決済ID
	 * @param int    $paid_amount 正しい入金額（税込）
	 * @param string $reason      訂正理由
	 * @return true|\WP_Error
	 */
	public function update_paid_amount( int $id, int $paid_amount, string $reason = '' ): true|\WP_Error {
		global $wpdb;

		$payment = $this->get( $id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		if ( ! in_array( $payment['status'], self::RECEIVED_STATUSES, true ) ) {
			return new \WP_Error(
				'invalid_status',
				__( 'Only paid payments can have their paid amount corrected.', 'conferencemanager' )
			);
		}

		if ( $paid_amount < 0 ) {
			return new \WP_Error( 'invalid_amount', __( 'Paid amount must be 0 or more.', 'conferencemanager' ) );
		}

		// 既に返金済みの金額を下回る訂正は、純入金額が負になるため認めない
		$totals = PaymentAdjustmentManager::instance()->get_totals( $id );
		$net    = $paid_amount - $totals['refund'] + $totals['surcharge'];
		if ( $net < 0 ) {
			return new \WP_Error(
				'below_refunded',
				__( 'The paid amount cannot be corrected to less than the amount already refunded.', 'conferencemanager' )
			);
		}

		$old = self::resolve_paid_amount( $payment );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update( $this->table(), [ 'paid_amount' => $paid_amount ], [ 'id' => $id ] );

		if ( false === $result ) {
			return new \WP_Error(
				'db_update_error',
				__( 'Failed to correct the paid amount.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		AuditLog::instance()->log( 'update_paid_amount', 'payment', $id, [
			'registration_id' => (int) $payment['registration_id'],
			'old_paid_amount' => $old,
			'new_paid_amount' => $paid_amount,
			'reason'          => $reason,
		] );

		return true;
	}

	/**
	 * 返金の基準となる受領済み金額（税込）を返す
	 *
	 * 全額返金の判定と、Stripe への実返金額の算出で共通に使う。
	 *
	 * @param int $payment_id 決済ID
	 * @return int
	 */
	public function get_refundable_amount( int $payment_id ): int {
		$payment = $this->get( $payment_id );
		if ( null === $payment ) {
			return 0;
		}
		return PaymentAdjustmentManager::instance()->get_net_paid( $payment );
	}

	/**
	 * 請求額を同期する（区分訂正時）
	 *
	 * 未消込（pending）と入金済（completed）の両方の請求額を正しい値に直す。
	 * 入金済の行は paid_amount（実際に受け取った額）を据え置くため、
	 * 差額がそのまま精算対象（要返金／要追加徴収）として現れる。
	 * 返金処理済みの行は会計上の確定記録として更新しない。
	 *
	 * @param int $registration_id 申込ID
	 * @param int $amount          税抜金額
	 * @param int $tax             消費税額
	 * @return int 更新件数
	 */
	public function sync_billed_amount( int $registration_id, int $amount, int $tax ): int {
		global $wpdb;

		// 請求額を書き換える前に、実入金額が未記録の消込済み行へ「訂正前の請求額」を
		// 焼き付ける。これを行わないと、実入金額の代替表示が新しい請求額に追随し、
		// 差額が 0 のままとなって精算（要返金／要追加徴収）を検出できない。
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの更新。テーブル名は内部メソッド由来でユーザー入力を含まず、値はすべて prepare() でバインドしている。
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$this->table()} SET paid_amount = amount + tax
				 WHERE registration_id = %d
				   AND paid_amount IS NULL
				   AND status IN ('completed', 'refunded', 'partial_refund')",
				$registration_id
			)
		);

		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$this->table()} SET amount = %d, tax = %d
				 WHERE registration_id = %d AND status IN ('pending', 'completed')",
				$amount,
				$tax,
				$registration_id
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return false === $updated ? 0 : (int) $updated;
	}

	/**
	 * 入金が発生済みとみなすステータス（一度入金された後に返金されたものを含む）
	 */
	private const RECEIVED_STATUSES = [ 'completed', 'refunded', 'partial_refund' ];

	/**
	 * 決済レコードの実入金額を解決する
	 *
	 * paid_amount 導入より前に消込された決済は paid_amount を持たないため、
	 * 「請求額どおり入金された」とみなす。未消込は null。
	 *
	 * @param array $payment 決済レコード
	 * @return int|null
	 */
	public static function resolve_paid_amount( array $payment ): ?int {
		if ( null !== ( $payment['paid_amount'] ?? null ) && '' !== $payment['paid_amount'] ) {
			return (int) $payment['paid_amount'];
		}
		return in_array( $payment['status'] ?? '', self::RECEIVED_STATUSES, true )
			? (int) $payment['amount'] + (int) $payment['tax']
			: null;
	}

	/**
	 * 精算状態を算出する
	 *
	 * 請求額（amount + tax）と純入金額（paid_amount − 返金 + 追加徴収）の差から、
	 * 返金が必要か・追加徴収が必要かを判定する。
	 *
	 * @param array                                $payment 決済レコード
	 * @param array{refund:int, surcharge:int}|null $totals  精算合計（省略時は都度取得）
	 * @return array{billed:int, paid:int|null, refund_total:int, surcharge_total:int, net_paid:int|null, diff:int, state:string}
	 *         state: unpaid（未入金）| refund_due（要返金）| surcharge_due（要追加徴収）| settled（精算済）
	 */
	public static function resolve_settlement( array $payment, ?array $totals = null ): array {
		$billed = (int) $payment['amount'] + (int) $payment['tax'];
		$paid   = self::resolve_paid_amount( $payment );

		if ( null === $totals ) {
			$totals = PaymentAdjustmentManager::instance()->get_totals( (int) $payment['id'] );
		}

		$refund    = (int) ( $totals['refund'] ?? 0 );
		$surcharge = (int) ( $totals['surcharge'] ?? 0 );

		if ( null === $paid ) {
			// 未消込。返金・追加徴収も発生し得ないため精算対象外。
			return [
				'billed'          => $billed,
				'paid'            => null,
				'refund_total'    => $refund,
				'surcharge_total' => $surcharge,
				'net_paid'        => null,
				'diff'            => 0,
				'state'           => 'unpaid',
			];
		}

		$net  = $paid - $refund + $surcharge;
		$diff = $net - $billed;

		// 全額返金した決済は取引そのものが取り消されている（refund() は全額返金時に
		// 申込もキャンセルする）。請求額はもう請求する相手のいない過去の値なので、
		// 請求額との差を精算対象として扱わない。除外しないと、返金を済ませた決済が
		// 「要追加徴収（請求額のぶん足りない）」として未対応リストに並び続ける。
		if ( 'refunded' === ( $payment['status'] ?? '' ) ) {
			$diff = 0;
		}

		if ( $diff > 0 ) {
			$state = 'refund_due';
		} elseif ( $diff < 0 ) {
			$state = 'surcharge_due';
		} else {
			$state = 'settled';
		}

		return [
			'billed'          => $billed,
			'paid'            => $paid,
			'refund_total'    => $refund,
			'surcharge_total' => $surcharge,
			'net_paid'        => $net,
			'diff'            => $diff,
			'state'           => $state,
		];
	}

	/**
	 * 決済レコード配列に精算情報を付与する（一覧用・N+1 回避）
	 *
	 * @param array $payments 決済レコードの配列
	 * @return array 各要素に settlement 情報を追加した配列
	 */
	public static function attach_settlement( array $payments ): array {
		$ids    = array_map( static fn( $p ) => (int) $p['id'], $payments );
		$totals = PaymentAdjustmentManager::instance()->get_totals_for_payments( $ids );

		foreach ( $payments as &$payment ) {
			$settlement = self::resolve_settlement(
				$payment,
				$totals[ (int) $payment['id'] ] ?? [ 'refund' => 0, 'surcharge' => 0 ]
			);

			$payment['refund_total']     = $settlement['refund_total'];
			$payment['surcharge_total']  = $settlement['surcharge_total'];
			$payment['net_paid']         = $settlement['net_paid'];
			$payment['settlement_diff']  = $settlement['diff'];
			$payment['settlement_state'] = $settlement['state'];
		}
		unset( $payment );

		return $payments;
	}

	/**
	 * 申込に紐づく精算状態を求める（参加者リスト用）
	 *
	 * 1 申込に複数の決済がある場合は差額を合算する。
	 *
	 * @param int $registration_id 申込ID
	 * @return array{state:string, amount:int} state: none | refund_due | surcharge_due
	 */
	public function get_registration_settlement( int $registration_id ): array {
		$payments = $this->get_by_registration( $registration_id );
		if ( empty( $payments ) ) {
			return [ 'state' => 'none', 'amount' => 0 ];
		}

		$payments = self::attach_settlement( $payments );

		$diff = 0;
		foreach ( $payments as $payment ) {
			$diff += (int) $payment['settlement_diff'];
		}

		if ( $diff > 0 ) {
			return [ 'state' => 'refund_due', 'amount' => $diff ];
		}
		if ( $diff < 0 ) {
			return [ 'state' => 'surcharge_due', 'amount' => abs( $diff ) ];
		}

		return [ 'state' => 'none', 'amount' => 0 ];
	}

	/**
	 * 決済を検索する
	 *
	 * @param array $args {
	 *     @type int    $registration_id 登録IDで絞り込み
	 *     @type string $method          決済手段で絞り込み
	 *     @type string $status          ステータスで絞り込み
	 *     @type string $date_from       入金日の開始（Y-m-d）
	 *     @type string $date_to         入金日の終了（Y-m-d）
	 *     @type int    $per_page        件数
	 *     @type int    $page            ページ番号
	 * }
	 * @return array{items: array, total: int, page: int, per_page: int}
	 */
	public function search( array $args = [] ): array {
		global $wpdb;

		$table    = $this->table();
		$where    = [];
		$values   = [];
		$join_sql = '';
		$per_page = min( 100, max( 1, absint( $args['per_page'] ?? 20 ) ) );
		$page     = max( 1, absint( $args['page'] ?? 1 ) );

		// イベントIDフィルタ（registrations JOIN）
		if ( ! empty( $args['event_id'] ) ) {
			$reg_table = $wpdb->prefix . 'conf_registrations';
			$join_sql  = "INNER JOIN {$reg_table} r ON r.id = p.registration_id";
			$where[]   = 'r.event_id = %d';
			$values[]  = absint( $args['event_id'] );
		}

		if ( ! empty( $args['registration_id'] ) ) {
			$where[]  = 'p.registration_id = %d';
			$values[] = absint( $args['registration_id'] );
		}

		if ( ! empty( $args['method'] ) && in_array( $args['method'], self::VALID_METHODS, true ) ) {
			$where[]  = 'p.method = %s';
			$values[] = $args['method'];
		}

		if ( ! empty( $args['status'] ) && in_array( $args['status'], self::VALID_STATUSES, true ) ) {
			$where[]  = 'p.status = %s';
			$values[] = $args['status'];
		}

		// ⚠️ 渡ってくるのは現地日付だが、比較先の p.paid_at は GMT で保存されている。
		// 現地の 1 日の境界を GMT に直してから比較しないと、日本では JST 00:00〜09:00 の
		// 入金が「その日」から漏れる。解釈できない書式は条件に足さない（'' が返る）。
		if ( ! empty( $args['date_from'] ) ) {
			$from_gmt = LocalTime::day_start_to_gmt( sanitize_text_field( $args['date_from'] ) );
			if ( '' !== $from_gmt ) {
				$where[]  = 'p.paid_at >= %s';
				$values[] = $from_gmt;
			}
		}

		if ( ! empty( $args['date_to'] ) ) {
			$to_gmt = LocalTime::day_end_to_gmt( sanitize_text_field( $args['date_to'] ) );
			if ( '' !== $to_gmt ) {
				$where[]  = 'p.paid_at <= %s';
				$values[] = $to_gmt;
			}
		}

		$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';

		$count_sql = "SELECT COUNT(*) FROM {$table} p {$join_sql} {$where_sql}";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_sql uses %s placeholders; when $values is empty, no user input in query.
		$total     = ! empty( $values )
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
			? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$values ) )
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
			: (int) $wpdb->get_var( $count_sql );

		$offset       = ( $page - 1 ) * $per_page;
		$query_values = array_merge( $values, [ $per_page, $offset ] );
		// ORDER BY の末尾に id を足して並びを一意にする。
		// 一意でないまま LIMIT/OFFSET で頁を送ると、頁をまたぐ順序が保証されず
		// **同じ行が 2 度来たり 1 行落ちたりする**。「100 件で切れる」より質が悪く、
		// 「たまに 1 件足りない」は誰も気づけない。しかも InnoDB はたいてい安定して
		// 返すため**テストでは守れない**ので、構造として固定する（M-12 が検査する）。
		$data_sql     = "SELECT p.* FROM {$table} p {$join_sql} {$where_sql} ORDER BY p.created_at DESC, p.id DESC LIMIT %d OFFSET %d";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
		$rows         = $wpdb->get_results(
			$wpdb->prepare( $data_sql, ...$query_values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			ARRAY_A
		);

		return [
			'items'    => $rows ?: [],
			'total'    => $total,
			'page'     => $page,
			'per_page' => $per_page,
		];
	}

	/**
	 * イベントの売上集計を取得
	 *
	 * @param int $event_id イベントID
	 * @return array{total_amount: int, total_tax: int, total_fee: int, count: int}
	 */
	public function get_event_summary( int $event_id ): array {
		global $wpdb;

		$reg_table = $wpdb->prefix . 'conf_registrations';
		$pay_table = $this->table();
		$adj_table = $wpdb->prefix . 'conf_payment_adjustments';
		// 取り消した明細は数えない。条件は PaymentAdjustmentManager::EFFECTIVE_SQL 1 つに置く
		// （集計は Free で 4 箇所。1 つ落とすとそこだけ古い返金を数え続ける。M-15 が本数ごと見張る）。
		$effective = PaymentAdjustmentManager::EFFECTIVE_SQL;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from plugin-defined prefix; values bound via prepare().
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT
					COALESCE(SUM(p.amount), 0) AS total_amount,
					COALESCE(SUM(p.tax), 0) AS total_tax,
					COALESCE(SUM(p.fee), 0) AS total_fee,
					COALESCE(SUM(COALESCE(p.paid_amount, p.amount + p.tax)), 0) - COALESCE(SUM(adj.refund_total), 0) + COALESCE(SUM(adj.surcharge_total), 0) AS total_paid,
					COUNT(p.id) AS count
				FROM {$pay_table} p
				INNER JOIN {$reg_table} r ON p.registration_id = r.id
				LEFT JOIN (
					SELECT payment_id,
						SUM(CASE WHEN type = 'refund' THEN amount ELSE 0 END) AS refund_total,
						SUM(CASE WHEN type = 'surcharge' THEN amount ELSE 0 END) AS surcharge_total
					FROM {$adj_table} WHERE {$effective} GROUP BY payment_id
				) adj ON adj.payment_id = p.id
				WHERE r.event_id = %d AND p.status = 'completed'",
				$event_id
			),
			ARRAY_A
		);

		// 未入金の集計も取得
		$pending_row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT
					COALESCE(SUM(p.amount), 0) AS total_amount,
					COALESCE(SUM(p.tax), 0) AS total_tax,
					COUNT(p.id) AS count
				FROM {$pay_table} p
				INNER JOIN {$reg_table} r ON p.registration_id = r.id
				WHERE r.event_id = %d AND p.status = 'pending'",
				$event_id
			),
			ARRAY_A
		);
		// phpcs:enable

		return [
			'completed_amount' => (int) ( $row['total_amount'] ?? 0 ),
			'completed_tax'    => (int) ( $row['total_tax'] ?? 0 ),
			'completed_fee'    => (int) ( $row['total_fee'] ?? 0 ),
			// 実際に入金された金額の合計（未記録の旧データは請求額どおりとみなす）
			'completed_paid'   => (int) ( $row['total_paid'] ?? 0 ),
			'completed_count'  => (int) ( $row['count'] ?? 0 ),
			'pending_amount'   => (int) ( $pending_row['total_amount'] ?? 0 ),
			'pending_tax'      => (int) ( $pending_row['total_tax'] ?? 0 ),
			'pending_count'    => (int) ( $pending_row['count'] ?? 0 ),
		];
	}

	/**
	 * バリデーション
	 */
	private function validate( array $data ): true|\WP_Error {
		if ( empty( $data['registration_id'] ) ) {
			return new \WP_Error( 'missing_registration_id', __( 'Registration ID is required.', 'conferencemanager' ) );
		}

		if ( empty( $data['method'] ) || ! in_array( $data['method'], self::VALID_METHODS, true ) ) {
			return new \WP_Error( 'invalid_method', __( 'Invalid payment method.', 'conferencemanager' ) );
		}

		if ( empty( $data['amount'] ) || (int) $data['amount'] <= 0 ) {
			return new \WP_Error( 'invalid_amount', __( 'Payment amount must be 1 or more.', 'conferencemanager' ) );
		}

		return true;
	}

	/**
	 * 決済手段別の売上集計を取得
	 *
	 * @param int $event_id イベントID
	 * @return array<string, array{amount: int, tax: int, fee: int, count: int}>
	 */
	public function get_summary_by_method( int $event_id ): array {
		global $wpdb;

		$reg_table = $wpdb->prefix . 'conf_registrations';
		$pay_table = $this->table();
		$adj_table = $wpdb->prefix . 'conf_payment_adjustments';
		// 取り消した明細は数えない。条件は PaymentAdjustmentManager::EFFECTIVE_SQL 1 つに置く
		// （集計は Free で 4 箇所。1 つ落とすとそこだけ古い返金を数え続ける。M-15 が本数ごと見張る）。
		$effective = PaymentAdjustmentManager::EFFECTIVE_SQL;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from plugin-defined prefix; values bound via prepare().
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT
					p.method,
					COALESCE(SUM(p.amount), 0) AS total_amount,
					COALESCE(SUM(p.tax), 0) AS total_tax,
					COALESCE(SUM(p.fee), 0) AS total_fee,
					COALESCE(SUM(COALESCE(p.paid_amount, p.amount + p.tax)), 0) - COALESCE(SUM(adj.refund_total), 0) + COALESCE(SUM(adj.surcharge_total), 0) AS total_paid,
					COUNT(p.id) AS count
				FROM {$pay_table} p
				INNER JOIN {$reg_table} r ON p.registration_id = r.id
				LEFT JOIN (
					SELECT payment_id,
						SUM(CASE WHEN type = 'refund' THEN amount ELSE 0 END) AS refund_total,
						SUM(CASE WHEN type = 'surcharge' THEN amount ELSE 0 END) AS surcharge_total
					FROM {$adj_table} WHERE {$effective} GROUP BY payment_id
				) adj ON adj.payment_id = p.id
				WHERE r.event_id = %d AND p.status = 'completed'
				GROUP BY p.method",
				$event_id
			),
			ARRAY_A
		);
		// phpcs:enable

		$summary = [];
		foreach ( $rows ?: [] as $row ) {
			$summary[ $row['method'] ] = [
				'amount' => (int) $row['total_amount'],
				'tax'    => (int) $row['total_tax'],
				'fee'    => (int) $row['total_fee'],
				'paid'   => (int) $row['total_paid'],
				'count'  => (int) $row['count'],
			];
		}

		return $summary;
	}

	/**
	 * 期間別の売上集計を取得（日別）
	 *
	 * @param int    $event_id  イベントID
	 * @param string $date_from 開始日（Y-m-d）
	 * @param string $date_to   終了日（Y-m-d）
	 * @return array<string, array{amount: int, tax: int, count: int}>
	 */
	public function get_daily_summary( int $event_id, string $date_from = '', string $date_to = '' ): array {
		global $wpdb;

		$reg_table = $wpdb->prefix . 'conf_registrations';
		$pay_table = $this->table();

		$where  = "r.event_id = %d AND p.status = 'completed' AND p.paid_at IS NOT NULL";
		$values = [ $event_id ];

		if ( '' !== $date_from ) {
			$where   .= ' AND p.paid_at >= %s';
			$values[] = sanitize_text_field( $date_from ) . ' 00:00:00';
		}

		if ( '' !== $date_to ) {
			$where   .= ' AND p.paid_at <= %s';
			$values[] = sanitize_text_field( $date_to ) . ' 23:59:59';
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from plugin-defined prefix; dynamic WHERE built from whitelisted placeholders; values bound via prepare() spread.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT
					DATE(p.paid_at) AS pay_date,
					COALESCE(SUM(p.amount), 0) AS total_amount,
					COALESCE(SUM(p.tax), 0) AS total_tax,
					COUNT(p.id) AS count
				FROM {$pay_table} p
				INNER JOIN {$reg_table} r ON p.registration_id = r.id
				WHERE {$where}
				GROUP BY DATE(p.paid_at)
				ORDER BY pay_date ASC",
				...$values
			),
			ARRAY_A
		);
		// phpcs:enable

		$summary = [];
		foreach ( $rows ?: [] as $row ) {
			$summary[ $row['pay_date'] ] = [
				'amount' => (int) $row['total_amount'],
				'tax'    => (int) $row['total_tax'],
				'count'  => (int) $row['count'],
			];
		}

		return $summary;
	}

	/**
	 * 売上レポートのCSVエクスポート用データを取得
	 *
	 * @param int $event_id イベントID
	 * @return array{header: array, rows: array}
	 */
	public function get_export_data( int $event_id ): array {
		global $wpdb;

		$reg_table = $wpdb->prefix . 'conf_registrations';
		$pay_table = $this->table();
		$mem_table = $wpdb->prefix . 'conf_members';
		$adj_table = $wpdb->prefix . 'conf_payment_adjustments';
		// 取り消した明細は数えない。条件は PaymentAdjustmentManager::EFFECTIVE_SQL 1 つに置く
		// （集計は Free で 4 箇所。1 つ落とすとそこだけ古い返金を数え続ける。M-15 が本数ごと見張る）。
		$effective = PaymentAdjustmentManager::EFFECTIVE_SQL;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names from plugin-defined prefix; values bound via prepare().
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT
					r.registration_no,
					m.name_sei,
					m.name_mei,
					m.member_type,
					r.participation_type,
					p.method,
					p.amount,
					p.tax,
					p.fee,
					p.paid_amount,
					COALESCE(adj.refund_total, 0) AS refund_total,
					COALESCE(adj.surcharge_total, 0) AS surcharge_total,
					CASE WHEN p.paid_amount IS NULL THEN NULL
						ELSE p.paid_amount - COALESCE(adj.refund_total, 0) + COALESCE(adj.surcharge_total, 0) END AS net_paid,
					CASE WHEN p.paid_amount IS NULL THEN NULL
						ELSE p.paid_amount - COALESCE(adj.refund_total, 0) + COALESCE(adj.surcharge_total, 0) - ( p.amount + p.tax ) END AS paid_diff,
					p.status AS payment_status,
					p.bank_transfer_name,
					p.confirm_note,
					p.paid_at
				FROM {$pay_table} p
				INNER JOIN {$reg_table} r ON p.registration_id = r.id
				INNER JOIN {$mem_table} m ON r.member_id = m.id
				LEFT JOIN (
					SELECT payment_id,
						SUM(CASE WHEN type = 'refund' THEN amount ELSE 0 END) AS refund_total,
						SUM(CASE WHEN type = 'surcharge' THEN amount ELSE 0 END) AS surcharge_total
					FROM {$adj_table} WHERE {$effective} GROUP BY payment_id
				) adj ON adj.payment_id = p.id
				WHERE r.event_id = %d
				ORDER BY p.created_at ASC",
				$event_id
			),
			ARRAY_A
		);
		// phpcs:enable

		$header = [
			'registration_no', 'name_sei', 'name_mei', 'member_type',
			'participation_type', 'method', 'amount', 'tax', 'fee',
			'paid_amount', 'refund_total', 'surcharge_total', 'net_paid', 'paid_diff',
			'payment_status', 'bank_transfer_name', 'confirm_note', 'paid_at',
		];

		// paid_at は GMT 保存。CSV は人が読むものなので、このサイトの時間に直して出す
		// （画面と CSV で日付が割れないようにする）。
		$rows = array_map(
			static function ( array $row ): array {
				$row['paid_at'] = LocalTime::from_gmt( $row['paid_at'] ?? null );
				return $row;
			},
			$rows ?: []
		);

		return [
			'header' => $header,
			'rows'   => $rows,
		];
	}

	/**
	 * 有効なステータス一覧
	 */
	public static function get_valid_statuses(): array {
		return self::VALID_STATUSES;
	}

	/**
	 * 帳票に印字する税率のラベルを解決する（例: '10%' / '8%' / '0%' / '9.5%'）
	 *
	 * 帳票はディスクに保存せず**都度再生成する**設計なので、イベントの
	 * `tax_rate` をそのまま印字すると、税率を変更したあとに過去の帳票を
	 * 刷り直したときに「税率 8% と書いてあるのに税額は 10% で計算された値」
	 * という食い違いが出る。決済行が持っているのは税「額」（`tax`）だけで、
	 * 税率は持っていない（`conf_payments` は `amount` と `tax` のみ）。
	 *
	 * そこで **「印字した税率 × 印字した税抜額 ＝ 印字した税額」が常に
	 * 成り立つ**ことを不変条件にして解決する:
	 *
	 *   1. イベントの税率が**保存済みの税額を再現できるときだけ**それを使う
	 *   2. 再現できないときは金額から逆算する
	 *   3. 税抜 0 円ではどの税率でも税額 0 円なので、イベントの宣言を出す
	 *
	 * ⚠️ **逆算だけにしてはいけない。** 税抜 333 円の 10% は floor で 33 円だが、
	 * 33/333 を逆算すると 9.91% になり、ありもしない税率が帳票に載る。
	 * だから「イベントの税率で再現できるならそれを使う」を先に置いている。
	 *
	 * @param array      $payment 決済データ（amount / tax）
	 * @param array|null $event   イベントデータ（tax_rate）。無い場合は逆算のみ
	 * @return string 末尾の 0 を落とした税率ラベル（'%' つき）
	 */
	public static function tax_rate_label( array $payment, ?array $event = null ): string {
		$amount = (int) ( $payment['amount'] ?? 0 );
		$tax    = (int) ( $payment['tax'] ?? 0 );

		$declared = null;
		if ( is_array( $event ) && isset( $event['tax_rate'] ) && '' !== $event['tax_rate'] ) {
			$declared = (float) $event['tax_rate'];
		}

		// 1. イベントの税率で保存済みの税額を再現できるなら、それが真の税率。
		if ( null !== $declared
			&& \ConfManager\Registration\RegistrationManager::instance()->calculate_tax( $amount, $declared ) === $tax ) {
			return self::format_tax_rate( $declared );
		}

		// 2. 再現できない（税率が後から変わった等）なら金額から逆算する。
		if ( $amount > 0 ) {
			return self::format_tax_rate( round( $tax * 100 / $amount, 2 ) );
		}

		// 3. 税抜 0 円ではどの税率でも税額 0 円なので不変条件は保たれる。
		//    イベントが宣言している税率をそのまま出す（逆算は 0 除算になる）。
		if ( null !== $declared ) {
			return self::format_tax_rate( $declared );
		}

		return self::format_tax_rate( 0.0 );
	}

	/**
	 * 税率を帳票用の文字列にする（末尾の 0 を落とす）
	 *
	 * 10.0 → '10%' / 8.0 → '8%' / 9.5 → '9.5%' / 0.0 → '0%'
	 */
	private static function format_tax_rate( float $rate ): string {
		$formatted = rtrim( rtrim( number_format( $rate, 2, '.', '' ), '0' ), '.' );
		if ( '' === $formatted || '-' === $formatted ) {
			$formatted = '0';
		}
		return $formatted . '%';
	}
}
