billwerk_subscriptions-1.x-dev/src/EventSubscriber/BillwerkWebhookEventSubscriber.php
src/EventSubscriber/BillwerkWebhookEventSubscriber.php
<?php
declare(strict_types=1);
namespace Drupal\billwerk_subscriptions\EventSubscriber;
use Drupal\billwerk_subscriptions\BillwerkDataObjectFactory;
use Drupal\billwerk_subscriptions\Event\BillwerkWebhookEvent;
use Drupal\billwerk_subscriptions\Event\SubscriberContractChangedEvent;
use Drupal\billwerk_subscriptions\Event\SubscriberCustomerChangedEvent;
use Drupal\billwerk_subscriptions\Exception\CustomerExternalIdEmptyException;
use Drupal\billwerk_subscriptions\Exception\WebhookException;
use Drupal\billwerk_subscriptions\LogHelper;
use Drupal\billwerk_subscriptions\Subscriber;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Subscriber to incoming Billwerk webhook calls.
*
* As the individual logic should not be implemented here in the general
* module, this is just used to trigger more specific events and handle
* "Test" webhook calls.
*/
final class BillwerkWebhookEventSubscriber implements EventSubscriberInterface {
/**
* The constructor.
*
* @param \Drupal\billwerk_subscriptions\BillwerkDataObjectFactory $billwerkDataObjectFactory
* The Billwerk Data Object Factory.
* @param \Drupal\billwerk_subscriptions\LogHelper $logHelper
* The Log Helper.
* @param \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher
* The Event Dispatcher.
*/
public function __construct(
protected readonly BillwerkDataObjectFactory $billwerkDataObjectFactory,
protected readonly LogHelper $logHelper,
protected readonly EventDispatcherInterface $eventDispatcher,
) {
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// NOTE: We do not list or subscribe to all available webhooks here.
// Just the ones that are likely to be relevant.
// Of course all available Billwerk webhooks can be used like this.
// The webhooks documentation can be found here:
// @see https://docu.billwerk.plus/api/premium-enterprise/en/webhooks.html
return [
BillwerkWebhookEvent::EVENT_NAME_PREFIX . 'ContractChanged' => 'onContractChanged',
BillwerkWebhookEvent::EVENT_NAME_PREFIX . 'CustomerChanged' => 'onCustomerChanged',
BillwerkWebhookEvent::EVENT_NAME_PREFIX . 'Test' => 'onTest',
];
}
/**
* Customer Changed.
*
* Sent whenever the base information of a customer has changed because it was
* modified via the customer portal.
*
* Typical Scenario:
* Usually, you want to listen to this hook to keep your customer data in sync
* between your system and billwerk. To do so, fetch the customer by id and
* apply all necessary changes on your side.
*
* Example:
* ```
* {
* "CustomerId": "62a86d3a826fa0a7d0d07032",
* "Event": "CustomerChanged",
* "EntityId": "62148e3c0c14e1609e9ca5c1"
* }
* ```
*
* @param \Drupal\billwerk_subscriptions\Event\BillwerkWebhookEvent $event
* The BillwerkWebhook event.
*
* @see https://docu.billwerk.plus/api/premium-enterprise/en/webhooks/customer-and-contract/customer-changed.html
*/
public function onCustomerChanged(BillwerkWebhookEvent $event): void {
// We don't implement specific logic here, instead we let submodules do that
// by the specific event:
$customerId = $event->extractDataValue('CustomerId');
if (empty($customerId)) {
// This webhook should always provide a ContractId:
throw new WebhookException("Expected CustomerId to be given, but CustomerId was empty: \"{$customerId}\"");
}
try {
$billwerkCustomer = $this->billwerkDataObjectFactory->billwerkLoadBillwerkCustomer($customerId);
}
catch (CustomerExternalIdEmptyException $e) {
// This customer has no ExternalId, which means has no Drupal account.
// Such accounts are not treated as Drupal-relevant customers,
// so we return here.
return;
}
// We only handle customers with an ExternalCustomerId (= Drupal UID) set.
$externalCustomerId = $billwerkCustomer->getExternalCustomerId();
// We need to cast the $externalCustomerId to int here as Drupal UID.
// But we ensure it's not UID=0 afterwards.
if (!empty($externalCustomerId) && !empty((int) $externalCustomerId)) {
$subscriber = Subscriber::loadByDrupalUid((int) $externalCustomerId);
if (!empty($subscriber)) {
$subscriberCustomerChangedEvent = new SubscriberCustomerChangedEvent($subscriber);
$this->eventDispatcher->dispatch($subscriberCustomerChangedEvent);
}
else {
// If an external customer id is set, we expect a Drupal user to exist
// for it, so throw an Exception, if none could be loaded.
throw new WebhookException("Subscriber user account for Billwerk Customer ID #{$customerId} with ExternalCustomerId #{$externalCustomerId} could not be found. No such user exists. Could not update the user subscription.");
}
}
}
/**
* Contract Changed.
*
* Sent whenever the state of a contract has changed. There are various
* reasons why the contract could have changed, e.g.:
* - The trial has ended
* - The contract has ended
* - The contract was up- or downgraded
* - A component subscription was added or changed.
*
* Typical Scenario:
* You want to listen to this hook to make sure your customers get what they
* ordered (and only what they paid for). Together with Contract Created, this
* is the most important hook. To find out what the current state of the
* contract is, simply fetch the referenced contract by id and make sure to
* configure your service to deliver what is configured in the contract.
*
* Contract Change types
* - Signup
* - Upgrade
* - TrialEndChange
* - ComponentSubscriptionChange
* - DiscountSubscriptionChange
* - Timebased
* - EndContract, Annulation
* - Pause, Resume
* - ExternalSubscriptionSync
* As multiple changes can be performed via an order and an order will lead to
* an Upgrade contract change - it is not enough to rely on a single type like
* e.g. ComponentSubscriptionChange. The type is intended to be used to
* opt-out early scenarios.
*
* All contract change webhooks contain the fields ContractId,
* ContractChangedType and ContractChangeId. To access more details about what
* has happened in the contract, call the REST API to get all the details of
* the contract change.
*
* Example values:
* ```
* {
* "ContractId": "645b429bb73d36f442b223df",
* "CustomerId": "645b429bb73d36f442b223db",
* "ExternalCustomerId": "249969",
* "ContractChangeId": "645b42d7f85fee194bce8990",
* "ContractChangeType": "Upgrade",
* "Event": "ContractChanged",
* "EntityId": "63b2d4405b49105c19fa7714"
* }
* ```
*
* @param \Drupal\billwerk_subscriptions\Event\BillwerkWebhookEvent $event
* The BillwerkWebhook event.
*
* @see https://docu.billwerk.plus/api/premium-enterprise/en/webhooks/customer-and-contract/contract-changed.html
*/
public function onContractChanged(BillwerkWebhookEvent $event): void {
// We don't implement specific logic here, instead we let submodules do that
// by the specific event:
$contractId = $event->extractDataValue("ContractId");
if (empty($contractId)) {
// This webhook should always provide a ContractId:
throw new WebhookException("Expected ContractId to be given, but ContractId was empty: \"{$contractId}\"");
}
// !IMPORTANT! Be careful with $contractChangeType and read the
// method comment and the Billwerk Documentation, as it doesn't works
// as one would expect.
$contractChangeType = $event->extractDataValue("ContractChangeType");
if (in_array($contractChangeType, [SubscriberContractChangedEvent::CONTRACT_CHANGE_TYPE_SIGNUP])) {
// Skip for the listed change types as we don't want to trigger the event
// in these cases.
// Skipping is in the way the documentation allows us to use the
// $contractChangeType!
$this->logHelper->debug("Skipping for explicitly unhandled ContractChangeType: \"{$contractChangeType}\".", [], $event->getData(), __CLASS__);
return;
}
// Dispatch the specific event:
$subscriber = Subscriber::loadByContractId($contractId);
if (empty($subscriber)) {
// We should not expect a contract to be present in Drupal for each
// Billwerk contract. So we shouldn't throw an exception here but just
// return if not found.
// So we just log this for debugging:
$this->logHelper->debug("No contract with ID: \"{$contractId}\" could be found.", [], $event->getData(), __CLASS__);
return;
}
$subscriberContractChangedEvent = new SubscriberContractChangedEvent($subscriber, $contractChangeType);
$this->eventDispatcher->dispatch($subscriberContractChangedEvent);
}
/**
* Handles a test event.
*/
public function onTest(BillwerkWebhookEvent $event): void {
// Just write a log entry to show it works as expected.
$this->logHelper->debug('Billwerk Test Webhook received!', [], $event->getData(), __CLASS__);
}
}
