refactor: replace custom CRM with Monica fork
Some checks failed
Build & Push Monica Image to Gitea Registry / build-and-push (push) Failing after 9s
Some checks failed
Build & Push Monica Image to Gitea Registry / build-and-push (push) Failing after 9s
This commit is contained in:
73
app/Traits/AmountFormatter.php
Normal file
73
app/Traits/AmountFormatter.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use App\Helpers\MoneyHelper;
|
||||
use App\Models\Settings\Currency;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
trait AmountFormatter
|
||||
{
|
||||
/**
|
||||
* Get the currency record associated with the debt.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function currency()
|
||||
{
|
||||
return $this->belongsTo(Currency::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set exchange value.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setAmountAttribute($value)
|
||||
{
|
||||
$this->attributes['amount'] = MoneyHelper::parseInput($value, $this->currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exchange value.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getAmountAttribute(): ?string
|
||||
{
|
||||
if (! ($amount = Arr::get($this->attributes, 'amount', null))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MoneyHelper::exchangeValue($amount, $this->currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value of amount (without currency).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getValueAttribute(): string
|
||||
{
|
||||
if (! ($amount = Arr::get($this->attributes, 'amount', null))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return MoneyHelper::getValue($amount, $this->currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display value: amount with currency.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getDisplayValueAttribute(): string
|
||||
{
|
||||
if (! ($amount = Arr::get($this->attributes, 'amount', null))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return MoneyHelper::format($amount, $this->currency);
|
||||
}
|
||||
}
|
||||
17
app/Traits/DAVFormat.php
Normal file
17
app/Traits/DAVFormat.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait DAVFormat
|
||||
{
|
||||
/**
|
||||
* Formats and returns a string for DAV Card/Cal.
|
||||
*
|
||||
* @param null|string $value
|
||||
* @return null|string
|
||||
*/
|
||||
private function formatValue(?string $value): ?string
|
||||
{
|
||||
return ! empty($value) ? str_replace('\;', ';', trim($value)) : null;
|
||||
}
|
||||
}
|
||||
27
app/Traits/HasUuid.php
Normal file
27
app/Traits/HasUuid.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait HasUuid
|
||||
{
|
||||
/**
|
||||
* Get model's Uuid.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUuidAttribute(): string
|
||||
{
|
||||
if (! isset($this->attributes['uuid']) || empty($this->attributes['uuid']) || $this->attributes['uuid'] == null) {
|
||||
return (string) tap(Str::uuid()->toString(), function ($uuid) {
|
||||
$this->forceFill([
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
$this->save(['timestamps' => false]);
|
||||
});
|
||||
}
|
||||
|
||||
return (string) $this->attributes['uuid'];
|
||||
}
|
||||
}
|
||||
37
app/Traits/Hasher.php
Normal file
37
app/Traits/Hasher.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\Instance\IdHasher;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @psalm-implements \App\Interfaces\Hashing */
|
||||
trait Hasher
|
||||
{
|
||||
/**
|
||||
* @psalm-suppress MethodSignatureMustProvideReturnType
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getRouteKey()
|
||||
{
|
||||
return app(IdHasher::class)->encodeId(parent::getRouteKey());
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?Model
|
||||
{
|
||||
$id = $this->decodeId($value);
|
||||
|
||||
return parent::resolveRouteBinding($id, $field);
|
||||
}
|
||||
|
||||
protected function decodeId($value)
|
||||
{
|
||||
return app(IdHasher::class)->decodeId($value);
|
||||
}
|
||||
|
||||
public function hashID()
|
||||
{
|
||||
return $this->getRouteKey();
|
||||
}
|
||||
}
|
||||
46
app/Traits/Journalable.php
Normal file
46
app/Traits/Journalable.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Journal\JournalEntry;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
trait Journalable
|
||||
{
|
||||
/**
|
||||
* Get all journal entries.
|
||||
*
|
||||
* @return MorphMany
|
||||
*/
|
||||
public function journalEntries()
|
||||
{
|
||||
return $this->morphMany(JournalEntry::class, 'journalable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the journal record associated.
|
||||
*
|
||||
* @return MorphOne
|
||||
*/
|
||||
public function journalEntry()
|
||||
{
|
||||
return $this->morphOne(JournalEntry::class, 'journalable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the Journal Entry associated with the given object.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteJournalEntry()
|
||||
{
|
||||
if ($this->journalEntry) {
|
||||
$this->journalEntry->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
193
app/Traits/JsonRespondController.php
Normal file
193
app/Traits/JsonRespondController.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
|
||||
trait JsonRespondController
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $httpStatusCode = 200;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $errorCode;
|
||||
|
||||
/**
|
||||
* Get HTTP status code of the response.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getHTTPStatusCode()
|
||||
{
|
||||
return $this->httpStatusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set HTTP status code of the response.
|
||||
*
|
||||
* @param int $statusCode
|
||||
* @return self
|
||||
*/
|
||||
public function setHTTPStatusCode($statusCode)
|
||||
{
|
||||
$this->httpStatusCode = $statusCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error code of the response.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getErrorCode()
|
||||
{
|
||||
return $this->errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error code of the response.
|
||||
*
|
||||
* @param int $errorCode
|
||||
* @return self
|
||||
*/
|
||||
public function setErrorCode($errorCode)
|
||||
{
|
||||
$this->errorCode = $errorCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON to the consumer.
|
||||
*
|
||||
* @param array $data
|
||||
* @param array $headers [description]
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respond($data, $headers = [])
|
||||
{
|
||||
return response()->json($data, $this->getHTTPStatusCode(), $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response not found (404) to the request.
|
||||
* Error Code = 31.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondNotFound()
|
||||
{
|
||||
return $this->setHTTPStatusCode(404)
|
||||
->setErrorCode(31)
|
||||
->respondWithError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error when the validator failed.
|
||||
* Error Code = 32.
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondValidatorFailed(Validator $validator)
|
||||
{
|
||||
return $this->setHTTPStatusCode(422)
|
||||
->setErrorCode(32)
|
||||
->respondWithError($validator->errors()->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error when the query didn't have the right parameters for
|
||||
* creating an object.
|
||||
* Error Code = 33.
|
||||
*
|
||||
* @param string $message
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondNotTheRightParameters($message = null)
|
||||
{
|
||||
return $this->setHTTPStatusCode(500)
|
||||
->setErrorCode(33)
|
||||
->respondWithError($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response invalid query (http 500) to the request.
|
||||
* Error Code = 40.
|
||||
*
|
||||
* @param string $message
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondInvalidQuery($message = null)
|
||||
{
|
||||
return $this->setHTTPStatusCode(500)
|
||||
->setErrorCode(40)
|
||||
->respondWithError($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error when the query contains invalid parameters.
|
||||
* Error Code = 41.
|
||||
*
|
||||
* @param string $message
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondInvalidParameters($message = null)
|
||||
{
|
||||
return $this->setHTTPStatusCode(422)
|
||||
->setErrorCode(41)
|
||||
->respondWithError($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response unauthorized (401) to the request.
|
||||
* Error Code = 42.
|
||||
*
|
||||
* @param string $message
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondUnauthorized($message = null)
|
||||
{
|
||||
return $this->setHTTPStatusCode(401)
|
||||
->setErrorCode(42)
|
||||
->respondWithError($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response with error.
|
||||
*
|
||||
* @param string|array $message
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondWithError($message = null)
|
||||
{
|
||||
return $this->respond([
|
||||
'error' => [
|
||||
'message' => $message ?? config('api.error_codes.'.$this->getErrorCode()),
|
||||
'error_code' => $this->getErrorCode(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a response that the object has been deleted, and also indicates
|
||||
* the id of the object that has been deleted.
|
||||
*
|
||||
* @param int $id
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function respondObjectDeleted($id)
|
||||
{
|
||||
return $this->respond([
|
||||
'deleted' => true,
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/Traits/Searchable.php
Normal file
76
app/Traits/Searchable.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Helpers\DBHelper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait Searchable
|
||||
{
|
||||
/**
|
||||
* Search for needle in the columns defined by $searchable_columns.
|
||||
*
|
||||
* @param Builder $builder query builder
|
||||
* @param string $needle
|
||||
* @param int $accountId
|
||||
* @param string $orderByColumn
|
||||
* @param string $orderByDirection
|
||||
* @param string $sortOrder
|
||||
* @return Builder|null
|
||||
*/
|
||||
public function scopeSearch(Builder $builder, string $needle, int $accountId, string $orderByColumn, string $orderByDirection = 'asc', string $sortOrder = null): ?Builder
|
||||
{
|
||||
if ($this->searchable_columns == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$searchableColumns = array_map(function ($column) {
|
||||
return DBHelper::getTable($this->getTable()).".`$column`";
|
||||
}, $this->searchable_columns);
|
||||
|
||||
$queryString = $this->buildQuery($searchableColumns, $needle);
|
||||
|
||||
$builder->whereRaw(DBHelper::getTable($this->getTable()).".`account_id` = $accountId")
|
||||
->whereRaw("( $queryString )")
|
||||
->orderBy($orderByColumn, $orderByDirection);
|
||||
|
||||
if ($sortOrder) {
|
||||
$builder->sortedBy($sortOrder);
|
||||
}
|
||||
|
||||
$builder->select(array_map(function ($column) {
|
||||
return "{$this->getTable()}.$column";
|
||||
}, $this->return_from_search));
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a query based on the array that contains column names.
|
||||
*
|
||||
* @param array $array
|
||||
* @param string $searchTerm
|
||||
* @return string
|
||||
*/
|
||||
private function buildQuery(array $array, string $searchTerm): string
|
||||
{
|
||||
$first = true;
|
||||
$queryString = '';
|
||||
$searchTerms = explode(' ', $searchTerm);
|
||||
|
||||
foreach ($searchTerms as $searchTerm) {
|
||||
$searchTerm = DBHelper::connection()->getPdo()->quote('%'.$searchTerm.'%');
|
||||
|
||||
foreach ($array as $column) {
|
||||
if ($first) {
|
||||
$first = false;
|
||||
} else {
|
||||
$queryString .= ' OR ';
|
||||
}
|
||||
$queryString .= $column.' LIKE '.$searchTerm;
|
||||
}
|
||||
}
|
||||
|
||||
return $queryString;
|
||||
}
|
||||
}
|
||||
57
app/Traits/StripeCall.php
Normal file
57
app/Traits/StripeCall.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Exceptions\StripeException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait StripeCall
|
||||
{
|
||||
/**
|
||||
* Call stripe.
|
||||
*
|
||||
* @template TValue
|
||||
*
|
||||
* @param (callable(): TValue) $callback
|
||||
* @return TValue
|
||||
*/
|
||||
private function stripeCall($callback)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Stripe\Exception\CardException $e) {
|
||||
// Since it's a decline, \Stripe\Error\Card will be caught
|
||||
$body = $e->getJsonBody();
|
||||
$err = $body['error'];
|
||||
$errorMessage = trans('settings.stripe_error_card', ['message' => $err['message']]);
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe card decline error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Stripe\Exception\RateLimitException $e) {
|
||||
// Too many requests made to the API too quickly
|
||||
$errorMessage = trans('settings.stripe_error_rate_limit');
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe RateLimit error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
// Invalid parameters were supplied to Stripe's API
|
||||
$errorMessage = trans('settings.stripe_error_invalid_request');
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe InvalidRequest error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Stripe\Exception\AuthenticationException $e) {
|
||||
// Authentication with Stripe's API failed
|
||||
// (maybe you changed API keys recently)
|
||||
$errorMessage = trans('settings.stripe_error_authentication');
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe Authentication error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Stripe\Exception\ApiConnectionException $e) {
|
||||
// Network communication with Stripe failed
|
||||
$errorMessage = trans('settings.stripe_error_api_connection_error');
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe ApiConnection error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Stripe\Exception\ApiErrorException $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe error: '.$e->getMessage(), ['body' => $e->getJsonBody(), $e]);
|
||||
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
Log::error(__CLASS__.' '.__FUNCTION__.': Stripe error: '.$e->getMessage(), [$e]);
|
||||
}
|
||||
|
||||
throw new StripeException($errorMessage);
|
||||
}
|
||||
}
|
||||
146
app/Traits/Subscription.php
Normal file
146
app/Traits/Subscription.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Laravel\Cashier\Billable;
|
||||
use App\Helpers\InstanceHelper;
|
||||
|
||||
trait Subscription
|
||||
{
|
||||
use Billable, StripeCall;
|
||||
|
||||
/**
|
||||
* Process the upgrade payment.
|
||||
*
|
||||
* @param string $payment_method
|
||||
* @param string $planName
|
||||
* @return bool|string
|
||||
*/
|
||||
public function subscribe(string $payment_method, string $planName)
|
||||
{
|
||||
$plan = InstanceHelper::getPlanInformationFromConfig($planName);
|
||||
if ($plan === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $this->stripeCall(function () use ($payment_method, $plan) {
|
||||
$this->newSubscription($plan['name'], $plan['id'])
|
||||
->create($payment_method, [
|
||||
'email' => auth()->user()->email,
|
||||
]);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing subscription.
|
||||
*
|
||||
* @param string $planName
|
||||
* @param \Laravel\Cashier\Subscription $subscription
|
||||
* @return \Laravel\Cashier\Subscription
|
||||
*/
|
||||
public function updateSubscription(string $planName, \Laravel\Cashier\Subscription $subscription)
|
||||
{
|
||||
$oldPlan = $subscription->stripe_price;
|
||||
$plan = InstanceHelper::getPlanInformationFromConfig($planName);
|
||||
if ($plan === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($oldPlan === $planName) {
|
||||
// No change
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
$subscription = $this->stripeCall(function () use ($subscription, $plan) {
|
||||
return $subscription->swap($plan['id']);
|
||||
});
|
||||
|
||||
if ($subscription->stripe_price !== $oldPlan && $subscription->stripe_price === $plan['id']) {
|
||||
$subscription->forceFill([
|
||||
'name' => $plan['name'],
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the account is currently subscribed to a plan.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isSubscribed()
|
||||
{
|
||||
if ($this->has_access_to_paid_version_for_free) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->getSubscribedPlan() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subscription the account is subscribed to.
|
||||
*
|
||||
* @return \Laravel\Cashier\Subscription|null
|
||||
*/
|
||||
public function getSubscribedPlan()
|
||||
{
|
||||
return $this->subscriptions()->recurring()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the id of the plan the account is subscribed to.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getSubscribedPlanId()
|
||||
{
|
||||
$plan = $this->getSubscribedPlan();
|
||||
|
||||
return is_null($plan) ? '' : $plan->stripe_price;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the friendly name of the plan the account is subscribed to.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getSubscribedPlanName(): ?string
|
||||
{
|
||||
$plan = $this->getSubscribedPlan();
|
||||
|
||||
return is_null($plan) ? null : $plan->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the plan the account is subscribed to.
|
||||
*
|
||||
* @return bool|string
|
||||
*/
|
||||
public function subscriptionCancel()
|
||||
{
|
||||
$plan = $this->getSubscribedPlan();
|
||||
|
||||
if (! is_null($plan)) {
|
||||
return $this->stripeCall(function () use ($plan) {
|
||||
$plan->cancelNow();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the account has invoices linked to this account.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasInvoices()
|
||||
{
|
||||
return $this->subscriptions()->count() > 0;
|
||||
}
|
||||
}
|
||||
26
app/Traits/WithUser.php
Normal file
26
app/Traits/WithUser.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\User\User;
|
||||
|
||||
trait WithUser
|
||||
{
|
||||
/**
|
||||
* @var \App\Models\User\User
|
||||
*/
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* Initialize.
|
||||
*
|
||||
* @param User $user
|
||||
* @return self
|
||||
*/
|
||||
public function init(User $user): self
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user