reader = new ResourceCapacityRepository(); } public function present(): bool { return OptionalStoreSchema::present( $this->reader, 'abientot_payments_schema_version', '3', array( 'payment_plans' => array( 'appointment_id' => 'bigint unsigned', 'gross_cents' => 'bigint unsigned', 'currency' => 'char(3)', 'deposit_percent' => 'tinyint unsigned', 'deposit_cents' => 'bigint unsigned', 'collection_frozen' => 'tinyint unsigned', 'privacy_erased' => 'tinyint unsigned' ), 'service_payment_rules' => array( 'service_id' => 'bigint unsigned', 'deposit_percent' => 'tinyint unsigned' ), 'payment_refunds' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payment_record_id' => 'bigint unsigned', 'amount_cents' => 'bigint unsigned', 'reserved_cents' => 'bigint unsigned', 'currency' => 'char(3)', 'state' => 'varchar(20)', 'provider_refund_digest' => 'char(64)', 'privacy_erased' => 'tinyint unsigned' ) ), array( 'payment_plans' => array( array( 'appointment_id' ) ), 'service_payment_rules' => array( array( 'service_id' ) ), 'payment_refunds' => array( array( 'id' ), array( 'command_uuid' ), array( 'provider_refund_digest' ) ) ), array( '', '1', '2' ) ); } public function plan( int $id ): ?array { global $wpdb; if ( ! $this->present() ) { return null; } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d', $this->reader->table( 'payment_plans' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return $rows[0] ?? null; } /** Confirmed receipts only; placeholders and refunds never become new collections. */ public function facts( int $id ): array { global $wpdb; $plan = $this->plan( $id ); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,price_cents,currency FROM %i WHERE id=%d', $this->reader->table( 'appointments' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $rows ) { throw new \DomainException( 'booking_missing' ); } $booking = $rows[0]; $currency = $plan['currency'] ?? $booking['currency']; $gross = (int) ( $plan['gross_cents'] ?? $booking['price_cents'] ); $collected = 0; $refunded = 0; $unresolved = 0; $frozen = ! empty( $plan['collection_frozen'] ) || ! empty( $plan['privacy_erased'] ); $records = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d ORDER BY id LIMIT 5001', $this->reader->table( 'payment_records' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( count( $records ) > 5000 ) { throw new \RuntimeException( 'payment_history_limit' ); } foreach ( $records as $row ) { if ( $row['currency'] !== $currency ) { throw new \DomainException( 'finance_currency_mismatch' ); } if ( $row['status'] === 'paid' ) { $collected += (int) $row['amount_cents']; $refunded += (int) ( $row['refunded_cents'] ?? 0 ); } if ( ! empty( $row['collection_frozen'] ) || ! empty( $row['privacy_erased'] ) ) { $frozen = true; } if ( $row['provider'] !== 'manual' && ! in_array( $row['status'], array( 'paid', 'expired', 'failed' ), true ) ) { ++$unresolved; } } if ( $gross < 0 || $collected < 0 || $refunded < 0 || $refunded > $collected ) { throw new \DomainException( 'finance_invalid_facts' ); } $deposit = (int) ( $plan['deposit_cents'] ?? 0 ); $percent = (int) ( $plan['deposit_percent'] ?? 0 ); $credit = ( new CreditRepository() )->coverage( $id, (string) $currency, $gross ); if ( $credit > 0 && $collected + $credit > $gross ) { throw new \DomainException( 'finance_overcovered' ); } $outstanding = max( 0, $gross - $collected - $credit ); $stage = $percent > 0 && $collected + $credit < $deposit ? 'deposit' : ( $percent > 0 ? 'balance' : 'full' ); return array( 'appointment_id' => $id, 'gross_cents' => $gross, 'currency' => $currency, 'deposit_percent' => $percent, 'deposit_cents' => $deposit, 'collected_cents' => $collected, 'credit_covered_cents' => $credit, 'refunded_cents' => $refunded, 'net_cents' => $collected - $refunded, 'outstanding_cents' => $outstanding, 'collection_frozen' => $frozen, 'due_stage' => $stage, 'due_cents' => $stage === 'deposit' ? max( 0, $deposit - $collected - $credit ) : $outstanding, 'unresolved' => $unresolved ); } /** Shared cash aggregate for Free customer statistics; grouping prevents receipt multiplication. */ public function cash_sql(): string { global $wpdb; $this->present(); $table = $this->reader->table( 'payment_records' ); $columns = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); $refunded = in_array( 'refunded_cents', array_column( $columns, 'Field' ), true ) ? 'COALESCE(refunded_cents,0)' : '0'; return "SELECT appointment_id,currency,SUM(CASE WHEN status='paid' THEN amount_cents ELSE 0 END) AS collected_cents,SUM(CASE WHEN status='paid' THEN amount_cents-{$refunded} ELSE 0 END) AS net_cents FROM {$table} GROUP BY appointment_id,currency"; } public function guard( array $after, ?array $before ): void { $plan = $this->plan( (int) $after['id'] ); if ( ! $plan ) { return; } if ( (int) $after['price_cents'] !== (int) $plan['gross_cents'] || $after['currency'] !== $plan['currency'] ) { throw new \DomainException( 'payment_plan_immutable' ); } if ( $before && (string) $before['payment_status'] !== (string) $after['payment_status'] ) { throw new \DomainException( 'payment_plan_requires_receipt' ); } } }