subman-1.0.x-dev/src/SubmanSync.php
src/SubmanSync.php
<?php
namespace Drupal\subman;
use Drupal\Core\Logger\RfcLogLevel;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\subman\Event\SubmanIncomingWebhook;
use Drupal\subman\Event\SubmanSubscriberUserModified;
use Drupal\subman\Event\SubmanSynchronousResult;
use Drupal\subman\Exception\SubmanException;
use Drupal\subman\Exception\SubmanWebhookSubscriberMissOrConflictException;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Class SubmanSync.
*
* Provides the re-usable parts of handling and syncing data from a
* subscription management SaaS to Drupal.
*
* This is the abstract base class for any specific service that
* connects to a specific subscription management SaaS.
* As such, it can and has to be extended by a specific SaaS integration module.
*
* As abstract basis, this class provides all parts of external data handling
* that are independent of any specific SaaS, e.g.:
* * Work with normalized versions of specific external data
* * Find and update Drupal user corresponding to normalized external data
* * Store sync information on to the corresponding Drupal user
* * Declare abstract base methods (that can be overridden in specific
* implementations for specific SaaS).
*
* A specific implementation of this base class should be declared as service
* "subman.sync", and as event subscriber via service tag "event_subscriber".
* We assume that only one implementation of the abstract class SubmanSync
* is used at a time. This is an easy way to let other code use a specific
* service class for a specific SaaS without knowing about it specifically.
*/
abstract class SubmanSync implements SubmanSyncInterface, EventSubscriberInterface
{
use StringTranslationTrait;
const DRUPAL_FIELDNAME_SAAS_ID = 'field_subman_external_id';
const DRUPAL_FIELDNAME_SYNC = 'field_subman_sync';
const DRUPAL_FIELDNAME_ID_SECONDARY = 'mail';
const SAAS_FIELDNAME_ID_SECONDARY = 'EmailAddress';
const SAAS_FIELDNAME_DRUPAL_UID = 'ExternalCustomerId';
// @todo rename to DATA_COMPONENT_...
const DATA_KEY_SUBSCRIBER = 'subscriber';
const DATA_KEY_SUBSCRIPTION = 'subscription';
const DATA_KEY_SUBSCRIPTION_TYPE = 'subscription_type';
const DATA_KEY_ADDONS = '_addons';
const DATA_KEY_TIMESTAMP = 'timestamp';
const MAP_NORMALIZED_KEY = '_normalized';
const MAP_NORMALIZED_SUBSCRIBER_ID = '_id';
const MAP_NORMALIZED_SUBSCRIBER_EMAIL = '_email';
const MAP_NORMALIZED_SUBSCRIBER_FIRSTNAME = '_firstname';
const MAP_NORMALIZED_SUBSCRIBER_LASTNAME = '_lastname';
const MAP_NORMALIZED_SUBSCRIBER_DEBITOR_ID = '_foreign_id';
const MAP_NORMALIZED_SUBSCRIBER_CREATED = '_created';
const MAP_NORMALIZED_SUBSCRIBER_ACTIVE = '_active';
const MAP_NORMALIZED_SUBSCRIBER_DELETED = '_deleted';
const MAP_NORMALIZED_SUBSCRIBER_LOCALE = '_locale';
const MAP_NORMALIZED_SUBSCRIBER_LANGUAGE = '_language';
const MAP_NORMALIZED_SUBSCRIPTION_ID = '_id';
const MAP_NORMALIZED_SUBSCRIPTION_SUBSCRIBER_ID = '_subscriber_id';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE = '_type';
const MAP_NORMALIZED_SUBSCRIPTION_VARIANT = '_variant';
const MAP_NORMALIZED_SUBSCRIPTION_REFERENCE = '_reference';
const MAP_NORMALIZED_SUBSCRIPTION_VALID = '_valid';
const MAP_NORMALIZED_SUBSCRIPTION_MESSAGE = '_message';
const MAP_NORMALIZED_SUBSCRIPTION_ESCALATION = '_escalation';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_ID = '_id';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME = '_name';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME_TECHNICAL = '_name_technical';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_ROLES = '_roles';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS = '_class';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS_MAIN = 'main';
const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS_VARIANT = 'variant';
const MAP_NORMALIZED_ADDON_ID = '_id';
const MAP_NORMALIZED_ADDON_SUBSCRIBER_ID = '_subscriber_id';
const MAP_NORMALIZED_ADDON_SUBSCRIPTION_ID = '_subscription_id';
const MAP_NORMALIZED_ADDON_QUANTITY = '_quantity';
const MAP_NORMALIZED_ADDON_ROLES = '_roles';
const MAP_NORMALIZED_ADDON_STATUS = '_status';
const NOTIFY_TRIAL_ENDING = 'trial_ending';
/**
* Constructs a new SubmanSubscriberSync object.
*
* @param SubmanUtilities $utils
* The subman utilities service.
*/
public function __construct(protected SubmanUtilities $utils)
{
}
/**
* Get the title of the subscription management service.
*
* @return string
* The title of the subscription management service.
*/
abstract public function getSubscriptionManagementServiceTitle(): string;
/**
* {@inheritDoc}
*/
public static function getSubscribedEvents(): array
{
return [
SubmanIncomingWebhook::EVENT_NAME => 'onIncomingWebhook',
SubmanSynchronousResult::EVENT_NAME => 'onSynchronousResult',
SubmanSubscriberUserModified::EVENT_NAME => 'onSubscriberModified',
];
}
/**
* {@inheritDoc}
*/
abstract public function onIncomingWebhook(SubmanIncomingWebhook $event): void;
/**
* {@inheritDoc}
*/
abstract public function onSynchronousResult(SubmanSynchronousResult $event): void;
/**
* {@inheritDoc}
*/
public function onSubscriberModified(SubmanSubscriberUserModified $event): void
{
// By default, send welcome email with password reset link for newly created
// subscribers.
if ($event->operation == SubmanSubscriberUserModified::OP_POST_CREATE) {
$this->notifyNewSubscriber($event->subscriber);
}
}
/**
* Welcome a newly create subscriber.
*
* By default this is done by triggering the register_admin_created mail.
*
* @param \Drupal\user\UserInterface $subscriber
*/
protected function notifyNewSubscriber(UserInterface $user): void
{
if ($user instanceof UserInterface) {
// Register with no admin approval required.
_user_mail_notify('register_no_approval_required', $user);
}
}
/**
* Updates the subscriber by an external id.
*
* Determines the customer with given id and triggers subman to update the
* subscriber with it.
*
* @param string $saasSubscriberId
* The id of the customer to get updated data on.
*/
protected function updateUserByExternalId($saasSubscriberId): void
{
// Get complete data for customer (incl. subscriptions).
$data = $this->getSubscriberDataComplete($saasSubscriberId);
// Do the actual updating.
$this->createOrUpdateUserByData($data);
}
/**
* Updates basic subscriber fields with provided data.
*
* To extend this updating, override this method in derived/overridden class,
* with parent::updateSubscriber(...) called last for saving.
*
* @param array $data
* Array with the subscriber data as it comes from the specific SaaS,
* including '_normalized' => ['id' => ..., 'email' => ..., 'active' => bool].
* @param \Drupal\user\UserInterface $subscriber
* The content entity to us as subscriber and update with $data. Will be looked up, if empty.
*/
protected function createOrUpdateUserByData(array $data, UserInterface $subscriber = NULL): void
{
$norm = $data[static::DATA_KEY_SUBSCRIBER][static::MAP_NORMALIZED_KEY];
// Lookup subscriber if none is specified. Create user account, if not yet existing:
$subscriber = $subscriber ?? $this->lookupSubscriber($data[static::DATA_KEY_SUBSCRIBER], TRUE);
// Abort with exception if no subscriber could be determined by now.
if (empty($subscriber) || empty($subscriber->id())) {
throw new SubmanWebhookSubscriberMissOrConflictException('Could neither find subscriber by primary ID: "' . $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID] . '" nor by secondary (' . static::DRUPAL_FIELDNAME_ID_SECONDARY . '): ' . ($data[static::SAAS_FIELDNAME_ID_SECONDARY] ?? ''));
}
// Save sync data into field.
$this->storeSyncData($subscriber, $data);
// Do any additional steps.
$this->updateSubscriberFields($subscriber, $data);
// Save updated entity, if still desirable.
if (empty($subscriber->_subman_abort_save)) {
$isNew = $subscriber->isNew();
// Trigger event:
$event = new SubmanSubscriberUserModified($subscriber, $isNew ? SubmanSubscriberUserModified::OP_PRE_CREATE : SubmanSubscriberUserModified::OP_PRE_UPDATE);
$this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);
// Save the changes to the user.
$subscriber->save();
// Trigger event:
$event = new SubmanSubscriberUserModified($subscriber, $isNew ? SubmanSubscriberUserModified::OP_POST_CREATE : SubmanSubscriberUserModified::OP_POST_UPDATE);
$this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);
// Log what we did.
$this->utils->log('createOrUpdateSubscriberByData(): Subscriber @entity_id @message_op for external id:@id/email@:email', [], [
'@entity_id' => $subscriber->id(),
'@id' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID],
'@email' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL] ?? $subscriber->get('mail')->value ?? '',
'@message_op' => $isNew ? 'created' : 'updated',
], 'debug');
}
}
/**
* Update the subscriber fields. May be extended or overwritten by custom implementations.
*
* By default updates the external ID, email address and the status.
*
* @param \Drupal\user\UserInterface $subscriber
* The user / content entity of the subscriber, incl. current sync data in
* field self::DRUPAL_FIELDNAME_SYNC.
* @param array $data
* The sync data as given by caller.
*/
protected function updateSubscriberFields(UserInterface $subscriber, array $data): void
{
// Update external ID, mail and status:
$norm = $data[static::DATA_KEY_SUBSCRIBER][static::MAP_NORMALIZED_KEY];
if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_ID]) && $subscriber->hasField(static::DRUPAL_FIELDNAME_SAAS_ID)) {
$subscriber->set(static::DRUPAL_FIELDNAME_SAAS_ID, $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID]);
}
// Update email address from remote. Typically useful with setting: "disable_drupal_mail_edit"
if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL]) && $subscriber->hasField('mail')) {
$currentMail = $subscriber->get('mail');
if (empty($currentMail) || \Drupal::config('subman.settings')->get('fetch_overwrite_mail')) {
$subscriber->set('mail', $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL]);
}
}
if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE]) && $subscriber->hasField('status')) {
// @todo: Do we also need to consider MAP_NORMALIZED_SUBSCRIBER_DELETED?
if (\Drupal::config('subman.settings')->get('fetch_overwrite_status')) {
$subscriber->set('status', $norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE]);
}
}
}
/**
* {@inheritDoc}
*/
public function getUserBySaasId($saasSubscriberId): ?UserInterface
{
$subscriber = $this->utils->retrieveEntityByField('user', [static::DRUPAL_FIELDNAME_SAAS_ID => $saasSubscriberId]);
return $subscriber;
}
/**
* Looks up a subscriber entity (here: user) via the provided data.
*
* This method assumes the user entity type is used as subscriber.
* To change the entity type to be used as subscriber, or how to look it up,
* override this method in derived/overridden service class.
*
* @todo Consider and decide whether to make this entity type independent.
*
* @param array $data
* Array of data, see updateSubscriber().
* @param bool $createIfNotFound
* A flag whether or not to create an empty entity if none found.
*
* @return ?\Drupal\user\UserInterface
* The subscriber entity found (if any).
*/
protected function lookupSubscriber(array $data, bool $createIfNotFound = FALSE): ?UserInterface
{
$norm = $this->normalize('subscriber', $data);
$id_primary = $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID];
$id_secondary_drupal = $this->utils->getSetting('mapping.subscriber.secondary_id.drupal', static::DRUPAL_FIELDNAME_ID_SECONDARY);
$id_secondary_saas = $this->utils->getSetting('mapping.subscriber.secondary_id.saas', static::SAAS_FIELDNAME_ID_SECONDARY);
// Don't create new user if subscriber is deleted:
$createIfNotFound = $createIfNotFound & !($norm[static::MAP_NORMALIZED_SUBSCRIBER_DELETED] ?? FALSE);
// First approach: try to find a subscriber by id:
$approach = 1;
$subscriber = $this->getUserBySaasId($id_primary);
// Second approach: try to find a subscriber by secondary identifier
// (e.g. email) but only use that user if it has not already a different
// external id:
if (!$subscriber) {
$subscriber = $this->utils->retrieveEntityByField('user', [$id_secondary_drupal => $data[$id_secondary_saas]]);
// If user found already has a different external id assigned, make sure
// we return no result. This will lead to an "Subscriber entity could not
// be found or created" exception being thrown and logged in the caller
// (e.g. updateSubscriberByData), wherefrom it can be monitored and thus
// used to trigger an alert. But this will more importantly prevent
// creation of duplicate users.
if ($subscriber && !$subscriber->get(static::DRUPAL_FIELDNAME_SAAS_ID)->isEmpty() && $subscriber->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value != $id_primary) {
$approach = 2;
$subscriber = NULL;
}
}
// If no approach succeeded, create empty new user, if desired.
if ((!$subscriber || empty($subscriber->id())) && $createIfNotFound && $approach == 1) {
if (empty($norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL])) {
$external_id = $this->extractSubscriberIdFromData($data);
$this->utils->log('User creation was skipped because no email address was given for subscriper id: @subscriber_id',
NULL,
[
'@subscriber_id' => $external_id,
],
'info'
);
}
$userdata = [
'name' => $this->generateUsername($data),
'mail' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL],
'init' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL],
'status' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE] && !$norm[static::MAP_NORMALIZED_SUBSCRIBER_DELETED],
'langcode' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_LANGUAGE] ?: NULL,
'preferred_langcode' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_LANGUAGE],
];
$subscriber = User::create($userdata);
$subscriber->enforceIsNew();
// User will be saved later with its data.
$this->utils->log('Created a new user: %name',
$userdata,
[
'%name' => $userdata['name']
]
);
}
return $subscriber;
}
/**
* Determine a new user's name from given data.
*
* @param array $data
* The structured subscriber data array.
*
* @return string
* The determined username.
*
* @throws SubmanException If the generated username is empty
*/
protected function generateUsername(array $data): string
{
// Assign username as "firstname lastname".
$name_parts = [
$data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_FIRSTNAME],
$data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_LASTNAME],
];
$name = trim(implode(' ', $name_parts));
// Fall back to the email address if empty:
if (empty($name)) {
$name = $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_EMAIL];
}
// If user with same username exists, append part of the external user ID to make username unique.
if (!empty($this->utils->retrieveEntityByField('user', ['name' => $name]))) {
$name .= ' ' . substr($data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID], -5);
}
if (empty($name)) {
throw new SubmanException('Generated Username may not be empty!');
}
return trim($name);
}
/**
* {@inheritDoc}
*/
public function deleteUser($saasSubscriberId, $cancelationMethod = 'user_cancel_delete'): void
{
// Lookup subscriber.
$subscriber = $this->lookupSubscriber([static::MAP_NORMALIZED_KEY => [static::MAP_NORMALIZED_SUBSCRIBER_ID => $saasSubscriberId]], FALSE);
// Delete the subscriber.
if ($subscriber) {
// Notify others of subscriber deletion. We do this short before the execution:
$event = new SubmanSubscriberUserModified($subscriber, SubmanSubscriberUserModified::OP_PRE_DELETE);
$this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);
if ($subscriber->getEntityTypeId() == 'user') {
// @improve: https://www.drupal.org/project/drupal/issues/3408997
user_cancel([], $subscriber->id(), $cancelationMethod);
} else {
$subscriber->delete();
}
$event = new SubmanSubscriberUserModified($subscriber, SubmanSubscriberUserModified::OP_POST_DELETE);
$this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);
$this->utils->log('deleteSubscriber(): Subscriber @id deleted.', NULL, ['@id' => $saasSubscriberId], 'info');
} else {
throw new SubmanWebhookSubscriberMissOrConflictException('Subscriber not found', 0, NULL, $saasSubscriberId);
}
}
/**
* Save sync data into field on subscriber.
*
* @param \Drupal\user\UserInterface $subscriberUser
* The subscriber.
* @param array $sync
* The sync data.
*/
protected function storeSyncData(UserInterface $subscriberUser, array $sync = []): void
{
if ($subscriberUser->hasField(static::DRUPAL_FIELDNAME_SYNC)) {
$sync[static::DATA_KEY_TIMESTAMP] = $this->utils->getRequestTime();
$sync = json_encode($sync, JSON_PRETTY_PRINT);
$subscriberUser->set(static::DRUPAL_FIELDNAME_SYNC, $sync);
}
}
/**
* {@inheritDoc}
*/
public function syncUser(UserInterface $subscriberUser, bool $forceFullLookup = FALSE, $catchExceptions = FALSE): bool
{
$external_id = NULL;
try {
// First check whether external_id is part of stored sync info.
if (!$subscriberUser->get(static::DRUPAL_FIELDNAME_SYNC)->isEmpty()) {
$data = $this->getUserSaasData($subscriberUser, static::DATA_KEY_SUBSCRIBER);
$external_id = $this->extractSubscriberIdFromData($data);
}
// Alternatively perform a full lookup, if necessary or explicitely called
// for.
if (empty($external_id) || $forceFullLookup) {
$data = $this->fetchUserSaasData($subscriberUser);
$external_id = $this->extractSubscriberIdFromData($data);
}
// If external subscriber data found, update local subscriber with data ...
if (!empty($external_id)) {
$data = $this->getSubscriberDataComplete($external_id);
$this->createOrUpdateUserByData($data, $subscriberUser);
}
// ... or with empty sync data/timestamp to avoid future futile sync.
else {
$this->storeSyncData($subscriberUser);
$subscriberUser->save();
}
return TRUE;
} catch (\Exception $e) {
if ($catchExceptions) {
$this->utils->log(
$this->t('syncSubscriberSafe(): An error occurred while syncing the @subscriber. Exception @ex_message occured at @ex_position during operation.', [
'@ex_message' => $e->getMessage(),
'@ex_position' => $e->getFile() . ':' . $e->getLine(),
'@subscriber' => $subscriberUser->toLink()->toString(),
]),
[],
['exception' => $e],
'error'
);
return FALSE;
} else {
throw $e;
}
}
}
/**
* Extracts the subscriber ID from the given sync data structure.
*
* @param array $data
* Sync data strucure to get the subscriber ID from.
*
* @return string|null
* The extracted subscriber ID, or NULL if none found.
*/
protected function extractSubscriberIdFromData(array $data): ?string
{
if (is_array($data) && isset($data[static::MAP_NORMALIZED_KEY]) && isset($data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID])) {
return $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID];
} else {
return NULL;
}
}
/**
* {@inheritDoc}
*/
public function handleUserLogin(AccountInterface $account): void
{
$user = User::load($account->id());
if ($user->hasField(static::DRUPAL_FIELDNAME_SYNC) && $user->get(static::DRUPAL_FIELDNAME_SYNC)->isEmpty()) {
$this->syncUser($user);
}
}
/**
* {@inheritDoc}
*/
public function getUserSubscriptions(UserInterface $subscriber = NULL, bool $limitToValid = TRUE): array
{
$user = $this->utils->getCurrentUser($subscriber);
$result = [];
$subscriptions = $this->getUserSaasData($user, static::DATA_KEY_SUBSCRIPTION) ?? [];
foreach ($subscriptions as $subscription) {
if (!$limitToValid || $subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_VALID]) {
$result[$subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_ID]] = $subscription;
}
}
return $result;
}
/**
* {@inheritDoc}
*/
public function getUserSaasId(UserInterface $user): ?string
{
return !$user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->isEmpty() ? $user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value : NULL;
}
/**
* {@inheritDoc}
*/
public function isUserSubscriber(UserInterface $user = NULL): bool
{
$user = $this->utils->getCurrentUser($user);
return $user !== NULL && ($this->getUserSaasId($user) !== NULL);
}
/**
* {@inheritDoc}
*/
public function getUserSaasData(UserInterface $user, string $key = NULL, bool $decodeJson = TRUE): mixed
{
$data = $user->get(static::DRUPAL_FIELDNAME_SYNC);
$data = $data ? $data->value : '';
if (($key !== NULL || $decodeJson) && !empty($data)) {
$data = json_decode($data, TRUE);
}
return empty($key) ? $data : $data[$key] ?? NULL;
}
/**
* Retrieves the normalized subscriber data from the SaaS that matches the given user.
*
* @param \Drupal\user\UserInterface $user
* The user to match.
*
* @return ?array
* The raw external data as array or NULL if none found.
*/
protected function fetchUserSaasData(UserInterface $user): ?array
{
$data = NULL;
// First approach: look up saas customer by external id field.
$id = $user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value;
if (!empty(($id))) {
$data = $this->lookupUserSaasDataByKey(trim($id));
}
// Second approach: look up saas customer by secondarty criteria
// (such as email).
if (empty($data)) {
$id_secondary = trim($this->getSecondaryId($user));
if (!empty($id_secondary)) {
$data = $this->lookupUserSaasDataByKey($id_secondary, FALSE);
}
}
$this->normalize(static::DATA_KEY_SUBSCRIBER, $data);
return $data;
}
/**
* {@inheritDoc}
*/
abstract protected function lookupUserSaasDataByKey(string $key, bool $keyIsSaasPrimaryId = TRUE): ?array;
/**
* {@inheritDoc}
*/
public function getSecondaryId(UserInterface $user): ?string
{
$id_secondary_drupal = $this->utils->getSetting('mapping.subscriber.secondary_id.drupal', static::DRUPAL_FIELDNAME_ID_SECONDARY);
$secondary_id = trim($user->get($id_secondary_drupal)->value);
return $secondary_id;
}
/**
* Retrieves external subscriber by the given external id.
*
* @param string $saasSubscriberId
* The external id for a subscriber to retrieve the subscriber data by.
*
* @return array
* The array with the raw external data.
*/
abstract protected function fetchSubscriberData(string $saasId): array;
/**
* {@inheritDoc}
*/
public function getSubscriberData(string $saasSubscriberId): array
{
$data = $this->fetchSubscriberData($saasSubscriberId);
$this->normalize(static::DATA_KEY_SUBSCRIBER, $data);
return $data;
}
/**
* {@inheritDoc}
*/
public function getSubscriberDataComplete(string $saasSubscriberId): array
{
$data = [
static::DATA_KEY_SUBSCRIBER => $this->getSubscriberData($saasSubscriberId),
static::DATA_KEY_SUBSCRIPTION => $this->getSubscriberSubscriptionsData($saasSubscriberId),
];
return $data;
}
/**
* Retrieves external subscription by the given external id.
*
* @param string $saasSubscriptionId
* The external id for a subscription to retrieve the subscription data by.
*
* @return array
* The array with the raw external data.
*/
abstract protected function fetchSubscriptionData(string $saasId): array;
/**
* {@inheritDoc}
*/
public function getSubscriptionData(string $saasSubscriptionId): array
{
$data = $this->fetchSubscriptionData($saasSubscriptionId);
$this->normalize(static::DATA_KEY_SUBSCRIPTION, $data);
return $data;
}
/**
* Retrieves external subscriptions by the given external id.
*
* @param string $saasSubscriberId
* The external id for a subscriber to retrieve the subscriptions data for.
*
* @return array
* The array with the raw external data.
*/
abstract protected function fetchSubscriberSubscriptionsData(string $saasSubscriberId): array;
/**
* {@inheritDoc}
*/
public function getSubscriberSubscriptionsData(string $saasSubscriberId): array
{
$data = $this->fetchSubscriberSubscriptionsData($saasSubscriberId);
$data = $this->normalizeMultiple(static::DATA_KEY_SUBSCRIPTION, $data);
if (!empty($data)) {
foreach ($data as $key => $subscription) {
$saasSubscriptionId = $subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_ADDON_ID];
$data[$key][static::DATA_KEY_ADDONS] = $this->getSubscriptionAddonsData($saasSubscriptionId);
}
}
return $data;
}
/**
* Retrieves external subscription addons by the given external susbcription id.
*
* @param string $saasSubscriptionId
* The external id for a subscription to retrieve the subscriptions data for.
*
* @return array
* The array with the raw external data.
*/
abstract protected function fetchSubscriptionAddonsData(string $saasSubscriptionId): array;
/**
* {@inheritDoc}
*/
public function getSubscriptionAddonsData(string $saasSubscriptionId): array
{
$data = $this->fetchSubscriptionAddonsData($saasSubscriptionId);
$data = $this->normalizeMultiple(static::DATA_KEY_ADDONS, $data);
return $data;
}
/**
* Retrieve data of subscription types from saas service.
*
* @return array
* The array with the raw external data.
*/
abstract protected function fetchSubscriptionTypesData();
/**
* {@inheritDoc}
*/
public function getSubscriptionType(string $typeId): ?array
{
if (empty($typeId)) {
return NULL;
}
// Get subscription types (probably from cache).
$types = $this->getSubscriptionTypes();
// If not subscription type foud for specified type_id, try again without
// cache.
if (!isset($types[$typeId])) {
$types = $this->getSubscriptionTypes(TRUE);
}
return $types[$typeId] ?? NULL;
}
/**
* {@inheritDoc}
*/
public function getSubscriptionTypes(bool $forceSaasRetrieval = FALSE): array
{
// First try if data is cached.
$types = $this->utils->loadCachedData('subscription_types');
// If no cache hit, retrieve data and store it in cache as associative
// array.
if (empty($types) || $forceSaasRetrieval) {
$types = $this->fetchSubscriptionTypes();
}
return $types;
}
/**
* Freshly retrieves subscription types.
*
* @return array
* The array of subscription type data, keyed by subscription type id.
*/
protected function fetchSubscriptionTypes(): array
{
// Retrieve from SaaS service.
$data = $this->fetchSubscriptionTypesData();
// Normalize.
$types = $this->normalizeMultiple(static::DATA_KEY_SUBSCRIPTION_TYPE, $data);
// Convert to associative arras keyed by subscription type id.
$types_assoc = [];
foreach ($types as $type) {
$type_id = $type[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_TYPE_ID];
$types_assoc[$type_id] = $type;
}
$types = $types_assoc;
// Store associative array.
$this->utils->saveData('subscription_types', $types);
return $types;
}
/**
* Normalizes the given data for the given type as defined by config.
*
* You can define config based normalization for TYPE via:
* $settings['subman.settings']['mapping.TYPE'] = [
* 'field_city.value' => 'Address.City',
* ];
*
* @param string $type
* The type to normalize (e.g. subscriber or subscription).
*
* @param ?array $data
* Customer data array as received by the subscription management service.
*
* @return array
* Array with normalized only data structured as following:
* 'id' => ... // String: The id of the subscriber
* 'email' => ... // String: the email address of the subscriber
* 'created' => ... // Int: unix timestamp of the creation date of the
* subscriber
* 'active' => ... // Boolean: Whether the subscriber is active
* 'deleted' => ... // Boolean: Whether the subscriber is deleted
*/
protected function getNormalizedData(string $type, array $data = NULL): array
{
$normalized = [];
// Get mapping.
$mapping = $this->utils->getSetting("mapping.$type") ?? [];
// For each mapping definition...
foreach ($mapping as $norm_key => $orig_key) {
// ... traverse original data structure to retrieve original value ...
$orig_value = $data;
foreach (explode('.', $orig_key) as $orig_key_part) {
if (isset($orig_value[$orig_key_part])) {
$orig_value = $orig_value[$orig_key_part];
} else {
$orig_value = NULL;
break;
}
}
// ... and assign original value to the normalized key.
if ($orig_value != NULL) {
$normalized[$norm_key] = $orig_value;
}
}
return $normalized;
}
/**
* Adds normalized form for and to given data.
*
* For more details see ::getNormalizeData().
*
* @param string $type
* The type to normalize (e.g. subscriber or subscription).
* @param array $data
* Customer data array as received by the subscription management service.
*/
protected function normalize(string $type, array &$data = NULL): array
{
if (!isset($data[static::MAP_NORMALIZED_KEY])) {
$data[static::MAP_NORMALIZED_KEY] = $this->getNormalizedData($type, $data);
}
return $data[static::MAP_NORMALIZED_KEY];
}
/**
* Normalize an array of multiple data items of given type.
*
* @param string $type
* The data type key to normalize for.
* @param array $data
* The array of data items (each being nested array itself).
*
* @return array
* The array of data items now augmented with normalized data.
*/
protected function normalizeMultiple(string $type, array $data): array
{
$normalized = [];
foreach ((array) $data as $data_item) {
$this->normalize($type, $data_item);
$normalized[] = $data_item;
}
return $normalized;
}
/**
* {@inheritDoc}
*/
public function buildSubscriptionTitle(array $subscriptionData)
{
$subscription_id = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_ID];
$subscription_reference = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_REFERENCE];
$subscription_variant_id = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_VARIANT];
$subscription_variant = $this->getSubscriptionType($subscription_variant_id);
$subscription_type_name = $subscription_variant[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME];
return ($subscription_reference ?? $subscription_id) . ' – ' . $subscription_type_name;
}
/**
* {@inheritDoc}
*/
public function buildEmbed(string $url): array
{
$build = [
'#type' => 'inline_template',
'#template' => '<iframe class="subman-embed" src="{{ url }}"></iframe>',
'#cache' => ['max-age' => 0],
'#context' => [
'url' => $url,
],
'#attached' => [
'library' => ['subman/iframe_embed'],
],
];
return $build;
}
/**
* {@inheritDoc}
*/
public function buildEmbedSignup(string $embedParameter): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function buildEmbedSelfservice(string $embedParameter): array
{
return [];
}
/**
* Send the notification of given key to given user.
*
* @param \Drupal\user\UserInterface $user
* The user to send the notification to.
* @param string $notificationKey
* The notification to send. Can be configured via:
* $settings['subman.settings']['notifications']['trial_ending'] = [
* 'subject' => 'A notification from [site:name]',
* 'body' => "Your subscription on [site:name] is there for you.",
* ];.
*/
protected function notifyUser(UserInterface $user, string $notificationKey)
{
$notifications = [];
// Add Trial ending notification, if enabled:
if (\Drupal::config('subman.settings')->get('notifications.trial_ending.enabled')) {
$notifications[static::NOTIFY_TRIAL_ENDING] = [
'subject' => \Drupal::config('subman.settings')->get('notifications.trial_ending.subject'),
'body' => \Drupal::config('subman.settings')->get('notifications.trial_ending.body'),
];
}
$notification = $notifications[$notificationKey] ?? NULL;
if (!empty($notification) && isset($notification['body'])) {
// Translate template, subject and text.
$subject = $this->t($notification['subject'], [], ['langcode' => $user->getPreferredLangcode()]);
$text = $this->t($notification['body'], [], ['langcode' => $user->getPreferredLangcode()]);
// Replace tokens using $user for [user].
$token_service = \Drupal::token();
$data = ['user' => $user];
$subject = $token_service->replace($subject, $data, ['clear' => TRUE]);
$text = $token_service->replace($text, $data, ['clear' => TRUE]);
// Send notification.
$result = $this->notifyUserSend($user, $subject, $text, $notificationKey);
// Log any fail.
if (!$result) {
$this->utils->log('notifyUser(): Failed sending notification @notification_key to @user', [], [
'@notification_key' => $notificationKey,
'@user' => $user->id(),
], 'error');
}
}
}
/**
* Actually send a notification.
*
* By default, mail is used. To use a different notification channel,
* override this method in a derived and swapped out service class.
*
* @param \Drupal\user\UserInterface $user
* The user to notify.
* @param string $subject
* The subject text of the notification.
* @param string $text
* The body text of the notification.
* @param string $notificationKey
* The key specifying of the notification.
*
* @return bool
* Whether the operation was successful (true) or not (false).
*/
protected function notifyUserSend(UserInterface $user, string $subject, string $text, string $notificationKey): bool
{
$mailManager = \Drupal::service('plugin.manager.mail');
$module = 'subman';
$key = 'notification';
$to = $user->getEmail();
$langcode = $user->getPreferredLangcode();
$params['notification_subject'] = $subject;
$params['notification_text'] = $text;
$params['notification_key'] = $notificationKey;
$send = TRUE;
$result = $mailManager->mail($module, $key, $to, $langcode, $params, NULL, $send);
return (($result['result'] ?? FALSE) === TRUE);
}
}
