diff --git a/README.md b/README.md index e6e74af..82f766d 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,45 @@ _TBD: make docker installation_ ``` ## API documentation - Two end-points are available for getting the API documentation: +Two end-points are available for getting the API documentation: - `/api/doc` swagger ui. - `/api/doc.json` as above but in Json format for consuming by [Postman](https://www.postman.com/product/what-is-postman/), for example. + +## Order status notifications +A subscription allows you to get notifications when the order status has been changed. + +### Channels and messages +A notification sends via channel. By default, `http` channel is available. +`http` does a POST/PUT/PATCH request to a predefined url with json payload. + +A message is used to represent data in the channel. There is `simple` message type, which +will turn an order and payment gateway response into a plain array. + +To get a list of available channels and messages, use `subscribe:channels` console command. + +If you want to develop your own channel and/or message, add tag `app.subscriber.channel` +for your custom channel and `app.subscriber.message` for a custom message (take a look +at `services.yml`). + +### Subscribe example +This is how to subscribe your external service when order status is changed to `payment_received`. +```shell +console subscriber:add-http --order-status payment_received --channel-message simple https://backend.my-service.com/api/order-payment +``` + +When the order gets `payment_received` status due to payment gateway response, +a POST http request will be sent to https://backend.my-service.com/api/order-payment end point. +The request will contain json data like this: +```json +{ + "order_num": "1234567890", + "order_status": "payment_received", + "success": true, + "transaction_id": "1234567890", + "response": { + // original response from the payment gateway. + } +} +``` diff --git a/composer.json b/composer.json index ffb120c..7814666 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,7 @@ } }, "require-dev": { + "dama/doctrine-test-bundle": "^8.2", "doctrine/doctrine-fixtures-bundle": "^3.6", "fakerphp/faker": "^1.23", "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index 48d8bec..7ffd42a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a2842e63c541fbc15ba099dc54da74ca", + "content-hash": "0ca29996ec9ed4587fff61e9edcc2fe2", "packages": [ { "name": "doctrine/cache", @@ -5902,6 +5902,73 @@ } ], "packages-dev": [ + { + "name": "dama/doctrine-test-bundle", + "version": "v8.2.0", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/1f81a280ea63f049d24e9c8ce00e557b18e0ff2f", + "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.0", + "php": "^7.4 || ^8.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.3 || ^7.0", + "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "symfony/phpunit-bridge": "^6.3", + "symfony/process": "^5.4 || ^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.3 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src/DAMA/DoctrineTestBundle" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.2.0" + }, + "time": "2024-05-28T15:41:06+00:00" + }, { "name": "doctrine/data-fixtures", "version": "1.7.0", diff --git a/config/bundles.php b/config/bundles.php index 37ad000..a4ab00c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -9,4 +9,5 @@ Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/packages/dama_doctrine_test_bundle.yaml b/config/packages/dama_doctrine_test_bundle.yaml new file mode 100644 index 0000000..3482cba --- /dev/null +++ b/config/packages/dama_doctrine_test_bundle.yaml @@ -0,0 +1,5 @@ +when@test: + dama_doctrine_test: + enable_static_connection: true + enable_static_meta_data_cache: true + enable_static_query_cache: true diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 60bf931..d8b7dba 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -5,17 +5,18 @@ framework: transports: # https://symfony.com/doc/current/messenger.html#transport-configuration - # async: '%env(MESSENGER_TRANSPORT_DSN)%' + async: '%env(MESSENGER_TRANSPORT_DSN)%' # failed: 'doctrine://default?queue_name=failed' # sync: 'sync://' routing: # Route your messages to the transports + App\Message\NotifySubscriber: async -# when@test: -# framework: -# messenger: -# transports: -# # replace with your transport name here (e.g., my_transport: 'in-memory://') -# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test -# async: 'in-memory://' +when@test: + framework: + messenger: + transports: + # replace with your transport name here (e.g., my_transport: 'in-memory://') + # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test + async: 'in-memory://' diff --git a/config/services.yaml b/config/services.yaml index aff476e..2a96faa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -36,3 +36,11 @@ services: App\Order\OrderTotalAmountCalculator: arguments: $currencyCode: 'UAH' + + App\Subscriber\Channel\HttpNotificationChannel: + tags: + - { name: 'app.subscriber.channel', type: 'http' } + + App\Subscriber\Channel\SimpleArrayChannelMessage: + tags: + - { name: 'app.subscriber.message', type: 'simple' } diff --git a/migrations/Version20240901095458.php b/migrations/Version20240901095458.php new file mode 100644 index 0000000..1efec21 --- /dev/null +++ b/migrations/Version20240901095458.php @@ -0,0 +1,44 @@ +addSql('CREATE TABLE subscribers ( + id INT AUTO_INCREMENT NOT NULL, + uuid BINARY(16) NOT NULL COMMENT \'(DC2Type:uuid)\', + hash VARCHAR(255) NOT NULL, + channel_type VARCHAR(255) NOT NULL, + order_status VARCHAR(255) NOT NULL, + channel_message VARCHAR(255) NOT NULL, + params JSON NOT NULL, + created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', + UNIQUE INDEX UNIQ_2FCD16ACD17F50A6 (uuid), + UNIQUE INDEX UNIQ_2FCD16ACD1B862B8 (hash), + INDEX IDX_2FCD16ACB88F75C9 (order_status), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE subscribers'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c76a655..01988de 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -34,5 +34,6 @@ + diff --git a/src/Command/PaymentGatewaysCommand.php b/src/Command/PaymentGatewaysCommand.php index 437caed..0330a70 100644 --- a/src/Command/PaymentGatewaysCommand.php +++ b/src/Command/PaymentGatewaysCommand.php @@ -17,7 +17,7 @@ name: 'payment:gateways', description: 'List of available payment gateways', )] -class PaymentGatewaysCommand extends Command +final class PaymentGatewaysCommand extends Command { public function __construct( private readonly PaymentGatewayRegistryInterface $paymentGateways, diff --git a/src/Command/SubscriberAddHttpCommand.php b/src/Command/SubscriberAddHttpCommand.php new file mode 100644 index 0000000..b8ea197 --- /dev/null +++ b/src/Command/SubscriberAddHttpCommand.php @@ -0,0 +1,88 @@ +addArgument('url', InputArgument::REQUIRED, 'Url to call.') + ->addOption( + 'http-method', + 'X', + InputOption::VALUE_OPTIONAL, + 'Http request method', + HttpNotificationChannel::DEFAULT_HTTP_METHOD, + ) + ->addOption( + 'order-status', + 's', + InputOption::VALUE_REQUIRED, + 'Expected order status: ' . OrderStatus::formattedString(), + '' + ) + ->addOption( + 'channel-message', + 'm', + InputOption::VALUE_REQUIRED, + 'Channel message type (see output of "subscriber:channels" for available message types)', + '' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $url = $input->getArgument('url'); + $httpMethod = $input->getOption('http-method'); + $orderStatusValue = $input->getOption('order-status'); + $channelMessage = $input->getOption('channel-message'); + + try { + if (! ($orderStatus = OrderStatus::tryFrom($orderStatusValue))) { + throw new Exception( + sprintf('Invalid order status: "%s". Expected: %s.', + $orderStatusValue, + OrderStatus::formattedString() + ) + ); + } + + $subscriber = $this->addHttpSubscriberAction->add($orderStatus, $url, $channelMessage, $httpMethod); + + $io->success("Subscriber \"{$subscriber->getUuid()}\" has been added."); + } catch (\Throwable $e) { + $io->error($e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/SubscriberChannelsCommand.php b/src/Command/SubscriberChannelsCommand.php new file mode 100644 index 0000000..604e165 --- /dev/null +++ b/src/Command/SubscriberChannelsCommand.php @@ -0,0 +1,49 @@ +channelCollection->getNotificationChannelTypes(); + $messageTypes = $this->channelCollection->getMessageTypes(); + + if ($channelTypes) { + $io->title('Notification channels:'); + $io->block($channelTypes); + } else { + $io->warning('No subscriber notification channels found.'); + } + + if ($messageTypes) { + $io->title('Channel messages:'); + $io->block($messageTypes); + } else { + $io->warning('No channel messages found.'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/SubscriberGetCommand.php b/src/Command/SubscriberGetCommand.php new file mode 100644 index 0000000..51f3d34 --- /dev/null +++ b/src/Command/SubscriberGetCommand.php @@ -0,0 +1,99 @@ +addArgument( + 'uuid', + InputArgument::OPTIONAL, + 'Get subscriber by its uuid', + ) + ->addOption( + 'order-status', + 's', + InputOption::VALUE_REQUIRED, + 'Filter by order status: ' . OrderStatus::formattedString(), + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + try { + $uuid = $input->getArgument('uuid'); + $orderStatus = $this->getOrderStatus($input->getOption('order-status')); + + if ($uuid) { + $subscribers = $this->subscriberRepository->findBy(['uuid' => $uuid]); + } else { + $subscribers = $this->subscriberRepository->getList($orderStatus); + } + + foreach ($subscribers as $subscriber) { + $io->title($subscriber->getUuid()->toRfc4122()); + $io->block([ + 'Order status: ' . $subscriber->getOrderStatus()->value, + 'Channel: ' . $subscriber->getChannelType(), + 'Message: ' . $subscriber->getChannelMessage(), + 'Added: ' . $subscriber->getCreatedAt()->format('Y-m-d H:i:s'), + 'Parameters: ' . json_encode($subscriber->getParams(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ]); + } + + if (! $subscribers) { + $io->warning('No subscribers found.'); + } + } catch (Throwable $exception) { + $io->error($exception->getMessage()); + return Command::FAILURE; + } + + + return Command::SUCCESS; + } + + private function getOrderStatus(?string $value): OrderStatus | null + { + if (! $value) { + return null; + } + + $orderStatus = OrderStatus::tryFrom($value); + if (!$orderStatus) { + throw new Exception("Invalid order status '$value'. Allowed values: " + . OrderStatus::formattedString()); + } + + return $orderStatus; + } +} diff --git a/src/Command/SubscriberRemoveCommand.php b/src/Command/SubscriberRemoveCommand.php new file mode 100644 index 0000000..4d75346 --- /dev/null +++ b/src/Command/SubscriberRemoveCommand.php @@ -0,0 +1,63 @@ +addArgument('uuid', InputArgument::REQUIRED, 'Subscriber uuid') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $uuid = $input->getArgument('uuid'); + + /** @var SubscriberRepository $subscriberRepository */ + $subscriberRepository = $this->entityManager->getRepository(Subscriber::class); + + try { + $subscriber = $subscriberRepository->findOneBy(['uuid' => $uuid]); + if (! $subscriber) { + throw new Exception("Subscriber \"$uuid\" not found."); + } + + $this->entityManager->remove($subscriber); + $this->entityManager->flush(); + + $io->success("Subscriber \"$uuid\" has been removed."); + } catch (Throwable $exception) { + $io->error($exception->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Api/V1/OrderController.php b/src/Controller/Api/V1/OrderController.php index fa4776a..f1ceab7 100644 --- a/src/Controller/Api/V1/OrderController.php +++ b/src/Controller/Api/V1/OrderController.php @@ -8,6 +8,7 @@ use App\Entity\Order; use App\Entity\OrderProduct; use App\Entity\OrderStatus; +use App\Event\OrderWasCreated; use App\Order\OrderTotalAmountCalculator; use App\Order\Workflow\OrderWorkflowFactory; use App\Payment\Common\Exception\PaymentGatewayIsNotRegisteredException; @@ -23,10 +24,15 @@ use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[Route('/api/v1/order', name: 'api_v1_order_')] class OrderController extends AbstractController { + public function __construct(private readonly EventDispatcherInterface $eventDispatcher) + { + } + #[OA\Post( description: 'Purchase order.', )] @@ -75,6 +81,8 @@ public function purchaseOrder( $entityManager->flush(); + $this->eventDispatcher->dispatch(new OrderWasCreated($order)); + $purchaseRequest = $paymentGateway->getPurchaseRequestBuilder()->build($order); $purchaseRequest->setCallbackUrl($this->generateUrl('api_v1_payment_callback_handler', [ diff --git a/src/DataFixtures/SubscriberFixture.php b/src/DataFixtures/SubscriberFixture.php new file mode 100644 index 0000000..d1f13e1 --- /dev/null +++ b/src/DataFixtures/SubscriberFixture.php @@ -0,0 +1,39 @@ +persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_RECEIVED)); + $manager->persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_FAILED)); + $manager->persist($this->makeHttpSubscriber(OrderStatus::PAYMENT_PENDING)); + + $manager->flush(); + } + + private function makeHttpSubscriber( + OrderStatus $orderStatus, + string $url = 'http://local-test.com', + string $httpMethod = 'POST', + ): Subscriber { + return $this->addHttpSubscriberAction->add( + orderStatus: $orderStatus, + url: $url, + channelMessage: 'simple', + httpMethod: $httpMethod, + ); + } +} diff --git a/src/Entity/Order.php b/src/Entity/Order.php index 93ee5ab..7aaf354 100644 --- a/src/Entity/Order.php +++ b/src/Entity/Order.php @@ -32,7 +32,7 @@ class Order private ?string $externalOrderId = null; #[ORM\Column(type: 'uuid', unique: true)] - private ?Uuid $uuid = null; + private ?Uuid $uuid; #[ORM\Column(length: 16)] private ?string $paymentGateway = null; diff --git a/src/Entity/OrderStatus.php b/src/Entity/OrderStatus.php index 8ce1ba4..6823171 100644 --- a/src/Entity/OrderStatus.php +++ b/src/Entity/OrderStatus.php @@ -4,12 +4,17 @@ namespace App\Entity; +use BackedEnum; + enum OrderStatus: string { case CREATED = 'created'; - case ERROR = 'error'; - case FINISHED = 'finished'; case PAYMENT_PENDING = 'payment_pending'; case PAYMENT_RECEIVED = 'payment_received'; case PAYMENT_FAILED = 'payment_failed'; + + public static function formattedString(string $delimiter = ', '): string + { + return implode($delimiter, array_map(fn (BackedEnum $enum): string => $enum->value, self::cases())); + } } diff --git a/src/Entity/Subscriber.php b/src/Entity/Subscriber.php new file mode 100644 index 0000000..63972b2 --- /dev/null +++ b/src/Entity/Subscriber.php @@ -0,0 +1,124 @@ +uuid = Uuid::v7(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function getHash(): ?string + { + return $this->hash; + } + + public function getChannelType(): ?string + { + return $this->channelType; + } + + public function getOrderStatus(): ?OrderStatus + { + return $this->orderStatus; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function getParams(): array + { + return $this->params; + } + + /** + * Generates a hash of order status, notification type and parameters. + * + * @return non-empty-string + */ + public function generateHash(): string + { + $this->hash = md5($this->channelType . $this->orderStatus->value . serialize($this->params)); + + return $this->hash; + } + + public function setChannelType(string $channelType): void + { + $this->channelType = $channelType; + } + + public function setOrderStatus(OrderStatus $orderStatus): void + { + $this->orderStatus = $orderStatus; + } + + public function setParams(array $params): void + { + $this->params = $params; + } + + public function setCreatedAtNow(): void + { + $this->createdAt = new DateTimeImmutable(); + } + + public function getChannelMessage(): ?string + { + return $this->channelMessage; + } + + public function setChannelMessage(string $channelMessage): void + { + $this->channelMessage = $channelMessage; + } +} diff --git a/src/Event/OrderStatusWasChanged.php b/src/Event/OrderStatusWasChanged.php new file mode 100644 index 0000000..0046af3 --- /dev/null +++ b/src/Event/OrderStatusWasChanged.php @@ -0,0 +1,21 @@ +subscriberRepository->getList($event->order->getStatus()); + + foreach ($subscribers as $subscriber) { + $message = new NotifySubscriber( + orderId: $event->order->getId(), + subscriberId: $subscriber->getId(), + paymentProcessingId: $event->paymentProcessing->getId(), + transactionId: $event->response->getTransactionId(), + responseMessage: $event->response->getMessage(), + ); + $this->messageBus->dispatch($message); + } + } +} diff --git a/src/Message/NotifySubscriber.php b/src/Message/NotifySubscriber.php new file mode 100644 index 0000000..011dac8 --- /dev/null +++ b/src/Message/NotifySubscriber.php @@ -0,0 +1,17 @@ +subscriberRepository->find($message->subscriberId); + if (! $subscriber) { + throw new EntityNotFoundException("Subscriber \"{$message->subscriberId}\" not found."); + } + + $paymentProcessing = $this->paymentProcessingRepository->find($message->paymentProcessingId); + if (! $paymentProcessing) { + throw new EntityNotFoundException("Payment processing \"{$message->paymentProcessingId}\" not found."); + } + + // TODO: check the order status and subscriber's expected order status. + + $this->sendSubscriberNotificationAction->sendNotification($subscriber, $paymentProcessing); + } catch (EntityNotFoundException|ChannelMessageNotRegistered|NotificationChannelNotRegisteredException $e) { + $this->logger->error($e->getMessage()); + return; + } + } +} diff --git a/src/Order/Workflow/OrderWorkflow.php b/src/Order/Workflow/OrderWorkflow.php index f38f95e..4e3eadf 100644 --- a/src/Order/Workflow/OrderWorkflow.php +++ b/src/Order/Workflow/OrderWorkflow.php @@ -7,22 +7,27 @@ use App\Entity\Order; use App\Entity\OrderStatus; use App\Entity\PaymentProcessing; +use App\Event\OrderStatusWasChanged; use App\Payment\Common\Message\RequestInterface; use App\Payment\Common\Message\ResponseInterface; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -class OrderWorkflow +final readonly class OrderWorkflow implements OrderWorkflowInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly Order $order, - private readonly RequestInterface | null $request, - private readonly ResponseInterface | null $response, + private EntityManagerInterface $em, + private EventDispatcherInterface $eventDispatcher, + private Order $order, + private RequestInterface | null $request, + private ResponseInterface | null $response, ) { } public function setState(OrderStatus $orderStatus): void { + $previousStatus = $this->order->getStatus(); + $this->order->setStatus($orderStatus); $this->em->persist($this->order); @@ -34,5 +39,19 @@ public function setState(OrderStatus $orderStatus): void $this->em->persist($paymentProcessing); $this->em->flush(); + + $this->dispatchOrderStatusEvent($previousStatus, $paymentProcessing); + } + + private function dispatchOrderStatusEvent(OrderStatus $previousStatus, PaymentProcessing $paymentProcessing): void + { + $event = new OrderStatusWasChanged( + previousStatus: $previousStatus, + order: $this->order, + paymentProcessing: $paymentProcessing, + response: $this->response, + ); + + $this->eventDispatcher->dispatch($event); } } diff --git a/src/Order/Workflow/OrderWorkflowFactory.php b/src/Order/Workflow/OrderWorkflowFactory.php index 185c716..1fb0a0f 100644 --- a/src/Order/Workflow/OrderWorkflowFactory.php +++ b/src/Order/Workflow/OrderWorkflowFactory.php @@ -8,11 +8,13 @@ use App\Payment\Common\Message\RequestInterface; use App\Payment\Common\Message\ResponseInterface; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class OrderWorkflowFactory { public function __construct( protected readonly EntityManagerInterface $em, + protected readonly EventDispatcherInterface $eventDispatcher, ) { } @@ -20,7 +22,7 @@ public function createFromContext( Order $order, RequestInterface | null $request = null, ResponseInterface | null $response = null, - ): OrderWorkflow { - return new OrderWorkflow($this->em, $order, $request, $response); + ): OrderWorkflowInterface { + return new OrderWorkflow($this->em, $this->eventDispatcher, $order, $request, $response); } } diff --git a/src/Order/Workflow/OrderWorkflowInterface.php b/src/Order/Workflow/OrderWorkflowInterface.php new file mode 100644 index 0000000..01c9fef --- /dev/null +++ b/src/Order/Workflow/OrderWorkflowInterface.php @@ -0,0 +1,12 @@ + + */ +class SubscriberRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Subscriber::class); + } + + public function hasSubscriber(string $hash): bool + { + $result = $this->createQueryBuilder('s') + ->where('s.hash = :hash') + ->setParameter('hash', $hash) + ->select('1') + ->getQuery() + ->getOneOrNullResult(); + + return (bool) $result; + } + + /** + * @param OrderStatus|null $orderStatus Filter by order status. + * @return Subscriber[] + */ + public function getList(?OrderStatus $orderStatus = null): array + { + $query = $this->createQueryBuilder('s'); + if ($orderStatus) { + $query->where('s.orderStatus = :orderStatus'); + $query->setParameter('orderStatus', $orderStatus); + } + + return $query->getQuery()->getResult(); + } + + // /** + // * @return Subscriber[] Returns an array of Subscriber objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Subscriber + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Subscriber/Action/AddHttpSubscriberAction.php b/src/Subscriber/Action/AddHttpSubscriberAction.php new file mode 100644 index 0000000..b32cba6 --- /dev/null +++ b/src/Subscriber/Action/AddHttpSubscriberAction.php @@ -0,0 +1,45 @@ + $url, + 'method' => $httpMethod, + ]; + + return $this->addSubscriber( + orderStatus: $orderStatus, + channelType: 'http', + channelMessage: $channelMessage, + params: $params, + ); + } +} diff --git a/src/Subscriber/Action/AddSubscriberAction.php b/src/Subscriber/Action/AddSubscriberAction.php new file mode 100644 index 0000000..fd14bfa --- /dev/null +++ b/src/Subscriber/Action/AddSubscriberAction.php @@ -0,0 +1,79 @@ +validateChannelType($channelType); + $this->validateChannelMessage($channelMessage); + + $subscriber = new Subscriber(); + $subscriber->setOrderStatus($orderStatus); + $subscriber->setChannelType($channelType); + $subscriber->setParams($params); + $subscriber->setChannelMessage($channelMessage); + $subscriber->setCreatedAtNow(); + $subscriber->generateHash(); + + // Subscriber must have unique order status, notification type and parameters. + $exists = $this->subscriberRepository->hasSubscriber($subscriber->getHash()); + if ($exists) { + throw new SubscriberExistsException(); + } + + $this->em->persist($subscriber); + $this->em->flush(); + + return $subscriber; + } + + /** + * @throws NotificationChannelNotRegisteredException + */ + protected function validateChannelType(string $channelType): void + { + if (! in_array($channelType, $this->notificationChannelCollection->getNotificationChannelTypes())) { + throw new NotificationChannelNotRegisteredException($channelType); + } + } + + /** + * @throws ChannelMessageNotRegistered + */ + protected function validateChannelMessage(string $channelMessage): void + { + if (! in_array($channelMessage, $this->notificationChannelCollection->getMessageTypes())) { + throw new ChannelMessageNotRegistered($channelMessage); + } + } +} diff --git a/src/Subscriber/Action/SendSubscriberNotificationAction.php b/src/Subscriber/Action/SendSubscriberNotificationAction.php new file mode 100644 index 0000000..35990cd --- /dev/null +++ b/src/Subscriber/Action/SendSubscriberNotificationAction.php @@ -0,0 +1,34 @@ +notificationChannelCollection->getNotificationChannel($subscriber->getChannelType()); + $message = $this->notificationChannelCollection->getMessage($subscriber->getChannelMessage()); + + $message->setPaymentProcessing($paymentProcessing); + + $channel->send($message, $subscriber->getParams()); + } +} diff --git a/src/Subscriber/Channel/AbstractChannelMessage.php b/src/Subscriber/Channel/AbstractChannelMessage.php new file mode 100644 index 0000000..564fa55 --- /dev/null +++ b/src/Subscriber/Channel/AbstractChannelMessage.php @@ -0,0 +1,24 @@ +paymentProcessing = $paymentProcessing; + } + + public function getPaymentProcessing(): PaymentProcessing + { + return $this->paymentProcessing + ?? throw new ChannelMessageException('Payment processing response not set.'); + } +} diff --git a/src/Subscriber/Channel/ChannelMessageInterface.php b/src/Subscriber/Channel/ChannelMessageInterface.php new file mode 100644 index 0000000..e2705fc --- /dev/null +++ b/src/Subscriber/Channel/ChannelMessageInterface.php @@ -0,0 +1,54 @@ + $this->getPaymentProcessing()->getOrder()->getExternalOrderId(), + * ]); + * } + * } + * + * In `config/services.yml` the custom channel: + * + * services: + * App\Subscriber\Channel\JsonChannelMessage: + * tags: + * - { name: 'app.subscriber.message', type: 'json' } + * + * Then, the `json` custom message will be available in `subscriber:channels` command output. + */ +interface ChannelMessageInterface +{ + public function setPaymentProcessing(PaymentProcessing $paymentProcessing): void; + + /** + * @throws ChannelMessageException When payment processing is not set. + */ + public function getPaymentProcessing(): PaymentProcessing; + + /** + * Returns a data that will be transmitted by a channel. + * + * @throws ChannelMessageException + */ + public function getData(): mixed; +} diff --git a/src/Subscriber/Channel/HttpNotificationChannel.php b/src/Subscriber/Channel/HttpNotificationChannel.php new file mode 100644 index 0000000..b8436d6 --- /dev/null +++ b/src/Subscriber/Channel/HttpNotificationChannel.php @@ -0,0 +1,53 @@ +getHttpMethod($params); + + $this->httpClient->request($method, $url, [ + 'json' => $message->getData(), + ]); + } + + /** + * @param array{method: string} $params + * @throws NotificationChannelException + */ + private function getHttpMethod(array $params): string + { + $method = (string) ($params['method'] ?? self::DEFAULT_HTTP_METHOD); + + if (! in_array($method, self::HTTP_METHODS)) { + throw new NotificationChannelException("Http method \"$method\" is not allowed."); + } + + return $method; + } +} diff --git a/src/Subscriber/Channel/NotificationChannelCollection.php b/src/Subscriber/Channel/NotificationChannelCollection.php new file mode 100644 index 0000000..1de0ba6 --- /dev/null +++ b/src/Subscriber/Channel/NotificationChannelCollection.php @@ -0,0 +1,66 @@ + + */ + private readonly array $channels; + + private readonly array $messages; + + public function __construct( + #[TaggedIterator('app.subscriber.channel', indexAttribute: 'type')] + iterable $channels, + + #[TaggedIterator('app.subscriber.message', indexAttribute: 'type')] + iterable $messages, + ) { + $this->channels = iterator_to_array($channels); + $this->messages = iterator_to_array($messages); + } + + /** + * @param non-empty-string $type A notification channel type. + * @throws NotificationChannelNotRegisteredException + */ + public function getNotificationChannel(string $type): NotificationChannelInterface + { + return $this->channels[$type] + ?? throw new NotificationChannelNotRegisteredException($type); + } + + /** + * @return string[] + */ + public function getNotificationChannelTypes(): array + { + return array_keys($this->channels); + } + + /** + * @param non-empty-string $type A channel message type. + * @throws ChannelMessageNotRegistered + */ + public function getMessage(string $type): ChannelMessageInterface + { + return $this->messages[$type] + ?? throw new ChannelMessageNotRegistered($type); + } + + /** + * @return string[] + */ + public function getMessageTypes(): array + { + return array_keys($this->messages); + } +} diff --git a/src/Subscriber/Channel/NotificationChannelInterface.php b/src/Subscriber/Channel/NotificationChannelInterface.php new file mode 100644 index 0000000..185ac10 --- /dev/null +++ b/src/Subscriber/Channel/NotificationChannelInterface.php @@ -0,0 +1,48 @@ +sms->send($message->getData(), $params['phone_number']); + * } + * } + * + * In `config/services.yml` the custom channel: + * + * services: + * App\Subscriber\Channel\MyCustomSmsChannel: + * tags: + * - { name: 'app.subscriber.channel', type: 'sms' } + * + * Then, the `sms` custom channel will be available in `subscriber:channels` command output under `channels`. + * The adding subscriber command should be implemented too, in order to add subscribers. + */ +interface NotificationChannelInterface +{ + /** + * @param array $params Channel parameters. + * + * @throws NotificationChannelException + * @throws ChannelMessageException + */ + public function send(ChannelMessageInterface $message, array $params): void; +} diff --git a/src/Subscriber/Channel/SimpleArrayChannelMessage.php b/src/Subscriber/Channel/SimpleArrayChannelMessage.php new file mode 100644 index 0000000..1601c4d --- /dev/null +++ b/src/Subscriber/Channel/SimpleArrayChannelMessage.php @@ -0,0 +1,34 @@ +getPaymentProcessing(); + $order = $paymentProcessing->getOrder(); + + return [ + 'order_num' => $order->getExternalOrderId(), + 'order_status' => $order->getStatus()->value, + 'success' => $paymentProcessing->getResponseSuccess(), + // TODO: a column in payment_processing should be added. + //'transaction_id' => $this->getResponse()->getTransactionId(), + 'response' => $paymentProcessing->getResponseData(), + ]; + } +} diff --git a/src/Subscriber/Exception/ChannelMessageException.php b/src/Subscriber/Exception/ChannelMessageException.php new file mode 100644 index 0000000..e43f8b0 --- /dev/null +++ b/src/Subscriber/Exception/ChannelMessageException.php @@ -0,0 +1,11 @@ +channelMessageType}\" not registered."); + } +} diff --git a/src/Subscriber/Exception/NotificationChannelException.php b/src/Subscriber/Exception/NotificationChannelException.php new file mode 100644 index 0000000..4f58466 --- /dev/null +++ b/src/Subscriber/Exception/NotificationChannelException.php @@ -0,0 +1,11 @@ +notificationChannelType\" is not registered."); + } +} diff --git a/src/Subscriber/Exception/SubscriberExistsException.php b/src/Subscriber/Exception/SubscriberExistsException.php new file mode 100644 index 0000000..364d96c --- /dev/null +++ b/src/Subscriber/Exception/SubscriberExistsException.php @@ -0,0 +1,12 @@ +application = new Application(self::$kernel); + } +} diff --git a/tests/Application/Command/PaymentGatewaysCommandTest.php b/tests/Application/Command/PaymentGatewaysCommandTest.php index f555c62..5699841 100644 --- a/tests/Application/Command/PaymentGatewaysCommandTest.php +++ b/tests/Application/Command/PaymentGatewaysCommandTest.php @@ -10,20 +10,10 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; -final class PaymentGatewaysCommandTest extends KernelTestCase +final class PaymentGatewaysCommandTest extends AbstractCommandTest { use WithPaymentGateway; - private Application $application; - - protected function setUp(): void - { - parent::setUp(); - - self::bootKernel(); - $this->application = new Application(self::$kernel); - } - public function testCommandExecuteIsSuccessful(): void { $registry = new PaymentGatewayRegistry([]); diff --git a/tests/Application/Command/Subscribers/AddHttpCommandTest.php b/tests/Application/Command/Subscribers/AddHttpCommandTest.php new file mode 100644 index 0000000..06aa56f --- /dev/null +++ b/tests/Application/Command/Subscribers/AddHttpCommandTest.php @@ -0,0 +1,69 @@ +faker()->url(); + $httpMethod = 'PUT'; + $orderStatus = OrderStatus::PAYMENT_PENDING; + + $channelCollection = $this->createMock(NotificationChannelCollection::class); + $channelCollection + ->expects($this->once()) + ->method('getNotificationChannelTypes') + ->willReturn(['http']); + $channelCollection + ->expects($this->once()) + ->method('getMessageTypes') + ->willReturn(['test']); + + $this->getContainer()->set(NotificationChannelCollection::class, $channelCollection); + + $command = $this->application->find('subscriber:add-http'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'url' => $url, + '--channel-message' => 'test', + '--order-status' => $orderStatus->value, + '--http-method' => $httpMethod, + ]); + + $commandTester->assertCommandIsSuccessful($commandTester->getDisplay()); + + $display = $commandTester->getDisplay(); + + $this->assertEquals( + 1, + preg_match('/Subscriber "(.+)" has been added/', $display, $matches), + message: 'Cannot get subscriber uuid from command output.' + ); + + $uuid = $matches[1]; + + $subscriberRepository = $this->getContainer()->get(SubscriberRepository::class); + $subscriber = $subscriberRepository->findOneBy(['uuid' => $uuid]); + + $this->assertNotEmpty($subscriber, 'Subscriber not found'); + + $this->assertEquals($orderStatus, $subscriber->getOrderStatus()); + + $this->assertEquals([ + 'url' => $url, + 'method' => $httpMethod, + ], $subscriber->getParams()); + } +} diff --git a/tests/Application/Command/Subscribers/ChannelsCommandTest.php b/tests/Application/Command/Subscribers/ChannelsCommandTest.php new file mode 100644 index 0000000..6d51616 --- /dev/null +++ b/tests/Application/Command/Subscribers/ChannelsCommandTest.php @@ -0,0 +1,36 @@ +createMock(NotificationChannelCollection::class); + $channelCollection + ->expects($this->once()) + ->method('getNotificationChannelTypes') + ->willReturn(['test-channel']); + $channelCollection + ->expects($this->once()) + ->method('getMessageTypes') + ->willReturn(['test-message']); + + $this->getContainer()->set(NotificationChannelCollection::class, $channelCollection); + + $command = $this->application->find('subscriber:channels'); + $commandTester = new CommandTester($command); + $commandTester->execute([]); + + $display = $commandTester->getDisplay(); + + $this->assertStringContainsString('test-channel', $display, 'Channel type is missing in command output.'); + $this->assertStringContainsString('test-message', $display, 'Message type is missing in command output.'); + } +} diff --git a/tests/Application/Command/Subscribers/RemoveCommandTest.php b/tests/Application/Command/Subscribers/RemoveCommandTest.php new file mode 100644 index 0000000..a356c48 --- /dev/null +++ b/tests/Application/Command/Subscribers/RemoveCommandTest.php @@ -0,0 +1,38 @@ +getContainer()->get(SubscriberRepository::class); + + $subscriber = $subscriberRepository->findOneBy([ + 'orderStatus' => OrderStatus::PAYMENT_PENDING, + ]); + + $command = $this->application->find('subscriber:remove'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'uuid' => $subscriber->getUuid(), + ]); + + $commandTester->assertCommandIsSuccessful(); + + $subscriber = $subscriberRepository->findOneBy(['uuid' => $subscriber->getUuid()]); + + $this->assertEmpty($subscriber); + } +} diff --git a/tests/Application/Event/NotifySubscribersTest.php b/tests/Application/Event/NotifySubscribersTest.php new file mode 100644 index 0000000..df2449c --- /dev/null +++ b/tests/Application/Event/NotifySubscribersTest.php @@ -0,0 +1,81 @@ +eventDispatcher = new EventDispatcher(); + $this->entityManager = $kernel->getContainer()->get('doctrine')->getManager(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->close(); + $this->entityManager = null; + } + + public function testEventOrderStatusWasChangedSendsMessage(): void + { + $orderRepository = self::getContainer()->get(OrderRepository::class); + $listener = self::getContainer()->get(NotifySubscribersListener::class); + + $this->eventDispatcher->addListener('onOrderStatusWasChanged', [$listener, 'onOrderStatusWasChanged']); + + // check fixtures for existing order. + $order = $orderRepository->findOneBy([ + 'externalOrderId' => '111-test', + ]); + // Because orders in fixtures are with "created" status, + // set manually one of the status which subscribers expecting (see subscriber fixtures). + $order->setStatus(OrderStatus::PAYMENT_RECEIVED); + + $response = $this->createStub(ResponseInterface::class); + $response->method('isSuccessful')->willReturn(true); + $response->method('jsonSerialize')->willReturn([ + 'message' => 'ok', + ]); + + $paymentProcessing = PaymentProcessing::create($order, null, $response); + $this->entityManager->persist($paymentProcessing); + $this->entityManager->flush(); + + $event = new OrderStatusWasChanged( + previousStatus: OrderStatus::CREATED, + order: $order, + paymentProcessing: $paymentProcessing, + response: $response, + ); + + $this->eventDispatcher->dispatch($event, 'onOrderStatusWasChanged'); + + /** @var InMemoryTransport $transport */ + $transport = $this->getContainer()->get('messenger.transport.async'); + + $this->assertCount(1, $transport->getSent()); + } +} diff --git a/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php b/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php new file mode 100644 index 0000000..2a4c95f --- /dev/null +++ b/tests/Unit/Subscriber/AddHttpSubscriberActionTest.php @@ -0,0 +1,122 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid url'); + + $action = new AddHttpSubscriberAction( + $this->createStub(EntityManagerInterface::class), + $this->createStub(SubscriberRepository::class), + $this->createStub(NotificationChannelCollection::class), + ); + + $action->add( + orderStatus: OrderStatus::PAYMENT_FAILED, + url: '11111', + channelMessage: 'test', + ); + } + + public function testAddSubscriber(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->once()) + ->method('persist'); + $entityManager + ->expects($this->once()) + ->method('flush'); + + $repository = $this->createMock(SubscriberRepository::class); + $repository + ->expects($this->once()) + ->method('hasSubscriber') + ->willReturn(false); + + $channelCollection = $this->createMock(NotificationChannelCollection::class); + $channelCollection + ->expects($this->once()) + ->method('getNotificationChannelTypes') + ->willReturn(['http']); + $channelCollection + ->expects($this->once()) + ->method('getMessageTypes') + ->willReturn(['test']); + + $action = new AddHttpSubscriberAction($entityManager, $repository, $channelCollection); + + $action->add(OrderStatus::PAYMENT_RECEIVED, $this->faker()->url(), 'test'); + } + + public function testCannotAddSubscriberWithSameParameters(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->never()) + ->method('persist'); + $entityManager + ->expects($this->never()) + ->method('flush'); + + $repository = $this->createMock(SubscriberRepository::class); + $repository + ->expects($this->once()) + ->method('hasSubscriber') + ->willReturn(true); + + $channelCollection = $this->createMock(NotificationChannelCollection::class); + $channelCollection + ->expects($this->once()) + ->method('getNotificationChannelTypes') + ->willReturn(['http']); + $channelCollection + ->expects($this->once()) + ->method('getMessageTypes') + ->willReturn(['test1']); + + $action = new AddHttpSubscriberAction($entityManager, $repository, $channelCollection); + + $this->expectException(SubscriberExistsException::class); + $this->expectExceptionMessage('Subscriber with such parameters already exists'); + + $action->add(OrderStatus::PAYMENT_RECEIVED, $this->faker()->url(), 'test1'); + } + + public function testHttpMethodMustBeAllowed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid http method "TEST", must be one of [POST,PUT,PATCH]'); + + $action = new AddHttpSubscriberAction( + $this->createStub(EntityManagerInterface::class), + $this->createStub(SubscriberRepository::class), + $this->createStub(NotificationChannelCollection::class), + ); + + $action->add( + orderStatus: OrderStatus::PAYMENT_FAILED, + url: $this->faker()->url(), + channelMessage: 'foobar', + httpMethod: 'TEST', + ); + } +} diff --git a/tests/Unit/Subscriber/NotificationChannelCollectionTest.php b/tests/Unit/Subscriber/NotificationChannelCollectionTest.php new file mode 100644 index 0000000..a7d7026 --- /dev/null +++ b/tests/Unit/Subscriber/NotificationChannelCollectionTest.php @@ -0,0 +1,73 @@ +createStub(NotificationChannelInterface::class); + $type2 = $this->createStub(NotificationChannelInterface::class); + + $collection = new NotificationChannelCollection( + channels: [ + 'a' => $type1, + 'b' => $type2, + ], + messages: [], + ); + + $this->assertEquals(['a', 'b'], $collection->getNotificationChannelTypes()); + } + + public function testAvailableMessageTypes(): void + { + $type1 = $this->createStub(ChannelMessageInterface::class); + $type2 = $this->createStub(ChannelMessageInterface::class); + + $collection = new NotificationChannelCollection( + channels: [], + messages: [ + 'x' => $type1, + 'y' => $type2, + ], + ); + + $this->assertEquals(['x', 'y'], $collection->getMessageTypes()); + } + + public function testThrowsChannelNotRegisteredException(): void + { + $collection = new NotificationChannelCollection( + channels: [], + messages: [], + ); + + $this->expectException(NotificationChannelNotRegisteredException::class); + $this->expectExceptionMessage('Subscriber notification channel "aaa" is not registered'); + + $collection->getNotificationChannel('aaa'); + } + + public function testThrowsMessageNotRegisteredException(): void + { + $collection = new NotificationChannelCollection( + channels: [], + messages: [], + ); + + $this->expectException(ChannelMessageNotRegistered::class); + $this->expectExceptionMessage('Channel message type "msg" not registered'); + + $collection->getMessage('msg'); + } +} diff --git a/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php b/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php new file mode 100644 index 0000000..3d6913e --- /dev/null +++ b/tests/Unit/Subscriber/SendSubscriberNotificationActionTest.php @@ -0,0 +1,61 @@ + 1, + 'b' => 2, + ]; + + $subscriber = new Subscriber(); + $subscriber->setChannelType('in-test'); + $subscriber->setChannelMessage('test-message'); + $subscriber->setParams($params); + + $message = $this->createMock(ChannelMessageInterface::class); + $message + ->expects($this->once()) + ->method('setPaymentProcessing'); + + $channel = $this->createMock(NotificationChannelInterface::class); + $channel + ->expects($this->once()) + ->method('send') + ->with($message, $params); + + $channelCollection = $this->createMock(NotificationChannelCollection::class); + $channelCollection + ->expects($this->once()) + ->method('getNotificationChannel') + ->with('in-test') + ->willReturn($channel); + $channelCollection + ->expects($this->once()) + ->method('getMessage') + ->with('test-message') + ->willReturn($message); + + $order = new Order(); + $paymentProcessing = new PaymentProcessing(); + $response = $this->createStub(ResponseInterface::class); + + $action = new SendSubscriberNotificationAction($channelCollection); + $action->sendNotification($subscriber, $paymentProcessing); + } +} diff --git a/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php b/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php new file mode 100644 index 0000000..27851eb --- /dev/null +++ b/tests/Unit/Subscriber/SimpleArrayChannelMessageTest.php @@ -0,0 +1,50 @@ +setExternalOrderId($this->faker()->md5()); + $order->setStatus(OrderStatus::PAYMENT_FAILED); + + $response = $this->createStub(ResponseInterface::class); + $response->method('isSuccessful')->willReturn(false); + $response->method('jsonSerialize')->willReturn([ + 'a' => 1, + 'b' => 2, + ]); + + $paymentProcessing = PaymentProcessing::create($order, null, $response); + + $message->setPaymentProcessing($paymentProcessing); + + $data = $message->getData(); + + $this->assertEquals([ + 'order_num' => $order->getExternalOrderId(), + 'order_status' => 'payment_failed', + 'success' => false, + 'response' => [ + 'a' => 1, + 'b' => 2, + ], + ], $data); + } +}