commercetools-8.x-1.2-alpha1/src/CommercetoolsCarts.php
src/CommercetoolsCarts.php
<?php
namespace Drupal\commercetools;
use Drupal\commercetools\Event\CommercetoolsOrderCreate;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\State\StateInterface;
use GraphQL\Actions\Mutation;
use GraphQL\Actions\Query;
use GraphQL\Entities\Node;
use GraphQL\Entities\Variable;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* The commercetools cart service.
*/
class CommercetoolsCarts {
/**
* Translatable order fields.
*/
const FIELDS_TRANSLATABLE_ORDER = [];
/**
* Translatable cart fields.
*/
const FIELDS_TRANSLATABLE_CART = [];
/**
* Translatable Line item fields.
*/
const FIELDS_TRANSLATABLE_LINE_ITEM = [
'name',
'productSlug',
];
const ACTIVE_CART_STATE = 'Active';
const SESSION_NAME_PREFIX = 'CT_CART_';
/**
* CommercetoolsCarts constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory service.
* @param \Drupal\commercetools\CommercetoolsService $ct
* The Commercetools service.
* @param \Drupal\commercetools\CommercetoolsApiServiceInterface $ctApi
* The Commercetools API service.
* @param \Drupal\commercetools\CommercetoolsCustomers $ctCustomers
* The Commercetools customers service.
* @param \Drupal\commercetools\CommercetoolsProducts $ctProducts
* The commercetools products service.
* @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
* The session.
* @param \Drupal\Core\State\StateInterface $state
* The Drupal state storage service.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
* An event dispatcher instance.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected CommercetoolsService $ct,
protected CommercetoolsApiServiceInterface $ctApi,
protected CommercetoolsCustomers $ctCustomers,
protected CommercetoolsProducts $ctProducts,
protected SessionInterface $session,
protected StateInterface $state,
protected EventDispatcherInterface $eventDispatcher,
) {
}
/**
* Get the user's current cart.
*
* Conditionally replaces the current cart with customer cart
* if customer ID exists and mismatches the current cart customer ID.
*
* @param bool $replace
* Indicates whether to replace a current cart with customer cart
* if cart customer ID mismatch.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse|null
* The cart CacheableCommercetoolsResponse or empty value.
*
* @todo Implement merge carts operation.
*/
public function getCurrentCart(bool $replace = TRUE): CacheableCommercetoolsResponse|null {
// Try to load cart by previously set cart ID.
if ($cartId = $this->getCurrentCartId()) {
$response = $this->loadCart($cartId);
$cart = $response->getData();
if (
$this->isCartMatchingAttributes($cart, [
'cartState' => self::ACTIVE_CART_STATE,
'customerId' => $this->ctCustomers->getCurrentUserCustomerId(),
])) {
$cacheableMetadata = $response->getCacheableMetadata();
return $this->ctApi->prepareResponse($cart, $cacheableMetadata);
}
else {
$this->deleteCurrentCart();
}
}
// Try to load cart by customer ID otherwise.
if ($customerId = $this->ctCustomers->getCurrentUserCustomerId()) {
$response = $this->loadCustomerCart($customerId);
$cart = $response->getData();
$cacheableMetadata = $response->getCacheableMetadata();
if ($this->isCartMatchingAttributes($cart, ['cartState' => self::ACTIVE_CART_STATE])) {
if ($replace) {
$this->setCurrentCart($cart);
}
return $this->ctApi->prepareResponse($cart, $cacheableMetadata);
}
}
return NULL;
}
/**
* Return the session key for storing the cart ID.
*/
protected function getSessionCartName(): string {
$connectionConfig = $this->ctApi->getConnectionConfig();
return self::SESSION_NAME_PREFIX . ':' . $connectionConfig[CommercetoolsApiServiceInterface::CONFIG_PROJECT_KEY] . ':' . $connectionConfig[CommercetoolsApiServiceInterface::CONFIG_HOSTED_REGION];
}
/**
* Set the user's current cart.
*
* @param array $cart
* The cart array.
*/
public function setCurrentCart(array $cart): void {
$this->session->set($this->getSessionCartName(), $cart['id']);
}
/**
* Get the user's current cart ID.
*
* @return string|null
* The cart ID if set.
*/
public function getCurrentCartId(): ?string {
return $this->session->get($this->getSessionCartName());
}
/**
* Unsets the user's current cart.
*/
public function deleteCurrentCart(): void {
$this->session->remove($this->getSessionCartName());
}
/**
* Create a user cart.
*
* @param array $draft
* The array of values which will be used for creating cart.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* Cacheable cart response.
*/
public function createCart(array $draft = []): CacheableCommercetoolsResponse {
$settingsLocale = $this->configFactory->get(CommercetoolsLocalization::CONFIGURATION_NAME);
// Add required fields.
$draft = $draft + array_filter([
'currency' => $settingsLocale->get(CommercetoolsLocalization::CONFIG_CURRENCY),
'country' => $settingsLocale->get(CommercetoolsLocalization::CONFIG_COUNTRY),
]);
// Set customer ID if available.
if ($customerId = $this->ctCustomers->getCurrentUserCustomerId()) {
$draft['customerId'] = $customerId;
}
$arguments = [
'draft' => new Variable('draft', 'CartDraft!'),
];
$cartMutation = new Mutation('createCart', $arguments);
$this->addCartFields($cartMutation);
$variables = array_filter([
'draft' => $draft,
]);
$response = $this->ctApi->executeGraphQlOperation($cartMutation, $variables);
$cart = $this->cartDataToCart($response->getData()['createCart']);
return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
}
/**
* Update cart.
*
* @param string $cartId
* The cart id.
* @param int $cartVersion
* The cart version.
* @param array $cartActions
* The array with update actions.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* Cacheable cart response.
*/
public function updateCart(string $cartId, int $cartVersion, array $cartActions): CacheableCommercetoolsResponse {
$arguments = [
'id' => new Variable('id', 'String'),
'version' => new Variable('version', 'Long!'),
'actions' => new Variable('actions', '[CartUpdateAction!]!'),
];
$updateCart = new Mutation('updateCart', $arguments);
$this->addCartFields($updateCart);
$variables = [
'id' => $cartId,
'version' => $cartVersion,
'actions' => $cartActions,
];
$response = $this->ctApi->executeGraphQlOperation($updateCart, $variables);
$cart = $this->cartDataToCart($response->getData()['updateCart']);
return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
}
/**
* Create order form the cart.
*
* @param array $cart
* The cart is used for creating order.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* Cacheable order response.
*/
public function createOrderFromCart(array $cart): CacheableCommercetoolsResponse {
$arguments = [
'draft' => new Variable('draft', 'OrderCartCommand!'),
];
$createOrder = new Mutation('createOrderFromCart', $arguments);
$this->addOrderFields($createOrder);
$variables = [
'draft' => [
'id' => $cart['id'],
'version' => $cart['version'],
'orderNumber' => $this->generateOrderNumber(),
'orderState' => 'Open',
'paymentState' => 'Pending',
'shipmentState' => 'Pending',
],
];
$response = $this->ctApi->executeGraphQlOperation($createOrder, $variables);
$order = $this->orderDataToOrder($response->getData()['createOrderFromCart']);
// Dispatch the order creation event.
$event = new CommercetoolsOrderCreate($order);
$this->eventDispatcher->dispatch($event);
return $this->ctApi->prepareResponse($order, $response->getCacheableMetadata());
}
/**
* Load cart by the card id.
*
* @param string $cartId
* The cart id.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* Cacheable cart response.
*/
public function loadCart(string $cartId): CacheableCommercetoolsResponse {
$arguments = [
'id' => new Variable('id', 'String!'),
];
$query = new Query('cart', $arguments);
$this->addCartFields($query);
$query->shippingAddress([])->use(...CommercetoolsCustomers::ADDRESS_FIELDS);
$query->billingAddress([])->use(...CommercetoolsCustomers::ADDRESS_FIELDS);
$response = $this->ctApi->executeGraphQlOperation($query, ['id' => $cartId]);
$cartData = $response->getData()['cart'] ?? [];
$cart = empty($cartData) ? [] : $this->cartDataToCart($cartData);
return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
}
/**
* Retrieves cart by the customer ID.
*
* @param string $customerId
* The given customer ID to query by.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* Cacheable cart response.
*
* @throws \Throwable
*/
public function loadCustomerCart(string $customerId): CacheableCommercetoolsResponse {
$arguments = [
'customerId' => new Variable('customerId', 'String!'),
];
$query = new Query('customerActiveCart', $arguments);
$this->addCartFields($query);
$response = $this->ctApi->executeGraphQlOperation($query, ['customerId' => $customerId]);
$data = $response->getData()['customerActiveCart'];
$cart = $data ? $this->cartDataToCart($data) : [];
return $this->ctApi->prepareResponse($cart, $response->getCacheableMetadata());
}
/**
* Retrieves orders based on the provided parameters.
*
* @param array $args
* Here the following array values are used:
* - sort: An associative array of sort options to apply to the query.
* - where: An string of filters to apply to the query.
* - offset: The number of orders to skip before starting to collect the
* result set. Defaults to 0.
* - limit: The maximum number of orders to return. Defaults to 10.
* @param bool|null $includeDetails
* Whether to include detailed order information. Defaults to NULL.
*
* @return \Drupal\commercetools\CacheableCommercetoolsResponse
* The response containing the product data.
*/
public function getOrders(array $args = [], ?bool $includeDetails = NULL): CacheableCommercetoolsResponse {
$variables = [
'limit' => $args['limit'] ?? 10,
'offset' => $args['offset'] ?? 0,
'sort' => $args['sort'] ?? NULL,
'where' => $args['where'] ?? NULL,
];
$arguments = [
'where' => new Variable('where', 'String'),
'sort' => new Variable('sort', '[String!]'),
'limit' => new Variable('limit', 'Int'),
'offset' => new Variable('offset', 'Int'),
];
$query = new Query('orders', $arguments);
$query->use('total');
$this->addOrderFields($query->results([]), $includeDetails);
$response = $this->ctApi->executeGraphQlOperation($query, $variables);
$result = $response->getData();
$orderDataList = $result['orders'] ?? ['results' => []];
foreach ($orderDataList['results'] as &$orderData) {
$orderData = $this->orderDataToOrder($orderData);
}
return $this->ctApi->prepareResponse($orderDataList, $response->getCacheableMetadata());
}
/**
* Add order fields to request node.
*
* @param \GraphQL\Entities\Node $orderQuery
* The order node to which the fields will be added.
* @param bool|null $includeDetails
* Whether to include detailed product information. Defaults to NULL.
*/
protected function addOrderFields(Node $orderQuery, ?bool $includeDetails = NULL): void {
$this->ct->addFieldsToNode(
$orderQuery,
[
'id',
'createdAt',
'orderNumber',
'orderState',
'customerId',
],
self::FIELDS_TRANSLATABLE_ORDER,
);
$this->ct->addPriceNodeFields($orderQuery->totalPrice([]));
$this->addLineItemsFields($orderQuery->lineItems([]));
if ($includeDetails) {
$this->ct->addPriceNodeFields($orderQuery->shippingInfo([])->use('shippingMethodName')->price([]));
$this->ct->addAddressNodeFields($orderQuery->shippingAddress([]));
$this->ct->addAddressNodeFields($orderQuery->billingAddress([]));
$taxedPriceQuery = $orderQuery->taxedPrice([]);
$taxPortions = $taxedPriceQuery->taxPortions([])->use('name', 'rate');
$this->ct->addPriceNodeFields($taxPortions->amount([]));
$this->ct->addPriceNodeFields($taxedPriceQuery->totalGross([]));
}
}
/**
* Converts the raw order data response to a simplified order array.
*
* @param array $orderData
* The array of the order response data.
*
* @return array
* The simplified order array.
*/
protected function orderDataToOrder(array $orderData): array {
$orderData['subtotalPrice'] = $this->ct->formatPrice(array_reduce($orderData['lineItems'], function ($carry, $item) {
$carry['centAmount'] += $item['totalPrice']['centAmount'];
return $carry;
}, ['centAmount' => 0] + $orderData['totalPrice']));
$orderData['totalPrice'] = $this->ct->formatPrice($orderData['totalPrice']);
$orderData['totalLineItemQuantity'] ??= 0;
$orderData['lineItems'] = $this->lineItemsDataToLineItems($orderData['lineItems']);
if (isset($orderData['shippingInfo'])) {
$orderData['shippingInfo']['price'] = $this->ct->formatPrice($orderData['shippingInfo']['price']);
}
$orderData['taxedPrice'] = empty($orderData['taxedPrice']) ? [] : array_filter($orderData['taxedPrice']);
if (!empty($orderData['taxedPrice'])) {
foreach ($orderData['taxedPrice']['taxPortions'] as &$portion) {
$portion['amount'] = $this->ct->formatPrice($portion['amount']);
}
$orderData['taxedPrice']['totalGross'] = $this->ct->formatPrice($orderData['taxedPrice']['totalGross']);
}
return $orderData;
}
/**
* Add cart fields to request node.
*
* @param \GraphQL\Entities\Node $cartQuery
* The cart node to which the fields will be added.
*/
protected function addCartFields(Node $cartQuery): void {
$this->ct->addFieldsToNode(
$cartQuery,
[
'id',
'cartState',
'version',
'customerId',
'totalLineItemQuantity',
],
self::FIELDS_TRANSLATABLE_CART,
);
$this->ct->addPriceNodeFields($cartQuery->totalPrice([]));
$this->ct->addAddressNodeFields($cartQuery->billingAddress([]));
$this->ct->addAddressNodeFields($cartQuery->shippingAddress([]));
$this->addCartDiscountsFields($cartQuery);
$this->addLineItemsFields($cartQuery->lineItems([]));
}
/**
* Add cart discounts fields.
*/
protected function addCartDiscountsFields($cartQuery): void {
$includedDiscounts = $cartQuery->discountOnTotalPrice([])->includedDiscounts([]);
$this->ct->addFieldsToNode(
$includedDiscounts->discount([]),
['id', 'name'],
['name'],
);
$this->ct->addPriceNodeFields(
$includedDiscounts->discountedAmount([])
);
$discountCode = $cartQuery->discountCodes([])->discountCode([]);
$this->ct->addFieldsToNode(
$discountCode,
['id', 'name', 'code'],
['name'],
);
$discountCode->cartDiscounts([])->use('id');
}
/**
* Add line items fields to request node.
*
* @param \GraphQL\Entities\Node $lineItemsQuery
* The line items node to which the fields will be added.
*/
protected function addLineItemsFields(Node $lineItemsQuery): void {
$this->ct->addFieldsToNode($lineItemsQuery, ['id', 'name', 'productSlug', 'quantity'], self::FIELDS_TRANSLATABLE_LINE_ITEM);
$lineItemsQuery->productType([])->use('id', 'key', 'name');
$this->ctProducts->addProductVariantFields($lineItemsQuery->variant([]));
$this->ct->addPriceNode($lineItemsQuery);
$this->ct->addPriceNodeFields($lineItemsQuery->totalPrice([]));
$this->addLineItemsDiscountsFields($lineItemsQuery);
}
/**
* Add line items discounts fields.
*/
protected function addLineItemsDiscountsFields(Node $lineItemsQuery): void {
$discountedPricePerQuantity = $lineItemsQuery->discountedPricePerQuantity([])->use('quantity');
$discountedPrice = $discountedPricePerQuantity->discountedPrice([]);
$this->ct->addPriceNodeFields(
$discountedPrice->value([]),
);
$discountedPriceAttrDefinition = $discountedPrice->includedDiscounts([]);
$discountedPriceAttrDefinition->discount([])->use('id');
$this->ct->addPriceNodeFields(
$discountedPriceAttrDefinition->discountedAmount([]),
);
}
/**
* Converts the raw cart data response to a simplified cart array.
*
* @param array $cartData
* The array of the cart response data.
*
* @return array
* The simplified cart array.
*/
protected function cartDataToCart(array $cartData): array {
$cartData['totalLineItemQuantity'] ??= 0;
$cartData['lineItems'] = $this->lineItemsDataToLineItems($cartData['lineItems']);
$this->enhanceTotalPrice($cartData);
$this->enhanceDiscounts($cartData);
return $cartData;
}
/**
* Enhance total price.
*/
protected function enhanceTotalPrice(array &$cartData): void {
$originalTotal = 0;
$discount = 0;
foreach ($cartData['lineItems'] as $lineItem) {
$originalTotal += $lineItem['originalTotalPrice']['centAmount'] ?? 0;
$discount += !empty($lineItem['discounted']['centAmount'])
? ($lineItem['price']['centAmount'] - $lineItem['discounted']['centAmount']) * $lineItem['quantity']
: 0;
}
$cartData['originalTotalPrice'] = $this->mergePriceData(['centAmount' => $originalTotal], $cartData['totalPrice']);
$cartData['discount'] = $this->mergePriceData(['centAmount' => $discount], $cartData['totalPrice']);
$cartData['totalPrice'] = $this->ct->formatPrice($cartData['totalPrice']);
$cartData['originalTotalPrice'] = $this->ct->formatPrice($cartData['originalTotalPrice']);
$cartData['discount'] = $this->ct->formatPrice($cartData['discount']);
$cartData['isDiscounted'] = $originalTotal !== $cartData['totalPrice']['centAmount'];
}
/**
* Enhance discounts.
*/
protected function enhanceDiscounts(array &$cartData): void {
foreach ($cartData['discountCodes'] as &$discountCode) {
$discountCode = $discountCode['discountCode'];
$refIds = array_column($discountCode['cartDiscounts'], 'id');
$totalCentAmount = 0;
foreach ($cartData['lineItems'] as $lineItem) {
foreach ($lineItem['discountedPricePerQuantity'] as $dp) {
foreach ($dp['discountedPrice']['includedDiscounts'] as $inc) {
$discountId = $inc['discount']['id'] ?? NULL;
if (in_array($discountId, $refIds, TRUE)) {
$totalCentAmount += $inc['discountedAmount']['centAmount']
? $inc['discountedAmount']['centAmount'] * $dp['quantity']
: 0;
}
}
}
}
if (!empty($cartData['discountOnTotalPrice']['includedDiscounts'])) {
foreach ($cartData['discountOnTotalPrice']['includedDiscounts'] as $inc) {
$id = $inc['discount']['id'] ?? NULL;
if (in_array($id, $refIds, TRUE)) {
$totalCentAmount += $inc['discountedAmount']['centAmount'] ?? 0;
}
}
}
$discountCode['amount'] = $this->ct->formatPrice(
$this->mergePriceData(['centAmount' => $totalCentAmount], $cartData['totalPrice'])
);
}
}
/**
* Converts the raw line items data response to a simplified line items array.
*
* @param array $lineItemsData
* The array of the line items response data.
*
* @return array
* The simplified line items array.
*/
protected function lineItemsDataToLineItems(array $lineItemsData): array {
foreach ($lineItemsData as &$lineItemData) {
$this->addDiscountDataToLineItem($lineItemData);
$lineItemData['discounted'] = isset($lineItemData['price']['discounted']['value'])
? $this->ct->formatPrice($lineItemData['price']['discounted']['value'])
: NULL;
$lineItemData['price'] = $this->ct->formatPrice($lineItemData['price']['value']);
// The slug value is crucial for rendering the product link, set the
// fallback value if it's missing.
$lineItemData['productSlug'] ??= CommercetoolsProducts::MISSING_SLUG_VALUE;
$lineItemData['totalPrice'] = $this->ct->formatPrice($lineItemData['totalPrice']);
$lineItemData['variant'] = $this->ctProducts->variantDataToVariant($lineItemData['variant'], $lineItemData['productType']['key']);
}
return $lineItemsData;
}
/**
* Add discounted fields to line items.
*/
protected function addDiscountDataToLineItem(array &$lineItemData): void {
$originalTotalPrice = $lineItemData['quantity'] * $lineItemData['price']['value']['centAmount'];
$lineItemData['originalTotalPrice'] = $this->mergePriceData(['centAmount' => $originalTotalPrice], $lineItemData['price']['value']);
$lineItemData['isDiscounted'] = $lineItemData['totalPrice']['centAmount'] !== $originalTotalPrice;
$lineItemData['originalTotalPrice'] = $this->ct->formatPrice($lineItemData['originalTotalPrice']);
}
/**
* Validates the cart by given attributes list.
*
* @param array $cart
* The cart data.
* @param array $attributes
* The list of attributes to validate given cart by.
*
* @return bool
* Indicates whether the cart matches all given attributes.
*/
protected function isCartMatchingAttributes(array $cart, array $attributes): bool {
$result = TRUE;
// Empty cart is invalid by default.
if (empty($cart)) {
return FALSE;
}
foreach ($attributes as $attribute => $value) {
if (!$value) {
continue;
}
if (empty($cart[$attribute]) || $cart[$attribute] !== $value) {
$result = FALSE;
}
}
return $result;
}
/**
* Temporary function for generation order number.
*
* @return string
* Generated order number.
*
* @todo Move to external service and make pattern configurable.
*/
public function generateOrderNumber(): string {
$sequence = $this->state->get('commercetools_order_sequence', 100);
$this->state->set('commercetools_order_sequence', ++$sequence);
return str_ireplace('{sequence}', $sequence, 'DRUPAL-ORDER-{sequence}');
}
/**
* Merges calculated price values with data fields from the original price.
*
* When computing a partial price, typically only
* `centAmount` is recalculated.
*
* This method pulls all other data fields (e.g., `currencyCode`,
* `fractionDigits`, etc.) from the original price and
* combines them with the calculated price array, producing a complete
* price structure.
*
* @param array $calculatedPrice
* An associative array containing the newly calculated price values,
* at minimum:
* - centAmount (int): the recalculated amount in the smallest currency
* unit. It may also include other computed fields.
* @param array $sourcePrice
* The source price array, which must include data fields such as:
* - currencyCode (string): currency code.
* - fractionDigits (int): number of decimal places.
* - any other display.
*
* @return array
* A merged price array with:
* - centAmount: from $calculatedPrice
* - currencyCode, fractionDigits, and other data: from $originalPrice
* - any additional computed fields: from $calculatedPrice
*/
protected function mergePriceData(array $calculatedPrice, array $sourcePrice): array {
return array_merge($sourcePrice, $calculatedPrice);
}
}
