billwerk_subscriptions-1.x-dev/src/Subscriber.php
src/Subscriber.php
<?php
declare(strict_types=1);
namespace Drupal\billwerk_subscriptions;
use Drupal\Component\Serialization\Json;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\billwerk_subscriptions\DataObject\BillwerkContract;
use Drupal\billwerk_subscriptions\DataObject\BillwerkCustomer;
use Drupal\billwerk_subscriptions\Event\SubscriberOrderCreateEvent;
use Drupal\billwerk_subscriptions\Event\SubscriberRefreshUserEvent;
use Drupal\billwerk_subscriptions\Exception\SubscriberException;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* The billwerk subscriber class.
*
* Wraps a Drupal user entity and adds subscription-related functionality to
* this "Subscriber". This makes it easier to interact with subscription
* functionalities for this user.
*/
class Subscriber {
use DependencySerializationTrait;
const USER_FIELD_BILLWERK_CONTRACT_ID = 'field_billwerk_contract_id';
/**
* The API service for interacting with Billwerk.
*
* @var \Drupal\billwerk_subscriptions\Api
*/
protected readonly Api $api;
/**
* The event dispatcher service for handling events.
*
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected readonly EventDispatcherInterface $eventDispatcher;
/**
* The factory service for creating Billwerk data objects.
*
* @var \Drupal\billwerk_subscriptions\BillwerkDataObjectFactory
*/
protected readonly BillwerkDataObjectFactory $billwerkDataObjectFactory;
/**
* The helper service for logging.
*
* @var \Drupal\billwerk_subscriptions\LogHelper
*/
protected readonly LogHelper $logHelper;
/**
* Constructs a Subscriber object.
*/
protected function __construct(
protected readonly UserInterface $user,
protected ?BillwerkContract $billwerkContract,
) {
// Initialize services (we should, but can't use DI here):
// @phpstan-ignore-next-line
$this->api = \Drupal::service("billwerk_subscriptions.api");
// @phpstan-ignore-next-line
$this->eventDispatcher = \Drupal::service("event_dispatcher");
// @phpstan-ignore-next-line
$this->billwerkDataObjectFactory = \Drupal::service("billwerk_subscriptions.billwerk_data_object_factory");
// @phpstan-ignore-next-line
$this->logHelper = \Drupal::service("billwerk_subscriptions.log_helper");
}
/**
* Load the subscriber by the Drupal User object.
*
* @param \Drupal\user\UserInterface $user
* The Drupal user object.
*
* @return \Drupal\billwerk_subscriptions\Subscriber
* The subscriber object.
*/
public static function load(UserInterface $user): self {
// Lazy-initialize the Subscriber, only initialize the contract if needed.
// This allows us to use Subscriber for users before they have a
// subscription and only fetch the subscription data if needed.
if ($user->isAnonymous()) {
throw new SubscriberException("The anonymous user may never be a subscriber!");
}
return new self($user, NULL);
}
/**
* Load the subscriber by its Drupal UID (ExternalCustomerId at Billwerk).
*
* @param int $uid
* The Drupal user ID.
*
* @return \Drupal\billwerk_subscriptions\Subscriber
* The subscriber object.
*/
public static function loadByDrupalUid(int $uid): ?self {
$user = User::load($uid);
if ($user !== NULL) {
return self::load($user);
}
else {
return NULL;
}
}
/**
* Load the subscriber by the Drupal user's email address.
*
* @param string $mail
* The email address.
*
* @return Subscriber|null
* The subscriber object.
*/
public static function loadByMail(string $mail): ?self {
$user = user_load_by_mail($mail);
if ($user !== NULL) {
return self::load($user);
}
else {
return NULL;
}
}
/**
* Load the subscriber by the Billwerk Contract ID.
*
* This looks up the Drupal user with the given $billwerkContractId in their
* user profile.
*
* @param mixed $billwerkContractId
* The Billwerk Contract ID.
*
* @return Subscriber|null
* The subscriber object.
*/
public static function loadByContractId($billwerkContractId): ?self {
$users = \Drupal::entityTypeManager()
->getStorage('user')
->loadByProperties([self::USER_FIELD_BILLWERK_CONTRACT_ID => $billwerkContractId]);
$user = reset($users);
if (!empty($user)) {
/** @var \Drupal\user\UserInterface $user */
return self::load($user);
}
else {
return NULL;
}
}
/**
* Refresh the user account details from their Billwerk Contract Subscription.
*
* If the user has no Billwerk Contract ID assigned, no refresh will be
* performed and the Event won't be called.
* Use Subscriber::hasUserBillwerkContractId() before to check if the user
* has a Billwerk Contract ID set, if needed.
*
* Doesn't change anything on their own (to not make assumptions), but instead
* dispatches the SubscriberRefreshFromBillwerkContractSubscriptionEvent
* so that handlers can implement what happens if the subscription needs
* to be updated.
*/
public function refreshFromBillwerkContractSubscription(): void {
if (!$this->hasUserBillwerkContractId()) {
// Only process users with a Billwerk Contract ID set. Skip others!
return;
}
// Simply trigger the event to let handlers implement what should happen
// and pass the subscriber object:
$subscriberRefreshSubscriptionEvent = new SubscriberRefreshUserEvent($this);
$this->eventDispatcher->dispatch($subscriberRefreshSubscriptionEvent);
}
/**
* Creates a new Billwerk Contract and Customer by an order.
*
* @throws \Drupal\billwerk_subscriptions\Exception\SubscriberException
*
* @return \Drupal\billwerk_subscriptions\Subscriber
* The subscriber object.
*/
public function billwerkCreateContract(): Subscriber {
// Prefill the $orderData with typical defaults:
// They can be altered in the Event individually.
$email = $this->getUser()->getEmail();
$uid = $this->getUser()->id();
$language = $this->getUser()->getPreferredLangcode();
$orderData = [
'Cart' => [
// This needs to be overwritten by the subscriber!
'planVariantId' => NULL,
],
'Customer' => [
// Other example values:
// FirstName: response.firstName,
// LastName: response.lastName,
// Language parameter seems deprecated in favor of Locale?:
// 'Language' => $language,.
'EmailAddress' => $email,
// @codingStandardsIgnoreStart
// AdditionalEmailAddresses: [],
// Address: {
// Street: 'TRIAL',
// HouseNumber: 'TRIAL',
// PostalCode: 'TRIAL',
// City: 'TRIAL',
// CompanyName: 'TRIAL',
// Country: 'DE',
// },
// AdditionalAddresses: [],.
// @codingStandardsIgnoreEnd
'Locale' => $language,
'DefaultBearerMedium' => 'Email',
'ExternalCustomerId' => $uid,
// Leave a note in Billwerk to show the origin of this user:
'Note' => 'Created by Drupal billwerk_subscriptions module on Drupal User #' . $uid . ' creation at ' . date("c"),
],
];
// The SubscriberOrderCreateEvent can be used to modify the order data
// individually.
$subscriberOrderCreateEvent = new SubscriberOrderCreateEvent(SubscriberOrderCreateEvent::ORDER_TYPE_CREATE_CUSTOMER_CONTRACT, $orderData, $this, FALSE, TRUE);
$this->eventDispatcher->dispatch($subscriberOrderCreateEvent);
// Retrieve the possibly modified order data:
$orderData = $subscriberOrderCreateEvent->getOrderData();
if ($subscriberOrderCreateEvent->getPreviewOrder()) {
$order = $this->api->createOrderPreview($orderData);
// Order is returned as subarray in preview:
$order = $order->Order;
}
else {
$order = $this->api->createOrder($orderData);
}
if ($subscriberOrderCreateEvent->getCommitOrder()) {
$committedOrder = $this->api->commitOrder($order->Id, []);
}
$contractId = $committedOrder->Id;
if (!empty($contractId)) {
$this->setUserBillwerkContractId($contractId);
}
else {
$this->logHelper->error('Registration for %email was not possible, Billwerk returned the following response, without the required order ID: %response', [
'%email' => $email,
'%response' => Json::encode($order),
]);
throw new SubscriberException('Contract ID could not be determined for committed order. Contract was created, but not assigned to the user "' . $this->getUser()->id() . '"');
}
return $this;
}
/**
* Changes the active subscription.
*
* See https://billwerk.readme.io/reference/orders_postorder_orderdto_post
* for details and the returned BillwerkOrderDTO.
*
* @param string $newPlanVariantId
* The new Billwerk Plan Variant ID to upgrade / downgrade to.
* Provide the current Billwerk Plan Variant ID to keep the existing
* contract.
* @param array $newComponentIds
* An array of Billwerk Component IDs to book additionally.
* @param array $endComponentIds
* An array of Billwerk Component IDs to end.
* @param string $couponCode
* The coupon code to use.
* @param bool $changeImmediately
* Determines if the subscription change should happen immediately
* (typically for upgrades) or at the end of the contract period
* (typically for downgrades or cancellation).
* @param bool $previewOrder
* Preview the order (do not prepare a committable order).
* @param bool $commitOrder
* Commit the order (otherwise just prepare it to be committed later!).
*
* @return \stdClass
* The Billwerk Order data object (dynamically created from JSON).
*/
public function billwerkChangeSubscription(?string $newPlanVariantId = NULL, array $newComponentIds = [], array $endComponentIds = [], ?string $couponCode = NULL, bool $changeImmediately = FALSE, bool $previewOrder = FALSE, bool $commitOrder = FALSE): \stdClass {
$orderData = [
// "To instantly bill these fees you can trigger an interim billing in the
// order by setting TriggerInterimBilling to true, or by triggering a
// separate interim billing."
// @see https://docu.billwerk.plus/en/use-cases/components/component-subscriptions.html
'TriggerInterimBilling' => TRUE,
// 'PreviewAfterTrial' => FALSE,
'Cart' => [
"InheritStartDate" => FALSE,
],
// Should only be given, if a new contract is created, for up/downgrades
// only contract ID should be given.
// 'CustomerId' => $this->getBillwerkCustomer()->getId(),
'ContractId' => $this->getBillwerkContract()->getId(),
];
// "Negative" subscription changes like downgrades and cancellation should
// typically not happen immediately, but at the end of the contract period.
// "Positive" subscription changes like upgrades should typically happen
// immediately. Billwerk requires us to set the ChangeDate to the next
// end date of the subscription which needs to be determined from the API.
// @see https://developer.billwerk.io/docs/useCases/contracts/upDowngradingToPlan
if (!$changeImmediately) {
if (!empty($newPlanVariantId)) {
// We're handling a plan variant change here.
// Changes should apply to the next enddate, if not immediately:
$orderData['ChangeDate'] = $this->billwerkGetContractNextEndDate();
}
}
// @todo Should we move this logic out into the default handler via event?
// @improve Should we first validate the values against our entities here?
if (!empty($newPlanVariantId)) {
// @todo Awaiting Billwerk Support response: This will change the plan immediately! What can we do?
$orderData['Cart']['PlanVariantId'] = $newPlanVariantId;
}
if (!empty($newComponentIds)) {
foreach ($newComponentIds as $newComponentSubscriptionId) {
$orderData['Cart']['ComponentSubscriptions'][] = [
'ComponentId' => $newComponentSubscriptionId,
// Required:
// Currently we don't support other quantities!
'Quantity' => 1,
// #20250515: Do NOT set a start date for component subscriptions
// @see https://www.drupal.org/project/billwerk_subscriptions/issues/3524721
// Round up to next minute to have proper amounts:
// 'StartDate' => Api::billwerkDateFormat(time(), NULL, NULL, TRUE),
// @codingStandardsIgnoreStart
// The following should typically NOT be needed, just kept if we run into issues and have to fix it somehow like this:
// IMPORTANT: For "BilledUntil" to work, the component setting "Independent billing" needs to be enabled!
// Otherwise this value has NO effect!
// If this should also work for Trials, also "Bill in trial" needs to be enabled!
// Round up to next minute to have proper amounts:
// 'BilledUntil' => Api::billwerkDateFormat(strtotime("+1 month", time()), NULL, $this->billwerkGetContractNextEndDate(), TRUE),
// 'EndDate' => $this->api->billwerkDateFormat(strtotime("+1 month", time()), NULL, $this->billwerkGetContractNextEndDate(), TRUE),.
// @codingStandardsIgnoreEnd
];
}
// We definitely want to trigger interim billing in this case:
$orderData['TriggerInterimBilling'] = TRUE;
}
if (!empty($endComponentIds)) {
if (count($endComponentIds) > 1) {
// We can not handle more than one component subscription at once,
// as the order method only allows one ChangeDate and we need to
// set it on our own, because an empty value would not end the
// component to the end of the billing period, but instead immediately
// and refund the amount.
throw new \UnexpectedValueException("We don't support ending multiple add-on subscriptions at once.");
}
// So we only pick the first one:
$endComponentId = reset($endComponentIds);
// Get additional information about the active subscriptions of contract:
$contractActiveSubscriptions = $this->getContractSubscriptionsActive();
$contractActiveComponentSubscriptions = $contractActiveSubscriptions['ComponentSubscriptions'];
// Look up details of the subscription to end in the list of contract
// component subscriptions:
foreach ($contractActiveComponentSubscriptions as /*$componentSubscriptionId =>*/ $activeComponentSubscription) {
if ($activeComponentSubscription['ComponentId'] === $endComponentId) {
$orderData['Cart']['EndComponentSubscriptions'][] = $endComponentId;
if (!empty($activeComponentSubscription['BilledUntil'])) {
$orderData['ChangeDate'] = $activeComponentSubscription['BilledUntil'];
}
// We definitely want to trigger interim billing in this case:
$orderData['TriggerInterimBilling'] = TRUE;
}
}
}
if (!empty($couponCode)) {
$orderData['Cart']['CouponCode'] = $couponCode;
}
$subscriberOrderCreateEvent = new SubscriberOrderCreateEvent(SubscriberOrderCreateEvent::ORDER_TYPE_UPDATE_SUBSCRIPTION, $orderData, $this, $previewOrder, $commitOrder);
$this->eventDispatcher->dispatch($subscriberOrderCreateEvent);
$orderData = $subscriberOrderCreateEvent->getOrderData();
if ($subscriberOrderCreateEvent->getPreviewOrder()) {
$order = $this->api->createOrderPreview($orderData);
// Order is returned as subarray in preview:
$order = $order->Order;
}
else {
$order = $this->api->createOrder($orderData);
}
if ($subscriberOrderCreateEvent->getCommitOrder()) {
$order = $this->api->commitOrder($order->Id, []);
}
return $order;
}
/**
* Helper method to get further information about the active contract.
*
* Returns details on the contract and especially its state.
* Contains information about the phase and component / discount
* subscriptions, in a better structured way than Billwerk does.
*
* @return array
* The active contract subscriptions.
*/
protected function getContractSubscriptionsActive(): array {
// Get the active subscriptions information from the contract:
// @see https://docs.frisbii-transform.com/reference/contracts_getsubscriptions_id_timestamp_get
$contractSubscriptions = $this->api->getContractSubscriptions($this->getBillwerkContract()->getId());
// Create improved array:
$activeContractSubscriptions = $contractSubscriptions;
// Index component subscriptions by ID:
foreach ($activeContractSubscriptions['ComponentSubscriptions'] as $activeComponentSubscriptions) {
$activeContractSubscriptions['ComponentSubscriptions'][$activeComponentSubscriptions['Id']] = $activeComponentSubscriptions;
}
// Index discount subscriptions by ID:
foreach ($activeContractSubscriptions['DiscountSubscriptions'] as $activeDiscountSubscriptions) {
$activeContractSubscriptions['DiscountSubscriptions'][$activeDiscountSubscriptions['Id']] = $activeDiscountSubscriptions;
}
return $contractSubscriptions;
}
/**
* Looks up the customer at Billwerk by ExternalId = Drupal user id (UID).
*
* Especially useful after importing customers and contracts to Billwerk
* with the ExternalId set to the Drupal user ID. Can then be used
* to get the customers primary contract id for the user profile.
*
* Example use case: BillwerkContractIdsFetchAndAssignAction
*
* @return ?BillwerkCustomer
* The BillwerkCustomer object.
*/
public function billwerkLookupCustomerByUid(): ?BillwerkCustomer {
$customerData = $this->api->getCustomerByExternalId((string) $this->getUser()->id());
if (empty($customerData) || !isset($customerData['Id'])) {
// No customer with this ExternalId (=uid) found!
return NULL;
}
/** @var BillwerkDataObjectFactory $billwerkDataObjectFactory */
// @phpstan-ignore-next-line
$billwerkDataObjectFactory = \Drupal::service('billwerk_subscriptions.billwerk_data_object_factory');
return $billwerkDataObjectFactory->billwerkLoadBillwerkCustomer($customerData['Id']);
}
/**
* Locks the customer account at Billwerk.
*
* @throws \Drupal\billwerk_subscriptions\Exception\SubscriberException
*/
public function billwerkLockCustomer(): void {
$billwerkCustomerId = $this->getBillwerkCustomer()->getId();
if (!empty($billwerkCustomerId)) {
$this->api->customerUpdateLockedState($billwerkCustomerId, TRUE);
}
else {
throw new SubscriberException("Customer ID could not be determined for User #{$this->getUser()->id()}");
}
}
/**
* Unlocks the customer account at Billwerk.
*
* @throws \Drupal\billwerk_subscriptions\Exception\SubscriberException
*/
public function billwerkUnlockCustomer(): void {
$billwerkCustomerId = $this->getBillwerkCustomer()->getId();
if (!empty($billwerkCustomerId)) {
$this->api->customerUpdateLockedState($billwerkCustomerId, FALSE);
}
else {
throw new SubscriberException("Customer ID could not be determined for User #{$this->getUser()->id()}");
}
}
/**
* Deletes the customer account and its contracts at Billwerk entirely.
*
* @throws \Drupal\billwerk_subscriptions\Exception\SubscriberException
*/
public function billwerkDeleteCustomer(): void {
$billwerkCustomerId = $this->getBillwerkCustomer()->getId();
if (!empty($billwerkCustomerId)) {
$this->api->deleteCustomer($billwerkCustomerId);
$this->setUserBillwerkContractId('');
}
else {
throw new SubscriberException("Customer ID could not be determined for User #{$this->getUser()->id()}");
}
}
/**
* Sets the Billwerk Customer ExternalCustomerId to the Subscriber's user ID.
*
* @throws \Drupal\billwerk_subscriptions\Exception\SubscriberException
*/
public function billwerkSetCustomerExternalCustomerId(): void {
if (empty($this->getBillwerkCustomer())) {
throw new SubscriberException("Customer could not be loaded for User #{$this->getUser()->id()}");
}
if ($this->getBillwerkCustomer()->getExternalCustomerId() == $this->getUser()->id()) {
// The external customer ID already equals the Drupal user id.
return;
}
// Set the Billwerk ExternalCustomerId to the user ID.
$this->api->patchCustomer($this->getBillwerkCustomer()->getId(), ['ExternalCustomerId' => $this->getUser()->id()]);
}
/**
* Returns the Subscriber's Billwerk self service token.
*
* @return string
* The self service token.
*/
public function billwerkGetSelfserviceToken(): string {
$contractId = $this->getUserBillwerkContractId();
return $this->api->getSelfserviceToken($contractId);
}
/**
* Get the contract details by user billwerk contract id.
*
* @return string
* The contract details.
*/
public function billwerkGetContractDetails(): array {
$contractId = $this->getUserBillwerkContractId();
return $this->api->getContract($contractId);
}
/**
* Returns true if the user has pending (future) contract phases.
*
* This helper method is needed, as Billwerk has no other way to determine
* that the contract has already been changed to a new phase.
* We for example need that information to prevent a user from changing
* their contract again and again.
*
* @return bool
* Whether the user has pending contract phases.
*/
public function billwerkHasPendingContractPhases(): bool {
$contract = $this->getBillwerkContract();
if (empty($contract)) {
// No contract.
// @improve: Should we instead throw an exception here?
return FALSE;
}
if ($contract->getLifecycleStatus() == BillwerkContract::LIFECYCLE_STATUS_INTRIAL) {
// Never treat pending contract phases in trial as pending:
return FALSE;
}
$contractDetails = $this->billwerkGetContractDetails();
if (!empty($contractDetails['Phases'])) {
$contractPhases = $contractDetails['Phases'];
$lastContractPhase = end($contractPhases);
return $lastContractPhase['StartDate'] != $contractDetails['CurrentPhase']['StartDate'];
}
return FALSE;
}
/**
* Returns the next end date of the contract.
*
* @return string
* The next end date.
*/
public function billwerkGetContractNextEndDate(): string {
// https://developer.billwerk.io/docs/useCases/contracts/terminateContractWithNotice
$contractId = $this->getUserBillwerkContractId();
$cancellationPreview = $this->api->getContractCancellationPreview($contractId);
if (empty($cancellationPreview['EndDate'])) {
throw new \UnexpectedValueException("Contract next end date could not be determined, but is required.");
}
return $cancellationPreview['EndDate'];
}
/**
* Returns the next billing date of the contract.
*
* @return string
* The next billing date.
*/
public function billwerkGetContractNextBillingDate(): string {
// https://developer.billwerk.io/docs/useCases/contracts/upDowngradingToPlan
$contractId = $this->getUserBillwerkContractId();
$contract = $this->api->getContract($contractId);
if (empty($contract['NextBillingDate'])) {
throw new \UnexpectedValueException("Contract next billing date could not be determined, but is required.");
}
return $contract['NextBillingDate'];
}
/**
* Returns the contract billed until date.
*
* @return string
* The billed until date.
*/
public function billwerkGetContractBilledUntilDate(): string {
// https://developer.billwerk.io/docs/useCases/contracts/upDowngradingToPlan
$contractId = $this->getUserBillwerkContractId();
$contract = $this->api->getContract($contractId);
if (empty($contract['BilledUntil'])) {
throw new \UnexpectedValueException("Contract billed until date could not be determined, but is required.");
}
return $contract['BilledUntil'];
}
/**
* Returns the BillwerkContract with lazy-loading.
*
* @return \Drupal\billwerk_subscriptions\DataObject\BillwerkContract
* The BillwerkContract object.
*/
public function getBillwerkContract(): ?BillwerkContract {
if ($this->billwerkContract !== NULL) {
return $this->billwerkContract;
}
else {
if ($this->hasUserBillwerkContractId()) {
$billwerkContractId = $this->getUserBillwerkContractId();
$this->billwerkContract = $this->billwerkDataObjectFactory->billwerkLoadBillwerkContract($billwerkContractId);
}
}
return $this->billwerkContract;
}
/**
* Summary of getCustomerData.
*
* @return ?\Drupal\billwerk_subscriptions\DataObject\BillwerkCustomer
* The BillwerkCustomer object.
*/
public function getBillwerkCustomer(): ?BillwerkCustomer {
return $this->getBillwerkContract()->getBillwerkCustomer();
}
/**
* Returns the user.
*
* @return \Drupal\user\UserInterface
* The user object.
*/
public function getUser(): UserInterface {
// Safety net to never accidentally return the anonymous user:
if ($this->user->isAnonymous()) {
throw new SubscriberException('Subscriber should never be anonymous! Something is wrong.');
}
return $this->user;
}
/**
* Sets the Billwerk Contract ID in the Drupal user profile.
*
* @param string $billwerkContractId
* The Billwerk Contract ID.
* @param bool $save
* Whether to save the user profile.
*/
public function setUserBillwerkContractId(string $billwerkContractId, bool $save = TRUE): void {
$this->user->set(self::USER_FIELD_BILLWERK_CONTRACT_ID, $billwerkContractId);
if ($save) {
$this->user->save();
}
}
/**
* Returns the Billwerk Contract ID stored in the Drupal user profile.
*
* If none ist stored, returns NULL.
*
* @return ?string
* The Billwerk Contract ID.
*/
public function getUserBillwerkContractId(): ?string {
return $this->user->get(self::USER_FIELD_BILLWERK_CONTRACT_ID)->getString() ?: NULL;
}
/**
* Returns true if the user has a Billwerk Contract ID stored, else false.
*
* @return bool
* Whether the user has a Billwerk Contract ID stored.
*/
public function hasUserBillwerkContractId(): bool {
$userBillwerkContractId = $this->getUserBillwerkContractId();
return !empty($userBillwerkContractId);
}
}
