apigee_m10n-8.x-1.7/modules/apigee_m10n_add_credit/src/Job/BalanceAdjustmentJob.php
modules/apigee_m10n_add_credit/src/Job/BalanceAdjustmentJob.php
<?php
/*
* Copyright 2018 Google Inc.
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License version 2 as published by the
* Free Software Foundation.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc., 51
* Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
namespace Drupal\apigee_m10n_add_credit\Job;
use Apigee\Edge\Api\Management\Entity\CompanyInterface;
use Apigee\Edge\Api\Monetization\Controller\PrepaidBalanceControllerInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Language\Language;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\apigee_edge\Entity\DeveloperInterface;
use Drupal\apigee_edge\Job\EdgeJob;
use Drupal\apigee_m10n\Controller\PrepaidBalanceController;
use Drupal\apigee_m10n_add_credit\AddCreditConfig;
use Drupal\commerce_order\Adjustment;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_price\Price;
use Drupal\user\UserInterface;
/**
* An apigee job that will apply a balance adjustment.
*
* The job is responsible for updating the account balance for a developer or
* company. It is usually initiated after an add credit product is purchased.
*
* Execute should not return anything if the job was successful. Throwing an
* error will let the job runner know that the request was unsuccessful and will
* trigger a retry.
*
* @todo Handle refunds when the monetization API supports it.
*/
class BalanceAdjustmentJob extends EdgeJob {
use StringTranslationTrait;
/**
* The developer account to whom a balance adjustment is to be made.
*
* @var \Drupal\user\UserInterface
*/
protected $developer;
/**
* The company to whom a balance adjustment is to be made.
*
* @var \Apigee\Edge\Api\Management\Entity\CompanyInterface
*/
protected $company;
/**
* The drupal commerce adjustment.
*
* Co-opt the commerce adjustment since this module requires it anyway. For
* the context of this job the adjustment is what is to be made to the account
* balance. An increase to the account balance would be a positive adjustment
* and a decrease would be a negative adjustment.
*
* @var \Drupal\commerce_order\Adjustment
*/
protected $adjustment;
/**
* The drupal commerce order.
*
* @var \Drupal\commerce_order\Entity\OrderInterface
*/
protected $order;
/**
* The `apigee_m10n_add_credit` module config.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
protected $module_config;
/**
* Creates an Apigee balance adjustment (add credit) job.
*
* @param \Drupal\Core\Entity\EntityInterface $company_or_user
* The company or user the adjustment should be applied to.
* @param \Drupal\commerce_order\Adjustment $adjustment
* The drupal commerce adjustment.
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The drupal commerce order.
*/
public function __construct(EntityInterface $company_or_user, Adjustment $adjustment, ?OrderInterface $order = NULL) {
parent::__construct();
// Either a developer or a company can be passed.
if ($company_or_user instanceof UserInterface) {
// A user was passed.
$this->developer = $company_or_user;
}
elseif ($company_or_user instanceof DeveloperInterface) {
// A developer was passed. Get the owner.
$this->developer = $company_or_user->getOwner();
}
elseif ($company_or_user instanceof CompanyInterface) {
// A company was passed.
$this->company = $company_or_user;
}
$this->adjustment = $adjustment;
$this->order = $order;
$this->module_config = \Drupal::config(AddCreditConfig::CONFIG_NAME);
$this->setTag('prepaid_balance_update_wait');
}
/**
* {@inheritdoc}
*
* @throws \Throwable
*/
protected function executeRequest() {
$adjustment = $this->adjustment;
$currency_code = $adjustment->getAmount()->getCurrencyCode();
// Grab the current balances.
if ($controller = $this->getBalanceController()) {
// Get existing balance with the same currency code.
$balance = $this->getPrepaidBalance($controller, $currency_code);
$existing_top_ups = new Price(!empty($balance) ? (string) $balance->getTopUps() : '0', $currency_code);
// Calculate the expected new balance.
$expected_balance = $existing_top_ups->add($adjustment->getAmount());
$transaction_time = new \DateTimeImmutable();
try {
// Top up by the adjustment amount.
$controller->topUpBalance((float) $adjustment->getAmount()->getNumber(), $currency_code);
// The data returned from `topUpBalance` doesn't get us the new top up
// total so we have to grab that from the balance controller again.
$balance_after = $this->getPrepaidBalance($controller, $currency_code);
$new_balance = new Price((string) ($balance_after->getTopUps()), $currency_code);
$cache_entity = $this->isDeveloperAdjustment() ? $this->developer : $this->company;
Cache::invalidateTags([PrepaidBalanceController::getCacheId($cache_entity)]);
}
catch (\Throwable $t) {
// Nothing gets logged/reported if we let errors end the job here.
$this->getLogger()->error((string) $t);
$thrown = $t;
}
$this->logTransaction($currency_code, $transaction_time);
// Check the balance again to make sure the amount is correct.
if (!empty($new_balance)
&& !empty($new_balance->getNumber())
&& ($expected_balance->getNumber() === $new_balance->getNumber())
) {
// Set the log action.
$log_action = 'info';
}
else {
// Something is fishy here, we should log as an error.
$log_action = 'error';
}
// Get the appropriate report text from the lookup table.
$report_text = $this->getMessage("report_text_{$log_action}_header") . $this->getMessage('report_text');
// Compile message context.
$context = [
'email' => !empty($this->developer) ? $this->developer->getEmail() : '',
'team_name' => !empty($this->company) ? $this->company->label() : '',
'existing' => $this->formatPrice($existing_top_ups),
'adjustment' => $this->formatPrice($adjustment->getAmount()),
'new_balance' => isset($new_balance) ? $this->formatPrice($new_balance) : 'Error retrieving the new balance.',
'expected_balance' => $this->formatPrice($expected_balance),
'month' => date('F'),
];
// Report the transaction.
$this->getLogger()->{$log_action}($report_text, $context);
/** @var \Drupal\Core\Logger\LogMessageParser $message_parser */
$message_parser = \Drupal::service('logger.log_message_parser');
// Strip br html tags.
$report_text = str_replace('<br />', '', $report_text);
// The message parser strips out empty values so we may need to re-add
// some empty values for formatting.
$all_placeholders = [
'@email' => '',
'@team_name' => '',
'@existing' => '',
'@adjustment' => '',
'@new_balance' => '',
'@expected_balance' => '',
'@month' => '',
];
// Format the message using the log message parser.
$message_context = $message_parser->parseMessagePlaceholders($report_text, $context);
// Re-add empty values to message context.
$message_context = $message_context + $all_placeholders;
// Add the report text to the message context.
$message_context['@report_text'] = $report_text;
// If there were any errors or exceptions, they still need to be thrown.
if (isset($thrown)) {
$message_context['@error'] = (string) $thrown;
// Sent the notification.
$this->sendNotification('balance_adjustment_error_report', $message_context);
throw $thrown;
}
elseif ($this->module_config->get('notify_on') == AddCreditConfig::NOTIFY_ALWAYS) {
$this->sendNotification('balance_adjustment_report', $message_context);
}
}
}
/**
* {@inheritdoc}
*/
public function shouldRetry(\Exception $exception): bool {
// We aren't retrying requests ATM. If we can confirm that the payment
// wasn't applied, we could return true here and the top-up would be
// retried.
// @todo Return true once we can determine the payment wasn't applied.
return FALSE;
}
/**
* {@inheritdoc}
*/
public function __toString(): string {
// Use "Add" for an increase adjustment or "Subtract" for a decrease.
$adj_verb = $this->adjustment->isPositive() ? 'Add' : 'Subtract';
$abs_price = new Price(abs($this->adjustment->getAmount()->getNumber()), $this->adjustment->getAmount()->getCurrencyCode());
return t(":adj_verb :amount to :account", [
':adj_verb' => $adj_verb,
':amount' => $this->formatPrice($abs_price),
':account' => $this->developer->getEmail(),
]);
}
/**
* Get's the prepaid balance information from the given controller.
*
* @param \Apigee\Edge\Api\Monetization\Controller\PrepaidBalanceControllerInterface $controller
* The team or developer controller.
* @param string $currency_code
* The currency code to retrieve the balance for.
*
* @return \Apigee\Edge\Api\Monetization\Entity\PrepaidBalanceInterface|null
* The balance for this adjustment currency.
*
* @throws \Exception
*/
protected function getPrepaidBalance(PrepaidBalanceControllerInterface $controller, $currency_code) {
/** @var \Apigee\Edge\Api\Monetization\Entity\PrepaidBalanceInterface[] $balances */
$balances = $controller->getPrepaidBalance(new \DateTimeImmutable());
if (!empty($balances)) {
$balances = array_combine(array_map(function ($balance) {
return $balance->getCurrency()->getName();
}, $balances), $balances);
}
return !empty($balances[$currency_code]) ? $balances[$currency_code] : NULL;
}
/**
* Get's the logger for this job.
*
* @return \Psr\Log\LoggerInterface
* The Psr7 logger.
*/
protected function getLogger() {
return \Drupal::service('logger.channel.apigee_m10n_add_credit');
}
/**
* Gets the developer balance controller for the developer user.
*
* @return \Apigee\Edge\Api\Monetization\Controller\PrepaidBalanceControllerInterface|false
* The developer balance controller
*/
protected function getBalanceController() {
// Return the appropriate controller for the operational entity type.
if (!empty($this->developer)) {
return \Drupal::service('apigee_m10n.sdk_controller_factory')
->developerBalanceController($this->developer);
}
elseif (!empty($this->company)) {
return \Drupal::service('apigee_m10n.sdk_controller_factory')
->companyBalanceController($this->company);
}
return FALSE;
}
/**
* Get's the drupal commerce currency formatter.
*
* @return \CommerceGuys\Intl\Formatter\CurrencyFormatterInterface
* The currency formatter.
*/
protected function currencyFormatter() {
return \Drupal::service('commerce_price.currency_formatter');
}
/**
* Formats a commerce price using the currency formatter service.
*
* @param \Drupal\commerce_price\Price $price
* The commerce price to be formatted.
*
* @return string
* The formatted price, i.e. $100 USD.
*/
protected function formatPrice(Price $price) {
return $this->currencyFormatter()->format(
$price->getNumber(),
strtoupper($price->getCurrencyCode()),
[
'currency_display' => 'symbol',
'minimum_fraction_digits' => 2,
]
);
}
/**
* Helper to determine if this is a developer adjustment.
*
* Otherwise, this is a company adjustment.
*
* @return bool
* True if this is a developer adjustment.
*/
protected function isDeveloperAdjustment(): bool {
return !empty($this->developer);
}
/**
* Get a message.
*
* A lookup for messages that depends on the type of adjustment we are dealing
* with here.
*
* @param string $message_id
* An identifier for the message.
*
* @return string
* The message.
*/
protected function getMessage($message_id) {
$type = $this->isDeveloperAdjustment() ? 'developer' : 'company';
$report_text = 'Existing credit added ({month}): `{existing}`.<br />' . PHP_EOL;
$report_text .= 'Amount Applied: `{adjustment}`.<br />' . PHP_EOL;
$report_text .= 'New Balance: `{new_balance}`.<br />' . PHP_EOL;
$report_text .= 'Expected New Balance: `{expected_balance}`.<br />' . PHP_EOL;
$messages = [
'developer' => [
'balance_error_message' => 'Apigee User ({email}) has no balance for ({currency}).',
'report_text_error_header' => 'Calculation discrepancy applying adjustment to developer `{email}`. <br />' . PHP_EOL . PHP_EOL,
'report_text_info_header' => 'Adjustment applied to developer: `{email}`. <br />' . PHP_EOL . PHP_EOL,
'report_text' => $report_text,
],
'company' => [
'balance_error_message' => 'Apigee team ({team_name}) has no balance for ({currency}).',
'report_text_error_header' => 'Calculation discrepancy applying adjustment to team `{team_name}`. <br />' . PHP_EOL . PHP_EOL,
'report_text_info_header' => 'Adjustment applied to team: `{team_name}`. <br />' . PHP_EOL . PHP_EOL,
'report_text' => $report_text,
],
];
return $messages[$type][$message_id];
}
/**
* Send a notification using drupal mail API.
*
* @param string $notification_type
* The notificaiton type.
* @param array|null $message_context
* The message context.
*/
protected function sendNotification($notification_type, $message_context) {
// Email the error to an administrator.
$recipient = !empty($this->module_config->get('notification_recipient'))
? $this->module_config->get('notification_recipient')
: \Drupal::config('system.site')->get('mail');
$recipient = !empty($recipient) ? $recipient : ini_get('sendmail_from');
\Drupal::service('plugin.manager.mail')->mail(
'apigee_m10n_add_credit',
$notification_type,
$recipient,
Language::LANGCODE_DEFAULT,
$message_context
);
}
/**
* Attempt to recover the Apigee transaction ID and save in log.
*
* @param string $currency_code
* The currency code.
* @param \DateTimeImmutable $transaction_time
* The transaction time.
*/
protected function logTransaction($currency_code, \DateTimeImmutable $transaction_time) {
$monetization = \Drupal::service('apigee_m10n.monetization');
$id = $this->isDeveloperAdjustment() ? $this->developer->getEmail() : $this->company->id();
$report = $monetization->getPrepaidBalanceReport($id, $transaction_time, $currency_code);
$csv = array_map('str_getcsv', explode("\r\n", $report));
// This assumes the last transaction is the one we just performed.
// @todo Find a better way to retrieve the transaction ID.
$transaction = end($csv);
/** @var \Drupal\apigee_m10n_add_credit\Entity\AddCreditLogInterface $log */
$log = \Drupal::entityTypeManager()->getStorage('add_credit_log')->create([
'commerce_order' => $this->order ? $this->order->id() : NULL,
'apigee_transaction' => isset($transaction[6]) ?: NULL,
'provider_status' => isset($transaction[4]) ?: NULL,
'created' => isset($transaction[8]) ? strtotime($transaction[8]) : NULL,
'developer' => $this->isDeveloperAdjustment() ? $this->developer->id() : NULL,
'team' => $this->isDeveloperAdjustment() ? NULL : $this->company->id(),
]);
try {
$log->save();
}
catch (\Exception $e) {
$this->getLogger()->error('Could not save add credit log entry.');
}
}
}
