src/Manager/StripeManager.php
<?php
declare(strict_types=1);
/*
* This file is part of the Serendipity HQ Stripe Bundle.
*
* Copyright (c) Adamo Aerendir Crespi <aerendir@serendipityhq.com>.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SerendipityHQ\Bundle\StripeBundle\Manager;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use SerendipityHQ\Bundle\StripeBundle\Model\StripeLocalCharge;
use SerendipityHQ\Bundle\StripeBundle\Model\StripeLocalCustomer;
use SerendipityHQ\Bundle\StripeBundle\SHQStripeBundle;
use SerendipityHQ\Bundle\StripeBundle\Syncer\ChargeSyncer;
use SerendipityHQ\Bundle\StripeBundle\Syncer\CustomerSyncer;
use SerendipityHQ\Bundle\StripeBundle\Syncer\WebhookEventSyncer;
use Stripe\ApiResource;
use Stripe\Charge;
use Stripe\Customer;
use Stripe\Event;
use Stripe\Exception\CardException;
use Stripe\Exception\ExceptionInterface;
use Stripe\Exception\RateLimitException;
use Stripe\Stripe;
use function Safe\sleep;
use function Safe\sprintf;
/**
* Manages the Stripe's API calls.
*/
final class StripeManager
{
/** @var int How many retries should the manager has to do */
private const MAX_RETRIES = 5;
private const ACTION_CREATE = 'create';
private const ACTION_RETRIEVE = 'retrieve';
private const OPTIONS = 'options';
private const ID = 'id';
private const PARAMS = 'params';
private const RETRY = 'retry';
private const ERROR = 'error';
private const TYPE = 'type';
private const CODE = 'code';
/** @var WebhookEventSyncer */
public $WebhookEventSyncer;
private string $debug;
private string $statementDescriptor;
/** Saves the errors thrown by the Stripe API */
private ?array $error = null;
private LoggerInterface $logger;
/** The current number of retries. This has to ever be less than $maxRetries */
private int $retries = 0;
/** The time in seconds the manager has to wait before retrying the request */
private int $wait = 1;
private ChargeSyncer $chargeSyncer;
private CustomerSyncer $customerSyncer;
public function __construct(string $secretKey, string $debug, string $statementDescriptor, ChargeSyncer $chargeSyncer, CustomerSyncer $customerSyncer, WebhookEventSyncer $webhookEventSyncer, LoggerInterface $logger = null)
{
Stripe::setApiKey($secretKey);
Stripe::setApiVersion(SHQStripeBundle::SUPPORTED_STRIPE_API);
$this->debug = $debug;
$this->statementDescriptor = $statementDescriptor;
$this->logger = $logger instanceof Logger ? $logger->withName('SHQStripeBundle') : $logger;
$this->chargeSyncer = $chargeSyncer;
$this->customerSyncer = $customerSyncer;
$this->WebhookEventSyncer = $webhookEventSyncer;
}
public function createCharge(StripeLocalCharge $localCharge): bool
{
// Get the object as an array
$params = $localCharge->toStripe(self::ACTION_CREATE);
// If the statement descriptor is not set and the default one is not null...
if (false === isset($params['statement_descriptor']) && false === \is_null($this->statementDescriptor)) {
// Set it
$params['statement_descriptor'] = $this->statementDescriptor;
}
$arguments = [
self::PARAMS => $params,
self::OPTIONS => [],
];
$stripeCharge = $this->callStripeApi(Charge::class, self::ACTION_CREATE, $arguments);
// If the creation failed...
if (false === $stripeCharge) {
// ... Check if it was due to a fraudulent detection
if (isset($this->error[self::ERROR][self::TYPE])) {
$this->chargeSyncer->handleFraudDetection($localCharge, $this->error);
}
// ... return false as the payment anyway failed
return false;
}
// Set the data returned by Stripe in the LocalCustomer object
$this->chargeSyncer->syncLocalFromStripe($localCharge, $stripeCharge);
// The creation was successful: return true
return true;
}
public function createCustomer(StripeLocalCustomer $localCustomer): bool
{
// Get the object as an array
$params = $localCustomer->toStripe(self::ACTION_CREATE);
$arguments = [
self::PARAMS => $params,
self::OPTIONS => [],
];
/** @var Customer $stripeCustomer */
$stripeCustomer = $this->callStripeApi(Customer::class, self::ACTION_CREATE, $arguments);
// If the creation failed, return false
if (false === $stripeCustomer) {
return false;
}
// Set the data returned by Stripe in the LocalCustomer object
$this->customerSyncer->syncLocalFromStripe($localCustomer, $stripeCustomer);
// The creation was successful: return true
return true;
}
public function retrieveCustomer(StripeLocalCustomer $localCustomer): ?Customer
{
// If no ID is set, return false
if (null === $localCustomer->getId()) {
return null;
}
$arguments = [
self::ID => $localCustomer->getId(),
self::OPTIONS => [],
];
$customer = $this->callStripeApi(Customer::class, self::ACTION_RETRIEVE, $arguments);
if ( ! $customer instanceof Customer) {
throw new \InvalidArgumentException(sprintf('The response is not of the expected type %s.', Customer::class));
}
// Return the stripe object that can be "false" or "Customer"
return $customer;
}
public function retrieveEvent(string $eventStripeId): Event
{
$arguments = [
self::ID => $eventStripeId,
self::OPTIONS => [],
];
$event = $this->callStripeApi(Event::class, self::ACTION_RETRIEVE, $arguments);
if ( ! $event instanceof Event) {
throw new \InvalidArgumentException(sprintf('The response is not of the expected type %s.', Event::class));
}
// Return the stripe object that can be "false" or "Customer"
return $event;
}
public function updateCustomer(StripeLocalCustomer $localCustomer, bool $syncSources): bool
{
// Get the stripe object
$stripeCustomer = $this->retrieveCustomer($localCustomer);
// The retrieving failed: return false
if (false === $stripeCustomer) {
return false;
}
// Update the stripe object with info set in the local object
$this->customerSyncer->syncStripeFromLocal($stripeCustomer, $localCustomer);
// Save the customer object
$stripeCustomer = $this->callStripeObject($stripeCustomer, 'save');
// If the update failed, return false
if (false === $stripeCustomer) {
return false;
}
// Set the data returned by Stripe in the LocalCustomer object
$this->customerSyncer->syncLocalFromStripe($localCustomer, $stripeCustomer);
if (true === $syncSources) {
$this->customerSyncer->syncLocalSources($localCustomer, $stripeCustomer);
}
return true;
}
/**
* Method to call the Stripe PHP SDK's static methods.
*
* This method wraps the calls in a try / catch statement to intercept exceptions raised by the Stripe client.
*
* It receives three arguments:
*
* 1) The FQN of the Stripe Entity class;
* 2) The method of the class to invoke;
* 3) The arguments to pass to the method.
*
* As the methods of the Stripe PHP SDK have all the same structure, it is possible to extract a pattern.
*
* So, the alternatives are as following:
*
* 1) options
* 2) id, options
* 3) params, options
* 4) id, params, options
*
* Only the case 2 and 3 are confusable.
*
* To make this method able to match the right Stripe SDK's method signature, always set all the required arguments.
* If one argument is not required (only options or params are allowed to be missed), anyway set it as an empty array.
* BUT ANYWAY SET IT to make the switch able to match the proper method signature.
*
* So, to call a Stripe API's entity with a method with signature that matches case 1:
*
* $arguments = [
* 'options' => [...] // Or an empty array
* ];
* $this->callStripe(Customer::class, 'save', $arguments)
*
* To call a Stripe API's entity with a method with signature that matches case 2:
*
* $arguments = [
* 'id' => 'the_id_of_the_entity',
* 'options' => [...] // Or an empty array
* ];
* $this->callStripe(Customer::class, 'save', $arguments)
*
* To call a Stripe API's entity with a method with signature that matches case 3:
*
* $arguments = [
* 'params' => [...] // Or an empty array,
* 'options' => [...] // Or an empty array
* ];
* $this->callStripe(Customer::class, 'save', $arguments)
*
* To call a Stripe API's entity with a method with signature that matches case 4:
*
* $arguments = [
* 'id' => 'the_id_of_the_entity',
* 'params' => [...] // Or an empty array,
* 'options' => [...] // Or an empty array
* ];
* $this->callStripe(Customer::class, 'save', $arguments)
*
* @return ApiResource|bool
*/
public function callStripeApi(string $endpoint, string $action, array $arguments)
{
$return = null;
try {
switch (\count($arguments)) {
// Method with 1 argument only accept "options"
case 1:
// If the value is an empty array, then set it as null
$options = empty($arguments[self::OPTIONS]) ? null : $arguments[self::OPTIONS];
$return = $endpoint::$action($options);
break;
case 2:
// If the ID exists, we have to call for sure a method that in the signature has the ID and the options
if (isset($arguments[self::ID])) {
// If the value is an empty array, then set it as null
$options = empty($arguments[self::OPTIONS]) ? null : $arguments[self::OPTIONS];
$return = $endpoint::$action($arguments[self::ID], $options);
}
// Else the method has params and options
else {
// If the value is an empty array, then set it as null
$params = empty($arguments[self::PARAMS]) ? null : $arguments[self::PARAMS];
$options = empty($arguments[self::OPTIONS]) ? null : $arguments[self::OPTIONS];
$return = $endpoint::$action($params, $options);
}
break;
// Method with 3 arguments accept id, params and options
case 3:
// If the value is an empty array, then set it as null
$params = empty($arguments[self::PARAMS]) ? null : $arguments[self::PARAMS];
$options = empty($arguments[self::OPTIONS]) ? null : $arguments[self::OPTIONS];
$return = $endpoint::$action($arguments[self::ID], $params, $options);
break;
default:
throw new \RuntimeException("The arguments passed don't correspond to the allowed number. Please, review them.");
}
} catch (ExceptionInterface $exception) {
$retry = $this->handleException($exception);
if ($retry) {
return $this->callStripeApi($endpoint, $action, $arguments);
}
}
// Reset the number of retries
$this->retries = 0;
if (null === $return) {
throw new \RuntimeException('No value is available after the call to the API. This should never happen, but happened.');
}
return $return;
}
/**
* Method to call the Stripe PHP SDK's NON static methods.
*
* This method is usually used to call methods of a Stripe entity that is already initialized.
*
* For example, it can be used to call "cancel" or "save" methods after they have been retrieved through
* $this->callStripe(...).
*
* This method wraps the calls in a try / catch statement to intercept exceptions raised by the Stripe client.
*
* It receives three arguments:
*
* 1) The FQN of the Stripe Entity class;
* 2) The method of the class to invoke;
* 3) The arguments to pass to the method.
*
* As the methods of the Stripe PHP SDK have all the same structure, it is possible to extract a pattern.
*
* So, the alternatives are as following:
*
* 1) options
* 2) params, options
* 3) params
* 4) [No arguments]
*
* There are no confusable cases.
*
* To make this method able to match the right Stripe SDK's method signature, set all the required arguments only
* for methods that match the case 2 and if If one argument is not required, anyway set it as an empty array BUT
* ANYWAY SET IT to make the switch able to match the proper method signature.
*
* So, to call a Stripe SDK's method with signature that matches case 1 or case 3:
*
* $arguments = [
* [...] // Or an empty array
* ];
* $this->callStripeObject(Customer::class, 'save', $arguments)
*
* You can give the key a name for clarity, but in the callStripeObject method it is anyway referenced to as
* $arguments[0], so is irrelevant you give a key or not.
*
* To call a Stripe SDK's method with signature that matches case 2:
*
* $arguments = [
* 'params' => [...] // Or an empty array,
* 'options' => [...] // Or an empty array
* ];
* $this->callStripeObject(Customer::class, 'cancel', $arguments)
*
* You can give the key a name for clarity, but in the callStripeObject method it is anyway referenced to as
* $arguments[0], so is irrelevant you give a key or not.
*
* @return ApiResource|bool
*/
public function callStripeObject(ApiResource $object, string $method, array $arguments = [])
{
try {
switch (\count($arguments)) {
// Method has no signature (it doesn't accept any argument)
case 0:
$return = $object->$method();
break;
// Method with 1 argument only accept one between "options" or "params"
case 1:
// So we simply use the unique value in the array
$return = $object->$method($arguments[0]);
break;
// Method with 3 arguments accept id, params and options
case 2:
// If the value is an empty array, then set it as null
$params = empty($arguments[self::PARAMS]) ? null : $arguments[self::PARAMS];
$options = empty($arguments[self::OPTIONS]) ? null : $arguments[self::OPTIONS];
$return = $object->$method($params, $options);
break;
default:
throw new \RuntimeException("The arguments passed don't correspond to the allowed number. Please, review them.");
}
} catch (ExceptionInterface $exception) {
$return = $this->handleException($exception);
if (self::RETRY === $return) {
$return = $this->callStripeObject($object, $method);
}
}
// Reset the number of retries
$this->retries = 0;
return $return;
}
/**
* This should be called only if an error exists. Use hasError().
*/
public function getError(): ?array
{
return $this->error;
}
public function hasErrors(): bool
{
return empty($this->error);
}
/**
* In dev mode, throws the catched exception while in production doesn't.
*
* @return bool Returns true if the call has to be retried, false instead.
* The call may need to be retried due to the reaching of the API rate limit.
*/
private function handleException(ExceptionInterface $e): bool
{
switch (\get_class($e)) {
case RateLimitException::class:
// We have to retry with an exponential backoff if is not reached the maximum number of retries
if ($this->retries <= self::MAX_RETRIES) {
// First, put the script on sleep
sleep($this->wait);
// Then we have to increment the sleep time
$this->wait += $this->wait;
// Increment by 1 the number of retries
++$this->retries;
return true;
}
break;
case CardException::class:
$concatenated = 'stripe';
if (isset($e->getJsonBody()[self::ERROR][self::TYPE])) {
$concatenated .= '.' . $e->getJsonBody()[self::ERROR][self::TYPE];
}
if (isset($e->getJsonBody()[self::ERROR][self::CODE])) {
$concatenated .= '.' . $e->getJsonBody()[self::ERROR][self::CODE];
}
if (isset($e->getJsonBody()[self::ERROR]['decline_code'])) {
$concatenated .= '.' . $e->getJsonBody()[self::ERROR]['decline_code'];
}
break;
}
// \Stripe\Error\Authentication, \Stripe\Error\InvalidRequest and \Stripe\Error\ApiConnection are processed immediately
$body = $e->getJsonBody();
$err = $body[self::ERROR];
$message = '[' . $e->getHttpStatus() . ' - ' . $e->getJsonBody()[self::ERROR][self::TYPE] . '] ' . $e->getMessage();
$context = [
'status' => $e->getHttpStatus(),
self::TYPE => $err[self::TYPE] ?? '',
self::CODE => $err[self::CODE] ?? '',
'param' => $err['param'] ?? '',
'request_id' => $e->getRequestId(),
'stripe_version' => $e->getHttpHeaders()['Stripe-Version'],
];
if ( ! $this->logger instanceof LoggerInterface) {
$this->logger->error($message, $context);
}
// If we are in debug mode, raise the exception immediately
if ('' !== $this->debug) {
throw $e;
}
// Set the error so it can be retrieved
$this->error = [
self::ERROR => $err,
'message' => $message,
'context' => $context,
];
if (isset($concatenated)) {
$this->error['concatenated'] = $concatenated;
}
return false;
}
}