changes
This commit is contained in:
608
fusion_clover/models/payment_provider.py
Normal file
608
fusion_clover/models/payment_provider.py
Normal file
@@ -0,0 +1,608 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_clover import const
|
||||
from odoo.addons.fusion_clover import utils as clover_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProvider(models.Model):
|
||||
_inherit = 'payment.provider'
|
||||
|
||||
code = fields.Selection(
|
||||
selection_add=[('clover', "Clover")],
|
||||
ondelete={'clover': 'set default'},
|
||||
)
|
||||
clover_api_key = fields.Char(
|
||||
string="Ecommerce Private Token",
|
||||
help="The private token from Clover's Ecommerce API Tokens page. "
|
||||
"Used for online charges and refunds (scl.clover.com).",
|
||||
required_if_provider='clover',
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
clover_merchant_id = fields.Char(
|
||||
string="Merchant ID",
|
||||
help="The Clover merchant ID for this business.",
|
||||
required_if_provider='clover',
|
||||
copy=False,
|
||||
)
|
||||
clover_rest_api_token = fields.Char(
|
||||
string="REST API Token",
|
||||
help="The merchant's REST API token from the Clover dashboard "
|
||||
"(Setup > API Tokens). Used for Platform API (devices, orders) "
|
||||
"and terminal payments (Cloud Pay Display). This is different "
|
||||
"from the Ecommerce API token.",
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
clover_app_id = fields.Char(
|
||||
string="App ID (Client ID)",
|
||||
help="The Clover App ID (client_id) from the developer dashboard. "
|
||||
"Used for OAuth2 merchant authorization flow.",
|
||||
copy=False,
|
||||
)
|
||||
clover_app_secret = fields.Char(
|
||||
string="App Secret",
|
||||
help="The Clover App Secret (client_secret) from the developer dashboard.",
|
||||
copy=False,
|
||||
groups='base.group_system',
|
||||
)
|
||||
clover_public_key = fields.Char(
|
||||
string="Public API Key (PAKMS)",
|
||||
help="The public token from Clover's Ecommerce API Tokens page. "
|
||||
"Used for client-side tokenization. Safe to expose in the browser.",
|
||||
copy=False,
|
||||
)
|
||||
clover_default_terminal_id = fields.Many2one(
|
||||
'clover.terminal',
|
||||
string="Default Terminal",
|
||||
help="The default Clover terminal used for in-store payment collection. "
|
||||
"Staff can override this per transaction.",
|
||||
domain="[('provider_id', '=', id), ('active', '=', True)]",
|
||||
)
|
||||
|
||||
# === COMPUTE METHODS === #
|
||||
|
||||
def _compute_feature_support_fields(self):
|
||||
"""Override of `payment` to enable additional features."""
|
||||
super()._compute_feature_support_fields()
|
||||
self.filtered(lambda p: p.code == 'clover').update({
|
||||
'support_manual_capture': 'full_only',
|
||||
'support_refund': 'partial',
|
||||
'support_tokenization': True,
|
||||
})
|
||||
|
||||
# === CRUD METHODS === #
|
||||
|
||||
def _get_default_payment_method_codes(self):
|
||||
"""Override of `payment` to return the default payment method codes."""
|
||||
self.ensure_one()
|
||||
if self.code != 'clover':
|
||||
return super()._get_default_payment_method_codes()
|
||||
return const.DEFAULT_PAYMENT_METHOD_CODES
|
||||
|
||||
# === BUSINESS METHODS - API REQUESTS === #
|
||||
|
||||
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None):
|
||||
"""Make an authenticated API request to the Clover Ecommerce API.
|
||||
|
||||
:param str method: HTTP method (GET, POST, PUT, DELETE).
|
||||
:param str endpoint: The API endpoint path (e.g., 'v1/charges').
|
||||
:param dict payload: The JSON request body (optional).
|
||||
:param dict params: The query parameters (optional).
|
||||
:return: The parsed JSON response.
|
||||
:rtype: dict
|
||||
:raises ValidationError: If the API request fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
is_test = self.state == 'test'
|
||||
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
|
||||
|
||||
idempotency_key = clover_utils.generate_idempotency_key()
|
||||
headers = clover_utils.build_ecom_headers(
|
||||
self.clover_api_key, idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Clover Ecom API %s request to %s (idempotency=%s)",
|
||||
method, url, idempotency_key,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Clover Ecom API request failed: %s", e)
|
||||
raise ValidationError(_("Communication with Clover failed: %s", e))
|
||||
|
||||
if response.status_code in (202, 204):
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
if response.status_code < 400:
|
||||
return {}
|
||||
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
|
||||
raise ValidationError(_("Clover returned an invalid response."))
|
||||
|
||||
if response.status_code >= 400:
|
||||
error = result.get('error', {})
|
||||
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
||||
error_code = error.get('code', '') if isinstance(error, dict) else ''
|
||||
_logger.error(
|
||||
"Clover Ecom API error %s: %s (code=%s)\n"
|
||||
" URL: %s %s\n Payload: %s\n Response: %s",
|
||||
response.status_code, error_msg, error_code,
|
||||
method, url,
|
||||
json.dumps(payload)[:2000] if payload else 'None',
|
||||
response.text[:2000],
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Clover API error (%(code)s): %(msg)s",
|
||||
code=response.status_code, msg=error_msg or 'Unknown error')
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None):
|
||||
"""Make an authenticated request to the Clover Platform API.
|
||||
|
||||
:param str method: HTTP method.
|
||||
:param str endpoint: The API endpoint path.
|
||||
:param dict payload: The JSON request body (optional).
|
||||
:param dict params: The query parameters (optional).
|
||||
:return: The parsed JSON response.
|
||||
:rtype: dict
|
||||
:raises ValidationError: If the API request fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
is_test = self.state == 'test'
|
||||
url = clover_utils.build_platform_url(
|
||||
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
|
||||
)
|
||||
|
||||
# Platform API uses the REST API token, falling back to ecom key
|
||||
api_token = self.clover_rest_api_token or self.clover_api_key
|
||||
headers = clover_utils.build_ecom_headers(api_token)
|
||||
|
||||
_logger.info("Clover Platform API %s request to %s", method, url)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=60,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Clover Platform API request failed: %s", e)
|
||||
raise ValidationError(_("Communication with Clover failed: %s", e))
|
||||
|
||||
if response.status_code in (202, 204):
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
if response.status_code < 400:
|
||||
return {}
|
||||
raise ValidationError(_("Clover returned an invalid response."))
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
||||
raise ValidationError(
|
||||
_("Clover API error (%(code)s): %(msg)s",
|
||||
code=response.status_code, msg=error_msg)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
|
||||
|
||||
def _clover_create_charge(self, source_token, amount, currency,
|
||||
capture=True, description='', ecomind='ecom',
|
||||
external_reference_id='', receipt_email='',
|
||||
metadata=None):
|
||||
"""Create a charge via the Clover Ecommerce API.
|
||||
|
||||
:param str source_token: The Clover card token.
|
||||
:param float amount: The charge amount in major currency units.
|
||||
:param recordset currency: The currency record.
|
||||
:param bool capture: Whether to capture immediately.
|
||||
:param str description: Optional charge description.
|
||||
:param str ecomind: 'ecom' or 'moto'.
|
||||
:param str external_reference_id: External reference.
|
||||
:param str receipt_email: Email for receipt.
|
||||
:param dict metadata: Optional metadata.
|
||||
:return: The charge response dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
payload = clover_utils.build_charge_payload(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
source_token=source_token,
|
||||
capture=capture,
|
||||
description=description,
|
||||
ecomind=ecomind,
|
||||
external_reference_id=external_reference_id,
|
||||
receipt_email=receipt_email,
|
||||
metadata=metadata,
|
||||
)
|
||||
return self._clover_make_ecom_request('POST', 'v1/charges', payload=payload)
|
||||
|
||||
def _clover_capture_charge(self, charge_id, amount=None, currency=None):
|
||||
"""Capture a previously authorized charge.
|
||||
|
||||
:param str charge_id: The Clover charge ID.
|
||||
:param float amount: Optional capture amount (for partial captures).
|
||||
:param recordset currency: Optional currency record.
|
||||
:return: The capture response dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
payload = {}
|
||||
if amount is not None and currency:
|
||||
payload['amount'] = clover_utils.format_clover_amount(amount, currency)
|
||||
return self._clover_make_ecom_request(
|
||||
'POST', f'v1/charges/{charge_id}/capture', payload=payload,
|
||||
)
|
||||
|
||||
def _clover_create_refund(self, charge_id, amount=None, currency=None, reason=''):
|
||||
"""Create a refund via the Clover Ecommerce API.
|
||||
|
||||
:param str charge_id: The Clover charge ID to refund.
|
||||
:param float amount: Optional partial refund amount.
|
||||
:param recordset currency: Optional currency record.
|
||||
:param str reason: Optional reason.
|
||||
:return: The refund response dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
payload = clover_utils.build_refund_payload(
|
||||
charge_id=charge_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
reason=reason,
|
||||
)
|
||||
return self._clover_make_ecom_request('POST', 'v1/refunds', payload=payload)
|
||||
|
||||
# === BUSINESS METHODS - NON-REFERENCED CREDIT === #
|
||||
|
||||
def _clover_create_credit(self, amount, currency, description=''):
|
||||
"""Issue a non-referenced credit (manual refund) via Clover Ecommerce API.
|
||||
|
||||
This creates a credit without referencing an original charge. Useful
|
||||
when the original transaction is too old for a referenced refund.
|
||||
|
||||
Note: merchants must have manual refunds enabled by Clover support.
|
||||
|
||||
:param float amount: The credit amount in major currency units.
|
||||
:param recordset currency: The currency record.
|
||||
:param str description: Optional description.
|
||||
:return: The credit response dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
minor_amount = clover_utils.format_clover_amount(amount, currency)
|
||||
payload = {
|
||||
'amount': minor_amount,
|
||||
'currency': currency.name.lower(),
|
||||
}
|
||||
if description:
|
||||
payload['description'] = description
|
||||
return self._clover_make_ecom_request('POST', 'v1/credits', payload=payload)
|
||||
|
||||
# === BUSINESS METHODS - VERIFICATION === #
|
||||
|
||||
def _clover_get_charge(self, charge_id):
|
||||
"""Fetch a charge from the Clover Ecommerce API.
|
||||
|
||||
:param str charge_id: The Clover charge ID.
|
||||
:return: The charge data dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._clover_make_ecom_request('GET', f'v1/charges/{charge_id}')
|
||||
|
||||
def _clover_verify_charge_not_reversed(self, charge_id):
|
||||
"""Check that a charge has not already been fully refunded or voided.
|
||||
|
||||
:param str charge_id: The Clover charge ID.
|
||||
:return: The charge data dict.
|
||||
:rtype: dict
|
||||
:raises UserError: If the charge is already refunded.
|
||||
"""
|
||||
self.ensure_one()
|
||||
charge_data = self._clover_get_charge(charge_id)
|
||||
status = charge_data.get('status', '')
|
||||
if status == 'refunded':
|
||||
raise UserError(_(
|
||||
"This charge (%(charge_id)s) has already been fully refunded "
|
||||
"on Clover. A duplicate refund cannot be issued.",
|
||||
charge_id=charge_id,
|
||||
))
|
||||
return charge_data
|
||||
|
||||
# === BUSINESS METHODS - INLINE FORM === #
|
||||
|
||||
def _clover_get_inline_form_values(self, amount, currency, partner_id, is_validation,
|
||||
payment_method_sudo=None, **kwargs):
|
||||
"""Return serialized JSON of values needed for the inline payment form.
|
||||
|
||||
:param float amount: The payment amount.
|
||||
:param recordset currency: The currency of the transaction.
|
||||
:param int partner_id: The partner ID.
|
||||
:param bool is_validation: Whether this is a validation operation.
|
||||
:param recordset payment_method_sudo: The sudoed payment method record.
|
||||
:return: The JSON-serialized inline form values.
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
partner = self.env['res.partner'].browse(partner_id).exists()
|
||||
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
|
||||
|
||||
inline_form_values = {
|
||||
'provider_id': self.id,
|
||||
'merchant_id': self.clover_merchant_id,
|
||||
'public_key': self.clover_public_key or '',
|
||||
'currency_name': currency.name if currency else 'USD',
|
||||
'minor_amount': minor_amount,
|
||||
'capture_method': 'manual' if self.capture_manually else 'automatic',
|
||||
'is_test': self.state == 'test',
|
||||
'billing_details': {
|
||||
'name': partner.name or '',
|
||||
'email': partner.email or '',
|
||||
'phone': partner.phone or '',
|
||||
'address': {
|
||||
'line1': partner.street or '',
|
||||
'line2': partner.street2 or '',
|
||||
'city': partner.city or '',
|
||||
'state': partner.state_id.code or '',
|
||||
'country': partner.country_id.code or '',
|
||||
'postal_code': partner.zip or '',
|
||||
},
|
||||
},
|
||||
'is_tokenization_required': (
|
||||
self.allow_tokenization
|
||||
and self._is_tokenization_required(**kwargs)
|
||||
and payment_method_sudo
|
||||
and payment_method_sudo.support_tokenization
|
||||
),
|
||||
}
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
surcharge_enabled = ICP.get_param(
|
||||
'fusion_clover.surcharge_enabled', 'False',
|
||||
) == 'True'
|
||||
if surcharge_enabled:
|
||||
inline_form_values['surcharge'] = {
|
||||
'enabled': True,
|
||||
'visa': float(ICP.get_param('fusion_clover.surcharge_visa_rate', '0') or 0),
|
||||
'mastercard': float(ICP.get_param('fusion_clover.surcharge_mastercard_rate', '0') or 0),
|
||||
'amex': float(ICP.get_param('fusion_clover.surcharge_amex_rate', '0') or 0),
|
||||
'debit': float(ICP.get_param('fusion_clover.surcharge_debit_rate', '0') or 0),
|
||||
'other': float(ICP.get_param('fusion_clover.surcharge_other_rate', '0') or 0),
|
||||
}
|
||||
|
||||
return json.dumps(inline_form_values)
|
||||
|
||||
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
|
||||
|
||||
def _clover_terminal_request(self, method, endpoint, serial_number=None,
|
||||
payload=None, params=None):
|
||||
"""Make a request to the Clover REST Pay Display Cloud API.
|
||||
|
||||
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
|
||||
|
||||
:param str method: HTTP method (GET, POST).
|
||||
:param str endpoint: The API endpoint path (e.g., 'payments', 'device/ping').
|
||||
:param str serial_number: The device serial number (X-Clover-Device-Id).
|
||||
:param dict payload: The JSON request body (optional).
|
||||
:param dict params: The query parameters (optional).
|
||||
:return: The parsed JSON response.
|
||||
:rtype: dict
|
||||
:raises ValidationError: If the API request fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
is_test = self.state == 'test'
|
||||
base_url = const.CONNECT_BASE_URL_TEST if is_test else const.CONNECT_BASE_URL
|
||||
url = f"{base_url}/{endpoint}"
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}',
|
||||
'X-POS-ID': 'FusionCloverOdoo',
|
||||
}
|
||||
if serial_number:
|
||||
headers['X-Clover-Device-Id'] = serial_number
|
||||
|
||||
idempotency_key = clover_utils.generate_idempotency_key()
|
||||
headers['Idempotency-Key'] = idempotency_key
|
||||
|
||||
_logger.info(
|
||||
"Clover Terminal API %s request to %s (device=%s)",
|
||||
method, url, serial_number or 'none',
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=120,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Clover Terminal API request failed: %s", e)
|
||||
raise ValidationError(_("Communication with Clover terminal failed: %s", e))
|
||||
|
||||
if response.status_code in (202, 204):
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
if response.status_code < 400:
|
||||
return {}
|
||||
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
|
||||
raise ValidationError(_("Clover terminal returned an invalid response."))
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = result.get('message', result.get('error', 'Unknown error'))
|
||||
_logger.error(
|
||||
"Clover Terminal API error %s: %s\n URL: %s %s",
|
||||
response.status_code, error_msg, method, url,
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Clover terminal error (%(code)s): %(msg)s",
|
||||
code=response.status_code, msg=error_msg)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _clover_get_merchant_devices(self):
|
||||
"""Fetch all devices provisioned to the merchant from the Platform API.
|
||||
|
||||
:return: List of device dicts with id, serial, name, model.
|
||||
:rtype: list[dict]
|
||||
"""
|
||||
self.ensure_one()
|
||||
result = self._clover_make_platform_request('GET', 'devices')
|
||||
elements = result.get('elements', [])
|
||||
return [
|
||||
{
|
||||
'id': d.get('id', ''),
|
||||
'serial': d.get('serial', ''),
|
||||
'name': d.get('name', d.get('productName', 'Clover Device')),
|
||||
'model': d.get('model', d.get('productName', '')),
|
||||
}
|
||||
for d in elements
|
||||
if d.get('serial')
|
||||
]
|
||||
|
||||
def action_sync_terminals(self):
|
||||
"""Sync terminals from the Clover Platform API."""
|
||||
self.ensure_one()
|
||||
if self.code != 'clover':
|
||||
return
|
||||
|
||||
try:
|
||||
devices = self._clover_get_merchant_devices()
|
||||
except (ValidationError, UserError) as e:
|
||||
return self._clover_notification(
|
||||
_("Failed to fetch devices: %(error)s", error=str(e)),
|
||||
'danger',
|
||||
)
|
||||
|
||||
if not devices:
|
||||
return self._clover_notification(
|
||||
_("No devices found for this merchant."),
|
||||
'warning',
|
||||
)
|
||||
|
||||
Terminal = self.env['clover.terminal'].sudo()
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for device in devices:
|
||||
serial = device['serial']
|
||||
existing = Terminal.search([
|
||||
('serial_number', '=', serial),
|
||||
('provider_id', '=', self.id),
|
||||
], limit=1)
|
||||
|
||||
if existing:
|
||||
# Only update metadata; don't overwrite user-set name
|
||||
vals = {
|
||||
'device_id': device['id'],
|
||||
'model_name': device['model'],
|
||||
'clover_device_name': device['name'],
|
||||
}
|
||||
existing.write(vals)
|
||||
updated += 1
|
||||
else:
|
||||
Terminal.create({
|
||||
'name': device['name'],
|
||||
'clover_device_name': device['name'],
|
||||
'serial_number': serial,
|
||||
'device_id': device['id'],
|
||||
'model_name': device['model'],
|
||||
'provider_id': self.id,
|
||||
})
|
||||
created += 1
|
||||
|
||||
return self._clover_notification(
|
||||
_("Sync complete: %(created)s created, %(updated)s updated.",
|
||||
created=created, updated=updated),
|
||||
'success',
|
||||
)
|
||||
|
||||
# === ACTION METHODS === #
|
||||
|
||||
def action_clover_test_connection(self):
|
||||
"""Test the connection to Clover by fetching merchant info.
|
||||
|
||||
:return: A notification action with the result.
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
result = self._clover_make_platform_request('GET', '')
|
||||
merchant_name = result.get('name', 'Unknown')
|
||||
message = _(
|
||||
"Connection successful. Merchant: %(name)s (ID: %(mid)s)",
|
||||
name=merchant_name,
|
||||
mid=self.clover_merchant_id,
|
||||
)
|
||||
notification_type = 'success'
|
||||
except (ValidationError, UserError) as e:
|
||||
message = _("Connection failed: %(error)s", error=str(e))
|
||||
notification_type = 'danger'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sticky': False,
|
||||
'type': notification_type,
|
||||
},
|
||||
}
|
||||
|
||||
def _clover_notification(self, message, notification_type='info'):
|
||||
"""Return a display_notification action."""
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sticky': False,
|
||||
'type': notification_type,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user