braintree_cashier-8.x-4.x-dev/src/BraintreeCashierService.php
src/BraintreeCashierService.php
<?php
namespace Drupal\braintree_cashier;
use Braintree\Transaction;
use Drupal\braintree_api\BraintreeApiService;
use Drupal\braintree_cashier\Entity\BraintreeCashierBillingPlanInterface;
use Drupal\braintree_cashier\Entity\BraintreeCashierDiscount;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountProxy;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\user\Entity\User;
use Money\Currencies\ISOCurrencies;
use Money\Formatter\IntlMoneyFormatter;
use Money\Parser\DecimalMoneyParser;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Class Helper.
*/
class BraintreeCashierService {
use StringTranslationTrait;
/**
* Drupal\Core\Session\AccountProxy definition.
*
* @var \Drupal\Core\Session\AccountProxy
*/
protected $currentUser;
/**
* The mail manager.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected $mailManager;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Billing plan storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $billingPlanStorage;
/**
* The Braintree API service.
*
* @var \Drupal\braintree_api\BraintreeApiService
*/
protected $braintreeApi;
/**
* The braintree_cashier logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* The discount entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $discountStorage;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* @var \Drupal\braintree_cashier\SubscriptionService
*/
protected $subscriptionService;
/**
* @var \Drupal\braintree_cashier\BillableUser
*/
protected $billableUser;
/**
* Constructs a new Helper object.
*
* @param \Drupal\Core\Session\AccountProxy $current_user
* The user entity.
* @param \Drupal\Core\Mail\MailManagerInterface $plugin_manager_mail
* The mail manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\braintree_api\BraintreeApiService $braintree_api
* The Braintree API service.
* @param \Drupal\Core\Logger\LoggerChannelInterface $logger
* The braintree_cashier logger channel.
* @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
* The request stack.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
*/
public function __construct(AccountProxy $current_user, MailManagerInterface $plugin_manager_mail, ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager, BraintreeApiService $braintree_api, LoggerChannelInterface $logger, RequestStack $requestStack, MessengerInterface $messenger, ModuleHandlerInterface $moduleHandler) {
$this->currentUser = $current_user;
$this->mailManager = $plugin_manager_mail;
$this->configFactory = $config_factory;
$this->billingPlanStorage = $entity_type_manager->getStorage('braintree_cashier_billing_plan');
$this->discountStorage = $entity_type_manager->getStorage('braintree_cashier_discount');
$this->braintreeApi = $braintree_api;
$this->logger = $logger;
$this->requestStack = $requestStack;
$this->messenger = $messenger;
$this->moduleHandler = $moduleHandler;
}
/**
* Sends an error email to the site administrator.
*
* @param string $message
* The error message to send to the site administrator.
*/
public function sendAdminErrorEmail($message) {
$to = $this->configFactory->get('system.site')->get('mail');
$lang_code = $this->configFactory->get('system.site')->get('langcode');
$this->mailManager->mail('braintree_cashier', 'admin_error', $to, $lang_code, ['message' => $message]);
}
/**
* Loads a billing plan entity from a Braintree Plan Id.
*
* @param string $braintree_plan_id
* The ID of the plan displayed in the Braintree control panel.
*
* @return \Drupal\braintree_cashier\Entity\BraintreeCashierBillingPlanInterface|false
* The billing plan entity.
*/
public function getBillingPlanFromBraintreePlanId($braintree_plan_id) {
$query = $this->billingPlanStorage->getQuery();
// It's safe to limit the range to 1 since the validation API ensures that
// only one billing plan of a given type can be created for each Braintree
// Plan ID.
$query->condition('environment', $this->braintreeApi->getEnvironment())
->condition('braintree_plan_id', $braintree_plan_id)
->range(0, 1);
$result = $query->execute();
if (!empty($result)) {
$entity_id = array_shift($result);
/** @var \Drupal\braintree_cashier\Entity\BraintreeCashierBillingPlanInterface $billing_plan */
$billing_plan = $this->billingPlanStorage->load($entity_id);
return $billing_plan;
}
return FALSE;
}
/**
* Handles a Braintree transaction which has a status of "processor_declined".
*
* @see https://developers.braintreepayments.com/reference/response/transaction/php#processor-declined
*/
public function handleProcessorDeclined($processor_response_code, $processor_response_text) {
$this->logger->error('Processor declined the transaction. %code:%message', [
'%code' => $processor_response_code,
'%message' => $processor_response_text,
]);
// The following codes possibly indicate attempted fraud.
$fraud_codes = [
2012 => 'Processor Declined – Possible Lost Card',
2013 => 'Processor Declined – Possible Stolen Card',
2014 => 'Processor Declined – Fraud Suspected',
2053 => 'Card reported as lost or stolen'
];
if (!empty($fraud_codes[$processor_response_code])) {
// Attempted fraud detected. Block the user account.
$this->blockFraudAccount();
$this->sendAdminErrorEmail('The user with email ' . $this->currentUser->getEmail() . ' has been blocked due to possible fraud. The processor declined the transaction with the message ' . $processor_response_code . ':' . $processor_response_text);
return;
}
switch ($processor_response_code) {
case 2010:
// Card Issuer Declined CVV.
$this->messenger->addError($this->t('Your bank reported that you entered in an invalid security code or made a typo in your card information. Please re-enter your card information.'));
break;
case 2047:
// Call Issuer. Pick Up Card
$this->messenger->addError($this->t('This card can not be used. Please try a different card or payment method.'));
break;
case 2006:
// Invalid Expiration Date.
$this->messenger->addError($this->t('Your bank reported that you made a typo in your card expiration date. Please re-enter your card information'));
break;
case 2004:
// Expired Card.
$this->messenger->addError($this->t('Your card has expired. Please use a different payment method.'));
break;
case 2024:
// Card Type Not Enabled.
$this->messenger->addError($this->t('Our payment processor can not use this brand of card. Please choose a different payment method.'));
break;
default:
$this->messenger->addError($this->t('Card declined. Please either choose a different payment method or contact your bank to request accepting charges from this website.'));
}
}
/**
* Block the current user account when it is suspected of fraudulent activity.
*
* Any active subscriptions will automatically be canceled in
* braintree_cashier_user_update().
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function blockFraudAccount() {
/** @var \Drupal\user\Entity\User $user */
$user = User::load($this->currentUser->id());
$user->block();
$user->save();
$this->logger->error('Attempted fraud detected. The user account with email %email has been blocked', [
'%email' => $user->getEmail(),
]);
$this->messenger->addError($this->t('Possible fraud detected. This account has been blocked. Please contact your card issuing bank.'));
}
/**
* Handles a Braintree transaction which has a status of "gateway_rejected".
*
* @param string $reason
* The reason for the rejection.
*
* @see https://developers.braintreepayments.com/reference/response/transaction/php#gateway-rejection
*/
public function handleGatewayRejected($reason) {
$this->logger->error('Gateway rejected. Reason: ' . $reason);
$fraud_reasons = [
'risk_threshold',
'fraud',
];
if (in_array($reason, $fraud_reasons)) {
$this->blockFraudAccount();
$this->sendAdminErrorEmail('The account with email ' . $this->currentUser->getEmail() . ' has been blocked due to possible fraud. The gateway rejection reason is: ' . $reason);
return;
}
$this->messenger->addError($this->t('Our payment processor rejected this transaction, and reported the following reason: %reason', [
'%reason' => $reason,
]));
}
/**
* Handle a "processor_settlement_declined" transaction.
*
* This status is rare, and only certain types of transactions can be
* affected.
*
* @param \Braintree\Transaction $transaction
* The transaction property of the Braintree response object.
* $transaction->status must be "processor_settlement_declined".
*
* @see https://developers.braintreepayments.com/reference/response/transaction/php#processor-settlement-declined
*/
public function handleProcessorSettlementDeclined(Transaction $transaction) {
$this->logger->error('Processor declined settlement of the transaction. %code:%message', [
'%code' => $transaction->processorSettlementResponseCode,
'%message' => $transaction->processorSettlementResponseText,
]);
$this->messenger->addError($this->t('It was not possible to create your subscription. Please contact the site administrator.'));
}
/**
* Gets the Braintree Plan.
*
* @param string $braintree_plan_id
* The machine name of the Plan in the Braintree control panel.
*
* @return \Braintree\Plan
* The Braintree Plan object.
*
* @throws \Exception
*/
public function getBraintreeBillingPlan($braintree_plan_id) {
$plans = $this->braintreeApi->getGateway()->plan()->all();
foreach ($plans as $plan) {
if ($plan->id == $braintree_plan_id) {
return $plan;
}
}
throw new \Exception("Unable to find Braintree plan with ID [{$braintree_plan_id}].");
}
/**
* Gets the Braintree discount object.
*
* @param string $discount_id
* The discount ID contained in the Braintree console.
*
* @return \Braintree\Discount|bool
* The Braintree discount object.
*/
public function getBraintreeDiscount($discount_id) {
$this->moduleHandler->alter('braintree_cashier_coupon_code', $discount_id);
$discounts = $this->braintreeApi->getGateway()->discount()->all();
foreach ($discounts as $discount) {
if ($discount->id == $discount_id) {
return $discount;
}
}
$this->logger->error("Unable to find the Braintree discount with ID {$discount_id}");
return FALSE;
}
/**
* Gets an array of transactions statuses which are considered completed.
*
* This is every status except those indicating a failed transaction.
*
* @return array
* An array of Braintree transaction statuses.
*/
public function getTransactionCompletedStatuses() {
return [
Transaction::AUTHORIZING,
Transaction::AUTHORIZED,
Transaction::SUBMITTED_FOR_SETTLEMENT,
Transaction::SETTLING,
Transaction::SETTLEMENT_PENDING,
Transaction::SETTLED,
];
}
/**
* Checks whether the provided coupon code is valid.
*
* Checks whether it is published and is applicable to the provided billing
* plan entity. If the discount does not specify a billing plan then it
* is valid for any billing plan.
*
* @param \Drupal\braintree_cashier\Entity\BraintreeCashierBillingPlanInterface $billing_plan
* The billing plan.
* @param string $coupon_code
* The coupon code.
* @param \Drupal\user\Entity\User $account
* The account attempting to use the coupon code.
*
* @return bool
* A boolean indicating whether the provided coupon code applies to the
* provided billing plan.
*/
public function discountExists(BraintreeCashierBillingPlanInterface $billing_plan, $coupon_code, User $account) {
$discount_exists = FALSE;
$query = $this->discountStorage->getQuery();
// Check that the code exists, and applies to the current selected plan.
// Queries are case insensitive.
$query->condition('status', TRUE)
->condition('discount_id', $coupon_code);
$results = $query->execute();
foreach ($results as $result) {
/** @var \Drupal\braintree_cashier\Entity\BraintreeCashierDiscountInterface $discount */
$discount = BraintreeCashierDiscount::load($result);
// The Braintree API is case sensitive.
if (strcmp($discount->getBraintreeDiscountId(), $coupon_code) === 0) {
$discount_exists = TRUE;
}
// If the billing plan is not empty, then ensure the discount applies to
// the provided billing plan.
if (!empty($discount->getBillingPlans()) && $discount_exists) {
$applicable_billing_plans = [];
foreach ($discount->getBillingPlans() as $applicable_billing_plan) {
$applicable_billing_plans[] = $applicable_billing_plan->id();
}
if (!in_array($billing_plan->id(), $applicable_billing_plans)) {
$discount_exists = FALSE;
}
}
}
// Let other modules decide whether the $coupon_code is valid.
$context = [
'billing_plan' => $billing_plan,
'account' => $account,
];
$this->moduleHandler->alter('braintree_cashier_discount_exists', $discount_exists, $coupon_code, $context);
return $discount_exists;
}
/**
* Gets the locale to use with the \NumberFormatter class.
*
* @return string
* The locale.
*/
public function getLocale() {
if ($this->configFactory->get('braintree_cashier.settings')->get('force_locale_en')) {
return 'en';
}
return $this->requestStack->getCurrentRequest()->getLocale();
}
/**
* Format an amount with a currency symbol.
*
* @param string $amount
* The raw amount as a string.
*
* @return string
* The formatted amount with a currency symbol.
*/
public function getFormattedAmount($amount) {
// Setup Money.
$currencies = new ISOCurrencies();
$moneyParser = new DecimalMoneyParser($currencies);
$numberFormatter = new \NumberFormatter($this->getLocale(), \NumberFormatter::CURRENCY);
$moneyFormatter = new IntlMoneyFormatter($numberFormatter, $currencies);
$amount = $moneyParser->parse($amount, $this->configFactory->get('braintree_cashier.settings')->get('currency_code'));
return $formatted_amount = $moneyFormatter->format($amount);
}
}
