billwerk_subscriptions-1.x-dev/src/Api.php

src/Api.php
<?php

declare(strict_types=1);

namespace Drupal\billwerk_subscriptions;

use Drupal\Component\Serialization\Json;
use Drupal\billwerk_subscriptions\Exception\ApiException;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Client\ClientInterface;

/**
 * API to interact with the SaaS API of Billwerk via REST.
 *
 * This service class provides the means to actually connect to Billwerk and do
 * thing like:
 * * Authorize against the SaaS api
 * * Find and retrieve SaaS data
 * * Send requests to the SaaS api
 * * Handle responses and basic error situations.
 *
 * Originally inspired by
 * https://github.com/billwerk/billwerkjs-jquery-php/blob/master/iteroapi.php
 */
class Api {

  /**
   * Amount of seconds the authentication will be cached.
   *
   * @var int
   *   Seconds until cache expiration.
   */
  protected static $authCacheExpiration = 60 * 60;

  const AUTH_PREFIX = 'Authorization: Bearer ';
  const AUTH_CACHE_KEY = 'BillwerkAp::auth_token';
  const RESPONSE_CACHE_NAME = 'BillwerkApi::get::requestCache';

  const ERROR_STREAM_CACHE_NAME = 'BillwerkApi::getErrorStream';

  /**
   * The constructor.
   *
   * @param \GuzzleHttp\Client $httpClient
   *   The HTTP client.
   * @param \Drupal\billwerk_subscriptions\Environment $environment
   *   The environment.
   * @param \Drupal\billwerk_subscriptions\SettingsHelper $settingsHelper
   *   The settings helper.
   * @param \Drupal\billwerk_subscriptions\LogHelper $logHelper
   *   The log helper.
   * @param \Drupal\billwerk_subscriptions\CacheHelper $cacheHelper
   *   The cache helper.
   */
  public function __construct(
    protected readonly ClientInterface $httpClient,
    protected readonly Environment $environment,
    protected readonly SettingsHelper $settingsHelper,
    protected readonly LogHelper $logHelper,
    protected readonly CacheHelper $cacheHelper,
  ) {
  }

  /**
   * Ensures there has been an authentication towards the API.
   */
  protected function ensureAuthorization($environment = NULL): void {
    if (empty($this->getAuthToken())) {
      $client_id = $this->environment->getClientId();
      $client_secret = $this->environment->getClientSecret();
      $auth = $this->authorize($client_id, $client_secret);
      $this->setAuthToken($auth);
    }
  }

  /**
   * Retrieves auth token from billwerk API.
   *
   * @param string $clientId
   *   The client ID to be used for authentication.
   * @param string $clientSecret
   *   The client secret to be used for authentication.
   *
   * @return string
   *   The authorization token obtained.
   */
  protected function authorize(string $clientId, string $clientSecret): string {
    $data = ['grant_type' => 'client_credentials'];
    try {
      $response = $this->httpClient->request('POST', $this->environment->getApiUrl(Environment::API_OAUTH_PATH), [
        'auth' => [$clientId, $clientSecret],
        'headers' => [
          'Content-Type' => 'application/x-www-form-urlencoded',
        ],
        'form_params' => $data,
      ]);
    }
    catch (ClientException $e) {
      $this->handleClientException($e);
      throw new ApiException('Could not authorize with provider.');
    }
    $body = $response->getBody();
    // Save auth token.
    $token = json_decode($body->getContents());

    return self::AUTH_PREFIX . $token->access_token;
  }

  /**
   * Returns the current token for authentication.
   *
   * @return string|null
   *   The current authorization token.
   */
  protected function getAuthToken(): ?string {
    // Retrieve cached auth token.
    $auth = $this->cacheHelper->load(self::AUTH_CACHE_KEY);
    return $auth;
  }

  /**
   * Stores the given token to be used as current authentication token.
   *
   * For not running into quota limits for auth token retrieval,
   * we store a retrieved auth token in cache for some time (1h by default).
   * Compare https://developer.billwerk.io/Docs/ApiIntroduction#authentication
   * By choosing a cache duration that is less than the SaaS own expiration
   * time, we practically circumvent the need for complex detection of
   * invalid-auth responses, re-authentication and re-sending of quests.
   *
   * @param string $auth
   *   The authorization token to be set.
   */
  protected function setAuthToken(string $auth): void {
    // Cache auth token.
    $this->cacheHelper->save(self::AUTH_CACHE_KEY, $auth, time() + self::$authCacheExpiration);

    // Log retrieval/caching of new auth token:
    $this->logHelper->debug(
      __CLASS__ . '::' . __FUNCTION__ . ': Authentication token retrieved and cached for @time seconds.',
      ['@time' => self::$authCacheExpiration],
      ['auth token excerpt' => substr($auth, strlen(self::AUTH_PREFIX), 5) . '...' . substr($auth, -6)],
      'api'
    );
  }

  /**
   * Issue a GET request to the API.
   *
   * Use drupal_static temporary static caching of responses during one Drupal
   * execution, to minimize API calls.
   *
   * @param string $resource
   *   The URL of the resource to get.
   *
   * @return mixed
   *   The retrieved data / result.
   */
  private function get(string $resource): mixed {
    $cache_key = 'GET:' . $resource;
    $cache = $this->settingsHelper->getApiResponseCacheEnabled();
    $responseCache = &drupal_static(self::RESPONSE_CACHE_NAME, []);
    $result = !$cache ? NULL : ($responseCache[$cache_key] ?? NULL);
    $in_cache = !empty($result);

    // If request is not in cache, perform real request.
    if (!$in_cache) {
      // Make sure we're authorized.
      $this->ensureAuthorization();

      // Assemble request options.
      $token = str_replace('Authorization: Bearer ', '', $this->getAuthToken());
      $guzzle_options = [
        'headers' => [
          'Authorization' => 'Bearer ' . $token,
          'Accept' => 'application/json',
          'Content-Type' => 'application/x-www-form-urlencoded',
        ],
        'debug' => FALSE,
        'stream' => FALSE,
      ];

      // Send the request and get the response object.
      $url = $this->environment->getApiUrl($resource);
      $response = $this->httpClient->get($url, $guzzle_options);
      $status = $response->getStatusCode();
      // @improve check whether other status codes have to be considered ok:
      $status_ok = $status == 200;

      if ($status_ok) {
        // Get json string from response.
        $result = $response->getBody()->getContents();

        // Cache response, if caching requests is enabled.
        if ($cache) {
          $responseCache[$cache_key] = $result;
        }
      }

      // Log.
      $this->logHelper->log(
        __CLASS__ . '::' . __FUNCTION__ . ': Sending GET request to @url. Status @status "@reason". Result size in bytes: @size',
        [
          '@url' => $url,
          '@status' => $status,
          '@reason' => $response->getReasonPhrase(),
          '@size' => strlen($result),
        ],
        [],
        'api',
        $status_ok ? 'debug' : 'error'
      );
    }

    // Convert result from json string to PHP structure.
    return Json::decode($result);
  }

  /**
   * Issue a POST request to the API.
   *
   * @param string $resource
   *   The URL of the resource to POST to.
   * @param mixed $data
   *   The data to post.
   *
   * @return mixed
   *   The result of the operation.
   */
  private function post(string $resource, $data = NULL): mixed {
    $this->ensureAuthorization();
    if (is_array($data) && empty($data)) {
      // Billwerk expects objects instead of an empty array!
      $data = new \stdClass();
    }
    $json = json_encode($data);
    $token = str_replace('Authorization: Bearer ', '', $this->getAuthToken());
    $headers = [
      'Authorization' => 'Bearer ' . $token,
      'Content-Type' => 'application/json',
      'Accept' => 'application/json',
      'Content-Length' => strlen($json),
    ];
    $url = $this->environment->getApiUrl($resource);
    $request = new Request('POST', $url, $headers, $json);
    // No status code checks required, 4xx and 5xx will throw errors.
    $response = $this->httpClient->send($request);
    // Get json string from response.
    return json_decode($response->getBody()->getContents());
  }

  /**
   * Issue a PUT request to the API.
   *
   * @param string $resource
   *   The URL of the resource to put to.
   * @param mixed $data
   *   The data to replace the resource with.
   *
   * @return mixed
   *   The result of the operation.
   */
  private function put(string $resource, $data): mixed {
    $this->ensureAuthorization();
    if (is_array($data) && empty($data)) {
      // Billwerk expects objects instead of an empty array!
      $data = new \stdClass();
    }
    $json = json_encode($data);

    $token = str_replace('Authorization: Bearer ', '', $this->getAuthToken());
    $headers = [
      'Authorization' => 'Bearer ' . $token,
      'Content-Type' => 'application/json',
      'Accept' => 'application/json',
      'Content-Length' => strlen($json),
    ];
    $url = $this->environment->getApiUrl($resource);
    $request = new Request('PUT', $url, $headers, $json);
    // No status code checks required, 4xx and 5xx will throw errors.
    $response = $this->httpClient->send($request);
    // Get json string from response.
    return json_decode($response->getBody()->getContents());
  }

  /**
   * Issue a PATCH request to the API.
   *
   * @param string $resource
   *   The URL of the resource to patch to.
   * @param mixed $data
   *   The data to patch.
   *
   * @return mixed
   *   The result of the operation.
   */
  private function patch(string $resource, $data): mixed {
    $this->ensureAuthorization();
    if (is_array($data) && empty($data)) {
      // Billwerk expects objects instead of an empty array!
      $data = new \stdClass();
    }
    $json = json_encode($data);

    $token = str_replace('Authorization: Bearer ', '', $this->getAuthToken());
    $headers = [
      'Authorization' => 'Bearer ' . $token,
      'Content-Type' => 'application/json',
      'Accept' => 'application/json',
      'Content-Length' => strlen($json),
    ];
    $url = $this->environment->getApiUrl($resource);
    $request = new Request('PATCH', $url, $headers, $json);
    // No status code checks required, 4xx and 5xx will throw errors.
    $response = $this->httpClient->send($request);
    // Get json string from response:
    return json_decode($response->getBody()->getContents());
  }

  /**
   * Issue a DELETE request to the API.
   *
   * @param string $resource
   *   The URL of the resource to put to.
   *
   * @return mixed
   *   The result of the operation.
   */
  private function delete(string $resource): mixed {
    $this->ensureAuthorization();
    $token = str_replace('Authorization: Bearer ', '', $this->getAuthToken());
    $headers = [
      'Authorization' => 'Bearer ' . $token,
      'Content-Type' => 'application/json',
      'Accept' => 'application/json',
    ];
    $request = new Request('DELETE', $resource, $headers);
    // No status code checks required, 4xx and 5xx will throw errors.
    $response = $this->httpClient->send($request);
    // Get json string from response:
    return json_decode($response->getBody()->getContents());
  }

  /**
   * Handles a client exception.
   *
   * @param \GuzzleHttp\Exception\ClientException $e
   *   The exception to handle.
   */
  protected function handleClientException(ClientException $e): void {
    // An exception was raised but there is an HTTP response body
    // with the exception (in case of 404 and similar errors)
    $request = $e->getRequest();
    $response = $e->getResponse();

    // @todo Do we need to remove credentials here from the request to not log them?
    $this->logHelper->error('API Client Exception: "%response" (Statuscode: "%statuscode" %reasonphrase). Request: "%request".',
      [
        "%response" => Message::toString($response),
        "%statuscode" => $response->getStatusCode(),
        "%reasonphrase" => $response->getReasonPhrase(),
        "%request" => Message::toString($request),
      ],
      [],
      'api');
  }

  /**
   * Retrieves token for accessing hosted self service pages.
   *
   * @param string $contractId
   *   The contract to get the selfservice token for.
   *
   * @return array
   *   The selfservice token information array retrieved.
   *   Contains "Expiry", "Token", "Purpose", "Url".
   */
  public function getSelfserviceTokenArray(string $contractId): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}/selfServiceToken");
  }

  /**
   * Gets the individual selfservice Token for the $contractId.
   *
   * @param string $contractId
   *   The contract id to get the selfservice token for.
   *
   * @return ?string
   *   The selfservice token or NULL if not set.
   */
  public function getSelfserviceToken(string $contractId): ?string {
    $response = $this->getSelfserviceTokenArray($contractId);

    return $response['Token'] ?? NULL;
  }

  /**
   * Gets the individual selfservice URL for the $contractId.
   *
   * @param string $contractId
   *   The contract id to get the selfservice token for.
   *
   * @return ?string
   *   The selfservice URL.
   */
  public function getSelfserviceUrl(string $contractId): ?string {
    $response = $this->getSelfserviceTokenArray($contractId);

    return $response['Url'] ?? NULL;
  }

  /**
   * Fetch all contracts from API.
   *
   * @return array
   *   The contracts data as array.
   */
  public function getContracts(): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH);
  }

  /**
   * Fetch contract by id from API.
   *
   * Example:
   * ```
   * {
   *   "Id": "599d51f881b1f00a28f7ae9e",
   *   "LastBillingDate": "2023-12-28T06:01:46.5518544Z",
   *   "NextBillingDate": "2023-12-28T06:01:46.5518548Z",
   *   "PlanId": "599d51f881b1f00a28f7ae9f",
   *   "CustomerId": "599d51f881b1f00a28f7ae9g",
   *   "IsDeletable": false,
   *   "LifecycleStatus": "Active",
   *   "CustomerName": "Marcellus Wallace",
   *   "CustomerIsLocked": false,
   *   "Phases": [
   *     {
   *       "Type": "Normal",
   *       "StartDate": "2023-12-28T06:01:46.5518566Z",
   *       "PlanVariantId": "599d51f881b1f00a28f7ae9h",
   *       "PlanId": "599d51f881b1f00a28f7ae9i",
   *       "InheritStartDate": false
   *     },
   *     {
   *       "Type": "Trial",
   *       "StartDate": "2023-12-28T06:01:46.5518575Z",
   *       "PlanVariantId": "599d51f881b1f00a28f7ae9j",
   *       "PlanId": "599d51f881b1f00a28f7ae9k",
   *       "InheritStartDate": false
   *     }
   *   ],
   *   "Balance": 0,
   *   "Currency": "EUR",
   *   "PlanGroupId": "599d51f881b1f00a28f7ae9l",
   *   "PaymentBearer": {
   *     "CardType": "Visa",
   *     "ExpiryMonth": 12,
   *     "ExpiryYear": 2020,
   *     "Holder": "Marcellus Wallace",
   *     "Last4": "1234",
   *     "Type": "CreditCard",
   *     "Country": "DE"
   *   },
   *   "PaymentProvider": "PayOne",
   *   "EscalationSuspended": false,
   *   "RecurringPaymentsPaused": false,
   *   "CurrentPhase": {
   *     "Type": "Normal",
   *     "StartDate": "2023-12-28T06:01:46.5518605Z",
   *     "PlanVariantId": "599d51f881b1f00a28f7ae9m",
   *     "PlanId": "599d51f881b1f00a28f7ae9n",
   *     "InheritStartDate": false
   *   },
   *   "PaymentProviderSupportRefunds": false,
   *   "BillingSuspended": false,
   *   "ThresholdBillingDisabled": false,
   *   "TimeGranularity": "Precise",
   *   "StartDate": "2023-12-28T06:01:46.5518610Z",
   *   "EndDate": "2023-12-28T06:01:46.5518612Z",
   *   "BilledUntil": "2023-12-28T06:01:46.5518614Z",
   *   "PlanVariantId": "599d51f881b1f00a28f7ae9o",
   *   "Notes": "NoteExample"
   * }
   * ```
   *
   * @param string $contractId
   *   The id of the contract to get.
   *
   * @return array
   *   The contract.
   */
  public function getContract(string $contractId): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}");
  }

  /**
   * Returns the contract changes information.
   *
   * @return array
   *   The contract changes.
   */
  public function getContractChanges(): array {
    return $this->get(Environment::API_CONTRACTCHANGES_RESOURCE_PATH);
  }

  /**
   * Returns the contract change information.
   *
   * @param string $id
   *   The contract id.
   *
   * @return array
   *   The contract change.
   */
  public function getContractChange(string $id): array {
    return $this->get(Environment::API_CONTRACTCHANGES_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Returns the customers information.
   *
   * @return array
   *   The customers.
   */
  public function getCustomers(): array {
    return $this->get(Environment::API_CUSTOMERS_RESOURCE_PATH);
  }

  /**
   * Fetch customer by id from API.
   *
   * Example response:
   * ```
   * {
   *   "Id": "5996a94681b200088ca84f1a",
   *   "CreatedAt": "2023-12-28T06:01:46.7935616Z",
   *   "IsDeletable": false,
   *   "DeletedAt": "0001-01-01T00:00:00.0000000Z",
   *   "IsLocked": false,
   *   "CustomerName": "Wallace, Marcellus",
   *   "CustomerSubName": "",
   *   "FirstName": "Marcellus",
   *   "LastName": "Wallace",
   *   "Language": "en-US",
   *   "EmailAddress": "marcellus@example.org",
   *   "AdditionalEmailAddresses": [
   *     {
   *       "EmailAddress": "marcellus02@example.com"
   *     },
   *     {
   *       "EmailAddress": "marcellus03@example.com"
   *     }
   *   ],
   *   "Address": {
   *     "Street": "Raymond Ave (Holly)",
   *     "HouseNumber": "145",
   *     "PostalCode": "91001",
   *     "City": "Pasadena",
   *     "Country": "CA"
   *   },
   *   "AdditionalAddresses": [
   *     {
   *       "FirstName": "Martin",
   *       "LastName": "Schmidt",
   *       "CompanyName": "Billwerk",
   *       "Street": "Schillergasse",
   *       "HouseNumber": "17",
   *       "PostalCode": "89278",
   *       "City": "Frankfurt am Main",
   *       "Country": "DE"
   *     }
   *   ],
   *   "Locale": "en-US",
   *   "CustomFields": {
   *     "CustomFieldName": "CustomFieldValue",
   *     "Name": "Value"
   *   },
   *   "DefaultBearerMedium": "Email",
   *   "CustomerType": "Consumer",
   *   "Hidden": false
   * }
   * ```
   *
   * @param string $customerId
   *   The id of the customer to get.
   *
   * @return array
   *   The customer DTO.
   */
  public function getCustomer(string $customerId): array {
    return $this->get(Environment::API_CUSTOMERS_RESOURCE_PATH . "/{$customerId}");
  }

  /**
   * Replace customer data entirely in/via API.
   *
   * @param array $customerData
   *   The customer data to set from as array.
   *
   * @return array
   *   The result of the operation.
   */
  public function putCustomer(array $customerData): array {
    return $this->put(Environment::API_CUSTOMERS_RESOURCE_PATH . "/{$customerData['Id']}", $customerData);
  }

  /**
   * Update single customer data in/via API.
   *
   * @param string $customerId
   *   The id of the customer to update.
   * @param array $customerData
   *   The customer data to update from as array.
   *
   * @return array
   *   The result of the operation.
   */
  public function patchCustomer(string $customerId, array $customerData): array {
    return $this->patch(Environment::API_CUSTOMERS_RESOURCE_PATH . "/{$customerId}", $customerData);
  }

  /**
   * Update single customer locked state in/via API.
   *
   * @param string $customerId
   *   The ID of the customer to update.
   * @param bool $isLocked
   *   True if the user should be set locked, false to set unlocked.
   *
   * @throws \Exception
   *   If the operation fails.
   */
  public function customerUpdateLockedState(string $customerId, bool $isLocked): void {
    $isLockedString = $isLocked ? 'true' : 'false';

    // Assuming $this->post() returns a result that can
    // be returned by this function.
    $this->post(Environment::API_CUSTOMERS_RESOURCE_PATH . "/updatelockedstate?id={$customerId}&isLocked={$isLockedString}");
  }

  /**
   * Delete the customer.
   *
   * Deletes the customer and all its personal data in accordance with GDPR and
   * accounting principles.
   *
   * If the customer had some contracts with ledger entry, billwerk will archive
   * the invoices and other relevant data. To ensure compliance with data
   * protection laws, customer data will be anonymized before archiving.
   *
   * @param string $customerId
   *   The id of the customer to delete.
   *
   * @see https://billwerk.readme.io/reference/contracts_delete_id_delete
   *
   * @return array
   *   The result of the operation.
   */
  public function deleteCustomer(string $customerId): array {
    return $this->delete(Environment::API_CUSTOMERS_RESOURCE_PATH . "/{$customerId}");
  }

  /**
   * Fetch customer by email address (if existing)
   *
   * @param string $email
   *   The email address of the customer to get.
   *
   * @return array
   *   The customer DTO or NULL if the customer could not be found.
   */
  public function getCustomerByEmail(string $email): array {
    // @improve as soon as Billwerk offers a search for specific fields, try to use that.
    // (the current search across multiple fields might turn up false positives)
    $results = $this->searchCustomers($email, 1);
    return empty($results) ? NULL : reset($results);
  }

  /**
   * Fetch customer by their ExternalId.
   *
   * Typically, in the context of this module, the ExternalId is the Drupal User
   * ID (UID).
   *
   * @param string $externalId
   *   The ExternalId to search the Billwerk Customers for.
   *
   * @return array
   *   The customer DTO or NULL if the customer could not be found.
   */
  public function getCustomerByExternalId(string $externalId): ?array {
    $querystring = "?externalId={$externalId}&take=1";
    $results = $this->get(Environment::API_CUSTOMERS_SEARCH_RESOURCE_PATH . "/{$querystring}");
    // Return the first result from the array:
    return empty($results) ? NULL : reset($results);
  }

  /**
   * Fetch customer from API by a search term.
   *
   * @param string $search
   *   The search term to filter customers on.
   * @param int $limit
   *   An optional limit.
   *
   * @return array
   *   The customers data as array.
   */
  public function searchCustomers(string $search, ?int $limit = NULL): array {
    $querystring = "?search=$search";
    if ($limit) {
      $querystring .= '&take=' . $limit;
    }
    return $this->get(Environment::API_CUSTOMERS_SEARCH_RESOURCE_PATH . "/{$querystring}");
  }

  /**
   * Returns the invoice information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The invoice.
   */
  public function getInvoice(string $id): array {
    return $this->get(Environment::API_INVOICES_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Returns the invoice change information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getInvoiceUsage(string $id): array {
    return $this->get(Environment::API_INVOICES_RESOURCE_PATH . "/{$id}/usage");
  }

  /**
   * Gets a list of contracts for given customer id.
   *
   * @param string $customerId
   *   The customer id.
   *
   * @return array
   *   The contracts data as nested array.
   */
  public function getCustomerContracts(string $customerId): array {
    $url = Environment::API_CUSTOMERS_RESOURCE_PATH . "/{$customerId}/contracts";
    return $this->get($url);
  }

  /**
   * Returns the orders information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getOrders(): array {
    return $this->get(Environment::API_ORDERS_RESOURCE_PATH);
  }

  /**
   * Returns the order information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getOrder(string $id): array {
    return $this->get(Environment::API_ORDERS_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Returns the PlanGroups (aka Component) information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPlanGroups(): array {
    return $this->get(Environment::API_PLANGROUPS_RESOURCE_PATH);
  }

  /**
   * Returns the PlanGroup (aka Component) information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPlanGroup(string $id): array {
    return $this->get(Environment::API_PLANGROUPS_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Gets a list of plans.
   *
   * @return array
   *   The plans data as nested array.
   */
  public function getPlans(): array {
    return $this->get(Environment::API_PLANS_RESOURCE_PATH);
  }

  /**
   * Returns the plan information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPlan(string $id): array {
    return $this->get(Environment::API_PLANS_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Gets a list of plan variants.
   *
   * @return array
   *   The plan variants data as nested array.
   */
  public function getPlanVariants(): array {
    return $this->get(Environment::API_PLANVARIANTS_RESOURCE_PATH);
  }

  /**
   * Returns the plan variant information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPlanVariant(string $id): array {
    return $this->get(Environment::API_PLANVARIANTS_RESOURCE_PATH . "/{$id}");
  }

  /**
   * Returns the pricelists information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPriceLists(): array {
    return $this->get(Environment::API_PRICELISTS_RESOURCE_PATH);
  }

  /**
   * Returns the pricelist information.
   *
   * @param string $id
   *   The id.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getPriceList(string $id): array {
    return $this->get(Environment::API_PRICELISTS_RESOURCE_PATH . "/{id}");
  }

  /**
   * Returns the hooks information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getWebhooks(): array {
    return $this->get(Environment::API_WEBHOOKS_RESOURCE_PATH);
  }

  /**
   * Returns the subscriptions information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getSubscriptions(): array {
    return $this->get(Environment::API_SUBSCRIPTIONS_RESOURCE_PATH);
  }

  /**
   * Returns the product information.
   *
   * @return array
   *   The Billwerk API response.
   */
  public function getProductInfo(): array {
    return $this->get(Environment::API_PRODUCTINFO_RESOURCE_PATH);
  }

  /**
   * Create a new plan/component subscription or up/downgrade order.
   *
   * @param array $data
   *   The order data.
   *
   * @return ?\stdClass
   *   OrderDTO.
   */
  public function createOrder(array $data): ?\stdClass {
    return $this->post(Environment::API_ORDERS_RESOURCE_PATH, $data);
  }

  /**
   * Create a new plan/component subscription or up/downgrade order preview.
   *
   * For example to show pricing information.
   *
   * @param array $data
   *   OrderDTO.
   *
   * @return ?\stdClass
   *   OrderDTO.
   */
  public function createOrderPreview(array $data): ?\stdClass {
    return $this->post(Environment::API_ORDERS_RESOURCE_PATH . '/preview', $data);
  }

  /**
   * Process and finalize an order.
   *
   * @param string $orderId
   *   Order ID.
   * @param array $data
   *   The order data.
   *
   * @return ?\stdClass
   *   OrderCommitDTO.
   */
  public function commitOrder(string $orderId, array $data = []): ?\stdClass {
    return $this->post(Environment::API_ORDERS_RESOURCE_PATH . "/{$orderId}/commit", $data);
  }

  /**
   * Approve an order.
   *
   * @param string $orderId
   *   Order ID.
   *
   * @return ?\stdClass
   *   The result of the operation.
   */
  public function approveOrder(string $orderId): ?\stdClass {
    return $this->post(Environment::API_ORDERS_RESOURCE_PATH . "/{$orderId}/approve");
  }

  /**
   * Decline an order.
   *
   * @param string $orderId
   *   Order ID.
   *
   * @return ?\stdClass
   *   The result of the operation.
   */
  public function declineOrder(string $orderId): ?\stdClass {
    return $this->post(Environment::API_ORDERS_RESOURCE_PATH . "/{$orderId}/decline");
  }

  /**
   * Deletes the order.
   *
   * @param string $orderId
   *   Order ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function deleteOrder(string $orderId): array {
    return $this->delete(Environment::API_ORDERS_RESOURCE_PATH . "/{$orderId}");
  }

  /**
   * Create customer in/via API.
   *
   * @param array $customerData
   *   The customer data to create from as array.
   *
   * @return ?\stdClass
   *   The result of the operation.
   */
  public function createCustomer(array $customerData): ?\stdClass {
    return $this->post(Environment::API_CUSTOMERS_RESOURCE_PATH, $customerData);
  }

  /**
   * Retrieves current subscriptions in the selected contract.
   *
   * @param string $contractId
   *   Contract ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getContractComponentSubscriptions(string $contractId): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}/ComponentSubscriptions");
  }

  /**
   * Create a new component subscription for this contract.
   *
   * @param string $contractId
   *   Contract ID.
   * @param array $data
   *   ComponentSubscriptionCreateDTO.
   *
   * @return ?\stdClass
   *   The result of the operation.
   */
  public function createContractComponentSubscription(string $contractId, array $data): ?\stdClass {
    return $this->post(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}/ComponentSubscriptions", $data);
  }

  /**
   * Returns the component subscription.
   *
   * @param string $componentSubscriptionId
   *   Component subscription ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getComponentSubscription(string $componentSubscriptionId): array {
    return $this->get(Environment::API_COMPONENT_SUBSCRIPTIONS_RESOURCE_PATH . "/{$componentSubscriptionId}");
  }

  /**
   * Retrieves all currently active subscriptions.
   *
   * Including plan variant, component subscriptions and discount subscriptions.
   *
   * Example response:
   * ```
   * {
   *   "Id": "658d0f4ac703e6a4d126704b",
   *   "Phase": {
   *     "Type": "Normal",
   *     "StartDate": "2023-06-28T06:01:46.5910722Z",
   *     "PlanVariantId": "5b20d2df81b1f00d1425280f",
   *     "PlanId": "5b20d2deba5c1e13409b15f2",
   *     "Quantity": 1,
   *     "InheritStartDate": false
   *   },
   *   "ComponentSubscriptions": [
   *     {
   *       "Id": "599d51f881b1f00a28f7ae9n",
   *       "ContractId": "599d51f881b1f00a28f7ae14",
   *       "CustomerId": "599d51f881b1f00a28f7asdf",
   *       "ComponentId": "599d51f881b1f00a28f7ae10",
   *       "Quantity": 1,
   *       "StartDate": "2023-11-28T06:01:46.5910686Z",
   *       "BilledUntil": "2024-01-28T06:01:46.5910665Z",
   *       "Status": "Active",
   *       "EndDate": "2024-06-28T06:01:46.5910681Z",
   *       "Memo": "Memoexample"
   *     },
   *     {
   *       "Id": "599d51f881b1f00a28f7ae8n",
   *       "ContractId": "599d51f881b1f00a28f7ae14",
   *       "CustomerId": "599d51f881b1f00a28f7asdf",
   *       "ComponentId": "599d51f881b1f00a28f7ae11",
   *       "Quantity": 0,
   *       "StartDate": "2023-11-28T06:01:46.5910695Z",
   *       "BilledUntil": "2024-01-28T06:01:46.5910692Z",
   *       "Status": "Active",
   *       "EndDate": "2024-06-28T06:01:46.5910694Z",
   *       "Memo": "Memoexample"
   *     }
   *   ],
   *   "DiscountSubscriptions": [
   *     {
   *       "Id": "599d51f881b1f00a28f7ae2m",
   *       "ContractId": "599d51f881b1f00a28f7ae14",
   *       "CouponCode": "50off",
   *       "CouponId": "599d51f881b1f00a28f7ae16",
   *       "DiscountId": "599d51f881b1f00a28f7ae18",
   *       "StartDate": "2023-12-28T06:01:46.5910710Z",
   *       "Status": "Active",
   *       "EndDate": "2024-06-28T06:01:46.5910708Z"
   *     },
   *     {
   *       "Id": "599d51f881b1f00a28f7ae2o",
   *       "ContractId": "599d51f881b1f00a28f7ae14",
   *       "CouponCode": "starter",
   *       "CouponId": "599d51f881b1f00a28f7ae17",
   *       "DiscountId": "599d51f881b1f00a28f7ae20",
   *       "StartDate": "2023-12-28T06:01:46.5910716Z",
   *       "Status": "Active",
   *       "EndDate": "2024-01-28T06:01:46.5910715Z"
   *     }
   *   ]
   * }
   * ```
   *
   * @param string $contractId
   *   Contract ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getContractSubscriptions(string $contractId): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}/Subscriptions");
  }

  /**
   * Retrieves the cancellation preview for this contract.
   *
   * Example response:
   * ```
   * {
   *    "NextPossibleCancellationDate": "2018-02-07T23:00:00Z",
   *    "EndDate": "2018-02-08T23:00:00Z",
   *    "Invoice": {...},
   *    "ContractAfter": {...}
   *  }
   * ```
   *
   * @param string $contractId
   *   Contract ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getContractCancellationPreview(string $contractId): array {
    return $this->get(Environment::API_CONTRACTS_RESOURCE_PATH . "/{$contractId}/CancellationPreview");
  }

  /**
   * Retrieves a list of all invoices / credit notes.
   *
   * @return array
   *   The result of the operation.
   */
  public function getInvoices(): array {
    return $this->get(Environment::API_INVOICES_RESOURCE_PATH);
  }

  /**
   * Retrieves an invoice by id.
   *
   * @param string $invoiceId
   *   Invoice ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getInvoiceById(string $invoiceId): array {
    return $this->get(Environment::API_INVOICES_RESOURCE_PATH . "/{$invoiceId}");
  }

  /**
   * Creates a file download token for the given invoice.
   *
   * @param string $invoiceId
   *   Invoice ID.
   * @param array $data
   *   The download link data.
   */
  public function getInvoiceDownloadLink(string $invoiceId, array $data): string {
    return $this->post(Environment::API_INVOICES_RESOURCE_PATH . "/{$invoiceId}/downloadLink", $data);
  }

  /**
   * Retrieves a list of all invoice drafts.
   *
   * @return array
   *   The result of the operation.
   */
  public function getInvoiceDrafts(): array {
    return $this->get(Environment::API_INVOICEDRAFTS_RESOURCE_PATH);
  }

  /**
   * Retrieves a draft by id.
   *
   * @param string $invoiceDraftId
   *   Invoice Draft ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function getInvoiceDraft(string $invoiceDraftId): array {
    return $this->get(Environment::API_INVOICEDRAFTS_RESOURCE_PATH . "/{$invoiceDraftId}");
  }

  /**
   * Create a new webhook subscription.
   *
   * @param array $data
   *   HookDTO.
   *
   * @return ?\stdClass
   *   The result of the operation.
   */
  public function createWebhook(array $data): ?\stdClass {
    return $this->post(Environment::API_WEBHOOKS_RESOURCE_PATH, $data);
  }

  /**
   * Deletes the webhook from the system.
   *
   * @param string $webhookId
   *   Webhook ID.
   *
   * @return array
   *   The result of the operation.
   */
  public function deleteWebhook(string $webhookId): array {
    return $this->delete(Environment::API_WEBHOOKS_RESOURCE_PATH . "/{$webhookId}");
  }

  /**
   * Gets the Environment used for the current API calls.
   *
   * @return \Drupal\billwerk_subscriptions\Environment
   *   The environment.
   */
  public function getEnvironment(): Environment {
    return $this->environment;
  }

  /**
   * Helper method to format a given $timestamp in the expected Billwerk format.
   *
   * Billwerk uses a certain date format, which we have to convert timestamps
   * into. This method furthermore allows modifications like a
   * low and high limit for the date range, because Billwerk is very strict
   * about subscription dates and prolongation periods.
   *
   * @param string $timestamp
   *   The unix timestamp to format.
   * @param string $min
   *   The minimum to round up to, if the $timestamp is lower.
   * @param string $max
   *   The maximum to round down to, if the $timestamp is lower.
   * @param bool $roundSeconds
   *   Round seconds to the nearest minute.
   *
   * @see https://support.billwerk.com/hc/en-us/articles/360020464720-What-to-consider-when-passign-a-date-and-times-
   *
   * @return string
   *   The formatted date time string in Billwerk expected ISO 8601 format
   *   format.
   */
  public static function billwerkDateFormat(int $timestamp, ?string $min = NULL, ?string $max = NULL, bool $roundSeconds = FALSE): string {
    if ($roundSeconds) {
      $timestamp = round($timestamp / 60) * 60;
    }
    // Create a DateTime object from the timestamp.
    $date = new \DateTimeImmutable('@' . $timestamp);
    if ($min !== NULL) {
      $minDate = self::billwerkParseDate($min);
      if ($minDate > $date) {
        $date = $minDate;
      }
    }
    if ($max !== NULL) {
      $maxDate = self::billwerkParseDate($max);
      if ($maxDate < $date) {
        $date = $maxDate;
      }
    }

    // Set the timezone to UTC.
    $date->setTimezone(new \DateTimeZone('UTC'));
    // Output in ISO 8601 format.
    return $date->format('Y-m-d\TH:i:s.u0\Z');
  }

  /**
   * Parses a given Billwerk Date in the format Y-m-d\TH:i:s.u0\Z to a DateTime.
   *
   * @param string $date
   *   The date string to parse.
   *
   * @return \DateTimeImmutable
   *   The parsed date.
   */
  public static function billwerkParseDate(string $date): \DateTimeImmutable {
    return \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.u0\Z', $date);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc