<?php

namespace Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository;

use Ekyna\Component\Commerce\Common\Model\CountryInterface;
use Ekyna\Component\Commerce\Pricing\Repository\TaxRuleRepositoryInterface;
use Ekyna\Component\Resource\Doctrine\ORM\ResourceRepository;

/**
 * Class TaxRuleRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class TaxRuleRepository extends ResourceRepository implements TaxRuleRepositoryInterface
{
    /**
     * @var \Doctrine\ORM\Query
     */
    private $byCountryAndCustomerQuery;

    /**
     * @var \Doctrine\ORM\Query
     */
    private $byCountryAndBusinessQuery;

    /**
     * @inheritdoc
     */
    public function findOneByCountryForCustomer(
        CountryInterface $country
    ) {
        return $this
            ->getByCountryAndCustomerQuery()
            ->setParameter('country', $country)
            ->getOneOrNullResult();
    }

    /**
     * @inheritdoc
     */
    public function findOneByCountryForBusiness(
        CountryInterface $country
    ) {
        return $this
            ->getByCountryAndBusinessQuery()
            ->setParameter('country', $country)
            ->getOneOrNullResult();
    }

    /**
     * Returns the "find one by country and customer" query.
     *
     * @return \Doctrine\ORM\Query
     */
    private function getByCountryAndCustomerQuery()
    {
        if (null === $this->byCountryAndCustomerQuery) {
            $qb = $this->getBaseQueryBuilder();

            $this->byCountryAndCustomerQuery = $qb
                ->andWhere($qb->expr()->eq('r.customer', ':customer'))
                ->getQuery()
                ->setParameter('customer', true)
                ->setMaxResults(1);
        }

        return $this->byCountryAndCustomerQuery;
    }

    /**
     * Returns the "find one by country and business" query.
     *
     * @return \Doctrine\ORM\Query
     */
    private function getByCountryAndBusinessQuery()
    {
        if (null === $this->byCountryAndBusinessQuery) {
            $qb = $this->getBaseQueryBuilder();

            $this->byCountryAndBusinessQuery = $qb
                ->andWhere($qb->expr()->eq('r.business', ':business'))
                ->getQuery()
                ->setParameter('business', true)
                ->setMaxResults(1);
        }

        return $this->byCountryAndBusinessQuery;
    }

    /**
     * Returns the base query builder.
     *
     * @return \Doctrine\ORM\QueryBuilder
     */
    private function getBaseQueryBuilder()
    {
        $qb = $this->getQueryBuilder('r', 'r.id');

        return $qb
            ->andWhere(
                $qb->expr()->orX(
                    $qb->expr()->isMemberOf(':country', 'r.countries'),
                    'r.countries IS EMPTY'
                )
            )
            ->addOrderBy('r.priority', 'DESC');
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Exception\LogicException;
use Ekyna\Component\Commerce\Shipment\Model as Shipment;
use Ekyna\Component\Commerce\Payment\Model as Payment;
use Ekyna\Component\Resource\Model\TimestampableTrait;

/**
 * Class AbstractShipment
 * @package Ekyna\Component\Commerce\Shipment\Entity
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractShipment implements Shipment\ShipmentInterface
{
    use Common\NumberSubjectTrait,
        Common\StateSubjectTrait,
        TimestampableTrait,
        Shipment\ShipmentDataTrait;

    /**
     * @var int
     */
    protected $id;

    /**
     * @var Shipment\ShipmentMethodInterface
     */
    protected $method;

    /**
     * @var ArrayCollection|Shipment\ShipmentItemInterface[]
     */
    protected $items;

    /**
     * @var ArrayCollection|Shipment\ShipmentParcelInterface[]
     */
    protected $parcels;

    /**
     * @var bool
     */
    protected $autoInvoice;

    /**
     * @var bool
     */
    protected $return;

    /**
     * @var string
     */
    protected $description;

    /**
     * @var array
     */
    protected $gatewayData;

    /**
     * @var \DateTime
     */
    protected $shippedAt;

    /**
     * @var \DateTime
     */
    protected $completedAt;

    /**
     * @var array
     */
    protected $senderAddress;

    /**
     * @var array
     */
    protected $receiverAddress;

    /**
     * @var Shipment\RelayPointInterface
     */
    protected $relayPoint;

    /**
     * @var Payment\PaymentMethodInterface
     */
    protected $creditMethod;

    /**
     * Constructor.
     */
    public function __construct()
    {
        $this->state = Shipment\ShipmentStates::STATE_NEW;
        $this->items = new ArrayCollection();
        $this->parcels = new ArrayCollection();
        $this->return = false;
        $this->autoInvoice = true;

        $this->initializeShipmentData();
    }

    /**
     * @inheritDoc
     */
    public function __toString()
    {
        return $this->getNumber();
    }

    /**
     * @inheritdoc
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @inheritdoc
     */
    public function getMethod()
    {
        return $this->method;
    }

    /**
     * @inheritdoc
     */
    public function setMethod(Shipment\ShipmentMethodInterface $method)
    {
        $this->method = $method;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function isAutoInvoice()
    {
        return $this->autoInvoice;
    }

    /**
     * @inheritdoc
     */
    public function setAutoInvoice($auto)
    {
        $this->autoInvoice = (bool)$auto;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function hasItems()
    {
        return 0 < $this->items->count();
    }

    /**
     * @inheritdoc
     */
    public function getItems()
    {
        return $this->items;
    }

    /**
     * @inheritdoc
     */
    public function hasItem(Shipment\ShipmentItemInterface $item)
    {
        return $this->items->contains($item);
    }

    /**
     * @inheritdoc
     */
    public function addItem(Shipment\ShipmentItemInterface $item)
    {
        if (!$this->hasItem($item)) {
            $this->items->add($item);
            $item->setShipment($this);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function removeItem(Shipment\ShipmentItemInterface $item)
    {
        if ($this->hasItem($item)) {
            $this->items->removeElement($item);
            $item->setShipment(null);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function hasParcels()
    {
        return 0 < $this->parcels->count();
    }

    /**
     * @inheritdoc
     */
    public function getParcels()
    {
        return $this->parcels;
    }

    /**
     * @inheritdoc
     */
    public function hasParcel(Shipment\ShipmentParcelInterface $parcel)
    {
        return $this->parcels->contains($parcel);
    }

    /**
     * @inheritdoc
     */
    public function addParcel(Shipment\ShipmentParcelInterface $parcel)
    {
        if (!$this->hasParcel($parcel)) {
            $this->parcels->add($parcel);
            $parcel->setShipment($this);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function removeParcel(Shipment\ShipmentParcelInterface $parcel)
    {
        if ($this->hasParcel($parcel)) {
            $this->parcels->removeElement($parcel);
            $parcel->setShipment(null);
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function isReturn()
    {
        return $this->return;
    }

    /**
     * @inheritdoc
     */
    public function setReturn($return)
    {
        $this->return = (bool)$return;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * @inheritdoc
     */
    public function setDescription($description)
    {
        $this->description = $description;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getPlatformName()
    {
        if ($this->method) {
            return $this->method->getPlatformName();
        }

        throw new LogicException("Shipment method is not set.");
    }

    /**
     * @inheritdoc
     */
    public function getGatewayName()
    {
        if ($this->method) {
            return $this->method->getGatewayName();
        }

        throw new LogicException("Shipment method is not set.");
    }

    /**
     * @inheritdoc
     */
    public function getGatewayData()
    {
        return $this->gatewayData;
    }

    /**
     * @inheritdoc
     */
    public function setGatewayData(array $data = null)
    {
        $this->gatewayData = $data;

        return $this;
    }

    /**
     * Returns the "shipped at" date time.
     *
     * @return \DateTime
     */
    public function getShippedAt()
    {
        return $this->shippedAt;
    }

    /**
     * Sets the "shipped at" date time.
     *
     * @param \DateTime $shippedAt
     */
    public function setShippedAt(\DateTime $shippedAt = null)
    {
        $this->shippedAt = $shippedAt;
    }

    /**
     * @inheritdoc
     */
    public function getCompletedAt()
    {
        return $this->completedAt;
    }

    /**
     * @inheritdoc
     */
    public function setCompletedAt(\DateTime $completedAt = null)
    {
        $this->completedAt = $completedAt;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getSenderAddress()
    {
        return $this->senderAddress;
    }

    /**
     * @inheritdoc
     */
    public function setSenderAddress($data)
    {
        $this->senderAddress = empty($data) ? null : $data;

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getReceiverAddress()
    {
        return $this->receiverAddress;
    }

    /**
     * @inheritdoc
     */
    public function setReceiverAddress($data)
    {
        $this->receiverAddress = empty($data) ? null : $data;

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getRelayPoint()
    {
        return $this->relayPoint;
    }

    /**
     * @inheritDoc
     */
    public function setRelayPoint(Shipment\RelayPointInterface $relayPoint = null)
    {
        $this->relayPoint = $relayPoint;

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getCreditMethod()
    {
        return $this->creditMethod;
    }

    /**
     * @inheritDoc
     */
    public function setCreditMethod(Payment\PaymentMethodInterface $method = null)
    {
        $this->creditMethod = $method;

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function isEmpty()
    {
        foreach ($this->items as $item) {
            if (0 < $item->getQuantity()) {
                return false;
            }
        }

        return true;
    }

    /**
     * @inheritDoc
     */
    public function isPartial()
    {
        $coveredIds = [];

        // For each shipment items
        foreach ($this->items as $item) {
            // Retain sale item id
            $coveredIds[] = $item->getSaleItem()->getId();
            // If shipment item quantity does not equal sale item total quantity
            if ($item->getQuantity() != $item->getSaleItem()->getTotalQuantity()) {
                // Shipment is partial
                return true;
            }
        }

        $sale = $this->getSale();
        // For each sale items
        foreach ($sale->getItems() as $saleItem) {
            // IF sale item id is not in retained sale items ids
            if (!$this->isSaleItemCovered($saleItem, $coveredIds)) {
                // Shipment is partial
                return true;
            }
        }

        // Shipment is not partial
        return false;
    }

    /**
     * Returns whether the given sale item is covered by this shipment.
     *
     * @param Common\SaleItemInterface $saleItem
     * @param array                    $coveredIds
     *
     * @return bool
     */
    private function isSaleItemCovered(Common\SaleItemInterface $saleItem, array $coveredIds)
    {
        // Skip compound with only public children
        if ($saleItem->isCompound() && !$saleItem->hasPrivateChildren()) {
            return true;
        }

        if (!in_array($saleItem->getId(), $coveredIds, true)) {
            return false;
        }

        foreach ($saleItem->getChildren() as $child) {
            if (!$this->isSaleItemCovered($child, $coveredIds)) {
                return false;
            }
        }

        return true;
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Resolver;

use Ekyna\Component\Commerce\Common\Context\ContextProviderInterface;
use Ekyna\Component\Commerce\Common\Model\CountryInterface;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Pricing\Model\TaxInterface;
use Ekyna\Component\Commerce\Pricing\Resolver\TaxResolverInterface;
use Ekyna\Component\Commerce\Shipment\Gateway\RegistryInterface;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentMethodInterface;
use Ekyna\Component\Commerce\Shipment\Model\ResolvedShipmentPrice;
use Ekyna\Component\Commerce\Shipment\Repository\ShipmentPriceRepositoryInterface;
use Ekyna\Component\Commerce\Shipment\Repository\ShipmentRuleRepositoryInterface;

/**
 * Class ShipmentPriceResolver
 * @package Ekyna\Component\Commerce\Shipment\Resolver
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class ShipmentPriceResolver implements ShipmentPriceResolverInterface
{
    /**
     * @var ShipmentPriceRepositoryInterface
     */
    private $priceRepository;

    /**
     * @var ShipmentRuleRepositoryInterface
     */
    private $ruleRepository;

    /**
     * @var RegistryInterface
     */
    private $gatewayRegistry;

    /**
     * @var TaxResolverInterface
     */
    private $taxResolver;

    /**
     * @var ContextProviderInterface
     */
    private $contextProvider;

    /**
     * [
     *     {countryId} => [
     *         [
     *             'method'     => (ShipmentMethodInterface),
     *             'max_weight' => (float),
     *             'prices'     => [
     *                 [
     *                     'weight' => (float),
     *                     'price'  => (float),
     *                 ]
     *             ],
     *         ]
     *     ],
     * ]
     *
     * @var array
     */
    private $grids = [];

    /**
     * Constructor.
     *
     * @param ShipmentPriceRepositoryInterface $priceRepository
     * @param ShipmentRuleRepositoryInterface  $ruleRepository
     * @param RegistryInterface                $gatewayRegistry
     * @param TaxResolverInterface             $taxResolver
     * @param ContextProviderInterface         $contextProvider
     */
    public function __construct(
        ShipmentPriceRepositoryInterface $priceRepository,
        ShipmentRuleRepositoryInterface $ruleRepository,
        RegistryInterface $gatewayRegistry,
        TaxResolverInterface $taxResolver,
        ContextProviderInterface $contextProvider
    ) {
        $this->priceRepository = $priceRepository;
        $this->ruleRepository = $ruleRepository;
        $this->gatewayRegistry = $gatewayRegistry;
        $this->taxResolver = $taxResolver;
        $this->contextProvider = $contextProvider;
    }

    /**
     * @inheritdoc
     */
    public function hasFreeShipping(SaleInterface $sale, ShipmentMethodInterface $method = null)
    {
        return null !== $this->ruleRepository->findOneBySale($sale, $method);
    }

    /**
     * @inheritdoc
     */
    public function getAvailablePricesBySale(SaleInterface $sale, $availableOnly = true)
    {
        $country = $this->contextProvider->getContext($sale)->getDeliveryCountry();

        $prices = [];

        $grid = $this->getGridForCountry($country);

        foreach ($grid as $entry) {
            /** @var ShipmentMethodInterface $method */
            $method = $entry['method'];
            if ($availableOnly && !$method->isAvailable()) {
                continue;
            }

            $resolvedPrice = new ResolvedShipmentPrice($method, $sale->getWeightTotal());

            if ($rule = $this->ruleRepository->findOneBySale($sale, $method)) {
                $price = $rule->getNetPrice();
            } else {
                $price = $this->resolvePrice($entry, $sale->getWeightTotal());
            }

            $resolvedPrice
                ->setPrice($price)
                ->setTaxes($this->getTaxesRates($method, $country));

            $prices[] = $resolvedPrice;
        }

        return $prices;
    }

    /**
     * @inheritdoc
     */
    public function getPriceBySale(SaleInterface $sale)
    {
        if (null === $method = $sale->getShipmentMethod()) {
            throw new RuntimeException("Sale's shipment method must be set.");
        }

        $country = $this->contextProvider->getContext($sale)->getDeliveryCountry();

        $grid = $this->getGridForCountry($country);

        if (!isset($grid[$method->getId()])) {
            return null;
        }

        $resolvedPrice = new ResolvedShipmentPrice($method, $sale->getWeightTotal());

        if ($rule = $this->ruleRepository->findOneBySale($sale, $method)) {
            $price = $rule->getNetPrice();
        } else {
            $price = $this->resolvePrice($grid[$method->getId()], $sale->getWeightTotal());
        }

        return $resolvedPrice
            ->setPrice($price)
            ->setTaxes($this->getTaxesRates($method, $country));
    }

    /**
     * @inheritdoc
     */
    public function getPriceByCountryAndMethodAndWeight(
        CountryInterface $country,
        ShipmentMethodInterface $method,
        $weight
    ) {
        $grid = $this->getGridForCountry($country);

        if (!isset($grid[$method->getId()])) {
            return null;
        }

        $resolvedPrice = new ResolvedShipmentPrice($method, $weight);

        return $resolvedPrice
            ->setPrice($this->resolvePrice($grid[$method->getId()], $weight))
            ->setTaxes($this->getTaxesRates($method, $country));
    }

    /**
     * Resolves the price form the given grid entry and total weight.
     *
     * @param array $entry
     * @param float $weight
     *
     * @return float
     */
    private function resolvePrice(array $entry, $weight)
    {
        $price = $count = 0;

        if ($weight > $entry['max_weight']) {
            $count = floor($weight / $entry['max_weight']);
            $weight = round(fmod($weight, $count), 3);
        }

        if (0 < $count) {
            $max = end($entry['prices'])['price'];
            $price = $count * $max;
        }

        foreach ($entry['prices'] as $p) {
            // If sale weight is lower than price weight
            if (1 === bccomp($p['weight'], $weight, 3)) {
                $price += $p['price'];
                break;
            }
        }

        return $price;
    }

    /**
     * Returns the price grid for the given country.
     *
     * @param CountryInterface $country
     *
     * @return array|mixed
     */
    private function getGridForCountry(CountryInterface $country)
    {
        if (isset($this->grids[$country->getId()])) {
            return $this->grids[$country->getId()];
        }

        $grid = [];

        $prices = $this->priceRepository->findByCountry($country);
        foreach ($prices as $price) {
            $method = $price->getMethod();

            // Create method if not exists
            if (!isset($grid[$method->getId()])) {
                $gateway = $this->gatewayRegistry->getGateway($method->getGatewayName());

                $grid[$method->getId()] = [
                    'method'     => $method,
                    'max_weight' => $gateway->getMaxWeight(),
                    'prices'     => [],
                ];
            }

            // Add price
            $grid[$method->getId()]['prices'][] = [
                'weight' => $price->getWeight(),
                'price'  => $price->getNetPrice(),
            ];
        }

        foreach ($grid as &$method) {
            // Sort prices by weight ASC
            usort($method['prices'], function ($a, $b) {
                if (0 === bccomp($a['weight'], $b['weight'], 3)) {
                    return 0;
                }

                return $a['weight'] > $b['weight'] ? 1 : -1;
            });

            // Fix max weight
            $max = end($method['prices'])['weight'];
            if (0 == $method['max_weight'] || $method['max_weight'] > $max) {
                $method['max_weight'] = $max;
            }

            unset($method);
        }

        return $this->grids[$country->getId()] = $grid;
    }

    /**
     * Returns the tax rates for the given method.
     *
     * @param ShipmentMethodInterface $method
     * @param CountryInterface        $country
     *
     * @return array
     */
    private function getTaxesRates(ShipmentMethodInterface $method, CountryInterface $country)
    {
        return array_map(function (TaxInterface $tax) {
            return $tax->getRate();
        }, $this->taxResolver->resolveTaxes($method, $country));
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\EventListener;

use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class AbstractSaleAddressListener
 * @package Ekyna\Component\Commerce\Common\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractSaleAddressListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * Sets the persistence helper.
     *
     * @param PersistenceHelperInterface $persistenceHelper
     */
    public function setPersistenceHelper(PersistenceHelperInterface $persistenceHelper)
    {
        $this->persistenceHelper = $persistenceHelper;
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $address = $this->getAddressFromEvent($event);

        if ($this->persistenceHelper->isChanged($address, 'country')) {
            $this->scheduleSaleAddressChangeEvent($address);
        }
    }

    /**
     * Schedules the sale address change event.
     *
     * @param Model\SaleAddressInterface $address
     */
    abstract protected function scheduleSaleAddressChangeEvent(Model\SaleAddressInterface $address);

    /**
     * Returns the sale address from the resource event.
     *
     * @param ResourceEventInterface $event
     *
     * @return Model\SaleAddressInterface
     */
    abstract protected function getAddressFromEvent(ResourceEventInterface $event);
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Payum\Offline;

use Ekyna\Component\Commerce\Bridge\Payum\Offline\Action;
use Payum\Core\Bridge\Spl\ArrayObject;
use Payum\Core\GatewayFactory;
use Payum\Core\GatewayFactoryInterface;

/**
 * Class OfflineGatewayFactory
 * @package Ekyna\Component\Commerce\Bridge\Payum\Offline
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class OfflineGatewayFactory extends GatewayFactory
{
    /**
     * Builds a new factory.
     *
     * @param array                   $defaultConfig
     * @param GatewayFactoryInterface $coreGatewayFactory
     *
     * @return static
     */
    public static function build(array $defaultConfig, GatewayFactoryInterface $coreGatewayFactory = null)
    {
        return new static($defaultConfig, $coreGatewayFactory);
    }

    /**
     * {@inheritDoc}
     */
    protected function populateConfig(ArrayObject $config)
    {
        $config->defaults([
            'payum.factory_name'           => Constants::FACTORY_NAME,
            'payum.factory_title'          => 'Offline',
            'payum.action.accept'          => new Action\AcceptAction(),
            'payum.action.authorize'       => new Action\AuthorizeAction(),
            'payum.action.cancel'          => new Action\CancelAction(),
            'payum.action.capture'         => new Action\CaptureAction(),
            'payum.action.convert_payment' => new Action\ConvertAction(),
            'payum.action.hang'            => new Action\HangAction(),
            'payum.action.refund'          => new Action\RefundAction(),
            'payum.action.status'          => new Action\StatusAction(),
            'payum.action.sync'            => new Action\SyncAction(),
        ]);
    }
}


<?php

namespace Ekyna\Component\Commerce\Supplier\Calculator;

use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderInterface;

/**
 * Class SupplierOrderCalculator
 * @package Ekyna\Component\Commerce\Supplier\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierOrderCalculator implements SupplierOrderCalculatorInterface
{
    /**
     * @var string
     */
    private $defaultCurrency;

    /**
     * Constructor.
     *
     * @param string $defaultCurrency
     */
    public function __construct(string $defaultCurrency)
    {
        $this->defaultCurrency = $defaultCurrency;
    }

    /**
     * @inheritdoc
     */
    public function calculatePaymentTax(SupplierOrderInterface $order)
    {
        $base = $this->calculatePaymentBase($order);

        $amount = 0;
        if (null !== $tax = $order->getSupplier()->getTax()) {
            $currency = $order->getCurrency()->getCode();

            $amount = Money::round($base * $tax->getRate() / 100, $currency);
        }

        return $amount;
    }

    /**
     * @inheritdoc
     */
    public function calculatePaymentTotal(SupplierOrderInterface $order)
    {
        $total = $this->calculatePaymentBase($order) + $this->calculatePaymentTax($order);

        $currency = $order->getCurrency()->getCode();

        return Money::round($total, $currency);
    }

    /**
     * @inheritdoc
     */
    public function calculateItemsTotal(SupplierOrderInterface $order)
    {
        $total = 0;

        $currency = $order->getCurrency()->getCode();

        foreach ($order->getItems() as $item) {
            $total += Money::round($item->getNetPrice(), $currency) * $item->getQuantity();
        }

        return $total;
    }

    /**
     * @inheritdoc
     */
    public function calculateForwarderTotal(SupplierOrderInterface $order)
    {
        if (null === $carrier = $order->getCarrier()) {
            return 0;
        }

        $base = $order->getForwarderFee();

        $taxAmount = 0;
        if (null !== $tax = $carrier->getTax()) {
            $taxAmount = Money::round($base * $tax->getRate() / 100, $this->defaultCurrency);
        }

        $total = $base + $taxAmount + $order->getCustomsTax() + $order->getCustomsVat();

        return Money::round($total, $this->defaultCurrency);
    }

    /**
     * @inheritdoc
     */
    public function calculateWeightTotal(SupplierOrderInterface $order)
    {
        $total = 0;

        foreach ($order->getItems() as $item) {
            if (null !== $product = $item->getProduct()) {
                $total += $product->getWeight() * $item->getQuantity();
            }
        }

        return $total;
    }

    /**
     * Calculates the supplier order base.
     *
     * @param SupplierOrderInterface $order
     *
     * @return float
     */
    private function calculatePaymentBase(SupplierOrderInterface $order)
    {
        $base = $this->calculateItemsTotal($order) + $order->getShippingCost() - $order->getDiscountTotal();

        $currency = $order->getCurrency()->getCode();

        return Money::round($base, $currency);
    }
}


<?php

namespace Skriv\Markup\Html;

/**
 * SkrivMarkup configuration object.
 * This object is based on the WikiRenderer project created by Laurent Jouanneau.
 *
 * @author	Amaury Bouchard <amaury@amaury.net>
 * @copyright	© 2012-2013, Amaury Bouchard
 * @package	SkrivMarkup
 * @see		WikiRenderer
 */
class Config extends \WikiRenderer\Config  {
	/** ??? */
	public $defaultTextLineContainer = '\WikiRenderer\HtmlTextLine';
	/** List of inline markups. */
	public $textLineContainers = array(
		'\WikiRenderer\HtmlTextLine' => array(
			'\Skriv\Markup\Html\Strong',		// **strong**
			'\Skriv\Markup\Html\Em',		// ''em''
			'\Skriv\Markup\Html\Strikeout',		// --strikeout--
			'\Skriv\Markup\Html\Underline',		// __underline__
			'\Skriv\Markup\Html\Monospace',		// ##monospace##
			'\Skriv\Markup\Html\Superscript',	// ^^superscript^^
			'\Skriv\Markup\Html\Subscript',		// ,,subscript,,
			'\Skriv\Markup\Html\Abbr',		// ??abbr|text??
			'\Skriv\Markup\Html\Link',		// [[link|url]]		[[url]]
			'\Skriv\Markup\Html\Image',		// {{image|url}}	{{url}}
			'\Skriv\Markup\Html\Footnote',		// ((footnote))		((label|footnote))
		)
	);
	/** List of block markups. */
	public $blocktags = array(
		'\Skriv\Markup\Html\Title',
		'\Skriv\Markup\Html\WikiList',
		'\Skriv\Markup\Html\Code',
		'\Skriv\Markup\Html\Pre',
		'\Skriv\Markup\Html\Hr',
		'\Skriv\Markup\Html\Blockquote',
		'\Skriv\Markup\Html\Table',
		'\Skriv\Markup\Html\StyledBlock',
		'\Skriv\Markup\Html\Paragraph',
	);
	// list of extensions and their default configuration
	private $_extensions = array(
		'ext-lipsum'	=> true,
		'ext-date'	=> true,
	);
	// list of inline extensions
	private $_inlineExtensions = array(
		'ext-date'	=> '\Skriv\Markup\Html\ExtDate',
	);
	// list of block extensions
	private $_blockExtensions = array(
		'ext-lipsum'	=> '\Skriv\Markup\Html\ExtLipsum',
	);

	/* ************ SKRIV MARKUP SPECIFIC ATTRIBUTES ************* */
	/** List of the footnotes. */
	private $_footnotes = null;
	/** Tree representing the table of content. */
	private $_toc = null;
	/** Parent configuration object, used for recursive calls. */
	private $_parentConfig = null;
	/** Hash containing the configuration parameters. */
	private $_params = null;

	/* ******************** CONSTRUCTION ****************** */
	/**
	 * Constructor.
	 * @param	array	$param		(optionnel) Hash containing specific configuration parameters.
	 *		- bool		shortenLongUrl		Specifies if we must shorten URLs longer than 40 characters. (default: true)
	 *		- bool		convertSmileys		Specifies if we must convert smileys. (default: true)
	 *		- bool		convertSymbols		Specifies if we must convert symbols. (default: true)
	 *		- Closure	urlProcessFunction	URLs processing function. (default: null)
	 *		- Closure	preParseFunction	Function for pre-parse process. (default: null)
	 *		- Closure	postParseFunction	Function for post-parse process. (default: null)
	 *		- Closure	titleToIdFunction	Function that converts title strings into HTML identifiers. (default: null)
	 *		- string	anchorsPrefix		Prefix of anchors' identifiers. (default: "skriv-" + random value)
	 *		- string	footnotesPrefix		Prefix of footnotes' identifiers. (default: "skriv-notes-" + random value)
	 *		- bool		codeSyntaxHighlight	Activate code highlighting. (default: true)
	 *		- bool		codeLineNumbers		Line numbers in code blocks. (default: true)
	 *		- int		firstTitleLevel		Offset of first level titles. (default: 1)
	 *		- bool		targetBlank		Add "target='_blank'" to every links.
	 *		- bool		nofollow		Add "rel='nofollow'" to every links.
	 *		- bool		addFootnotes		Add footnotes' content at the end of the page.
	 *		- bool		codeInlineStyles	Activate inline styles in code blocks. (default: false)
	 *		- bool		debugMode		Activate the debug mode, for development purposes. (default: false)
	 *		- bool		ext-lipsum		Activate the <<<lipsum>>> extension. (default: true)
	 *		- bool		ext-date		Activate the <<date>> extension. (default: true)
	 * @param	\Skriv\Markup\Html\Config	parentConfig	Parent configuration object, for recursive calls.
	 */
	public function __construct(array $param=null, \Skriv\Markup\Html\Config $parentConfig=null) {
		// creation of the default parameters array
		$randomId = base_convert(rand(0, 50000), 10, 36);
		$this->_params = array(
			'shortenLongUrl'	=> true,
			'convertSmileys'	=> true,
			'convertSymbols'	=> true,
			'anchorsPrefix'		=> '',
			'footnotesPrefix'	=> "skriv-notes-$randomId-",
			'urlProcessFunction'	=> null,
			'preParseFunction'	=> null,
			'postParseFunction'	=> null,
			'titleToIdFunction'	=> null,
			'codeSyntaxHighlight'	=> true,
			'codeLineNumbers'	=> true,
			'firstTitleLevel'	=> 1,
			'targetBlank'		=> null,
			'nofollow'		=> null,
			'addFootnotes'		=> false,
			'codeInlineStyles'	=> false,
			'debugMode'		=> false,
		);
		// processing of specified parameters
		if (isset($param['shortenLongUrl']) && $param['shortenLongUrl'] === false)
			$this->_params['shortenLongUrl'] = false;
		if (isset($param['convertSmileys']) && $param['convertSmileys'] === false)
			$this->_params['convertSmileys'] = false;
		if (isset($param['convertSymbols']) && $param['convertSymbols'] === false)
			$this->_params['convertymbols'] = false;
		if (isset($param['anchorsPrefix']))
			$this->_params['anchorsPrefix'] = $param['anchorsPrefix'];
		if (isset($param['footnotesPrefix']))
			$this->_params['footnotesPrefix'] = $param['footnotesPrefix'];
		if (isset($param['urlProcessFunction']) && is_a($param['urlProcessFunction'], 'Closure'))
			$this->_params['urlProcessFunction'] = $param['urlProcessFunction'];
		if (isset($param['preParseFunction']) && is_a($param['preParseFunction'], 'Closure'))
			$this->_params['preParseFunction'] = $param['preParseFunction'];
		if (isset($param['postParseFunction']) && is_a($param['postParseFunction'], 'Closure'))
			$this->_params['postParseFunction'] = $param['postParseFunction'];
		if (isset($param['titleToIdFunction']) && is_a($param['titleToIdFunction'], 'Closure'))
			$this->_params['titleToIdFunction'] = $param['titleToIdFunction'];
		if (isset($param['codeSyntaxHighlight']) && $param['codeSyntaxHighlight'] === false)
			$this->_params['codeSyntaxHighlight'] = false;
		if (isset($param['codeLineNumbers']) && $param['codeLineNumbers'] === false)
			$this->_params['codeLineNumbers'] = false;
		if (isset($param['firstTitleLevel']) && is_numeric($param['firstTitleLevel']) &&
		    $param['firstTitleLevel'] >= 1 && $param['firstTitleLevel'] <= 6)
			$this->_params['firstTitleLevel'] = $param['firstTitleLevel'];
		if (isset($param['targetBlank']) && is_bool($param['targetBlank']))
			$this->_params['targetBlank'] = $param['targetBlank'];
		if (isset($param['nofollow']) && is_bool($param['nofollow']))
			$this->_params['nofollow'] = $param['nofollow'];
		if (isset($param['addFootnotes']) && $param['addFootnotes'] === true)
			$this->_params['addFootnotes'] = true;
		if (isset($param['codeInlineStyles']) && $param['codeInlineStyles'] === true)
			$this->_params['codeInlineStyles'] = true;
		if (isset($param['debugMode']) && $param['debugMode'] === true)
			$this->_params['debugMode'] = true;
		// configuration of extensions
		foreach ($this->_extensions as $extensionName => $defaultConf) {
			if (isset($param[$extensionName]) && is_bool($param[$extensionName]))
				$this->_params[$extensionName] = $param[$extensionName];
			else
				$this->_params[$extensionName] = $defaultConf;
		}
		// storing the parent configuration object
		$this->_parentConfig = $parentConfig;
		// footnotes liste init
		$this->_footnotes = array();

		// add extensions to the lists of supported markups
		$inlineExt = array();
		$blockExt = array();
		foreach ($this->_inlineExtensions as $extName => $obj) {
			if ($this->_params[$extName])
				$inlineExt[] = $obj;
		}
		foreach ($this->_blockExtensions as $extName => $obj) {
			if ($this->_params[$extName])
				$blockExt[] = $obj;
		}
		if (!empty($inlineExt))
			$this->textLineContainers['\WikiRenderer\HtmlTextLine'] = array_merge($this->textLineContainers['\WikiRenderer\HtmlTextLine'], $inlineExt);
		if (!empty($blockExt))
			$this->blocktags = array_merge($blockExt, $this->blocktags);
	}
	/**
	 * Build an object of the same type, "child" of the current object.
	 * @return	\Skriv\Markup\\Html\Config	The new configuration object.
	 */
	public function subConstruct() {
		return (new Config($this->_params, $this));
	}

	/* *************** PARAMETERS MANAGEMENT ************* */
	/**
	 * Returns a specific configuration parameter. If a parent configuration object exists, the parameter is asked to it.
	 * @param	string	$param	Parameter's name.
	 * @return	mixed	Value of the configuration parameter.
	 */
	public function getParam($param) {
		if (isset($this->_parentConfig))
			return ($this->_parentConfig->getParam($param));
		return (isset($this->_params[$param]) ? $this->_params[$param] : null);
	}

	/* *************** TEXT MANAGEMENT ************* */
	/**
	 * Convert title string to a usable HTML identifier.
	 * @param	int	$depth	Depth of the title.
	 * @param	string	$text	Input string.
	 * @return	string	The converted string.
	 */
	public function titleToIdentifier($depth, $text) {
		$func = $this->getParam('titleToIdFunction');
		if (isset($func))
			return ($func($depth, $text));
		// conversion of accented characters
		// see http://www.weirdog.com/blog/php/supprimer-les-accents-des-caracteres-accentues.html
		$text = htmlentities($text, ENT_NOQUOTES, 'utf-8');
		$text = preg_replace('#&([A-za-z])(?:acute|cedil|circ|grave|orn|ring|slash|th|tilde|uml);#', '\1', $text);
		$text = preg_replace('#&([A-za-z]{2})(?:lig);#', '\1', $text);	// for ligatures e.g. 'œ'
		$text = preg_replace('#&([lr]s|sb|[lrb]d)(quo);#', ' ', $text);	// for *quote (http://www.degraeve.com/reference/specialcharacters.php)
		$text = str_replace(' ', ' ', $text);                      // for non breaking space
		$text = preg_replace('#&[^;]+;#', '', $text);                   // strips other characters

		$text = preg_replace("/[^a-zA-Z0-9_-]/", ' ', $text);           // remove any other characters
		$text = str_replace(' ', '-', $text);
		$text = preg_replace('/\s+/', " ", $text);
		$text = preg_replace('/-+/', "-", $text);
		$text = trim($text, '-');
		$text = trim($text);
		$text = empty($text) ? '-' : $text;

		return ($text);
	}

	/* *************** PARSING MANAGEMENT **************** */
	/**
	 * Method called for pre-parse processing.
	 * @param	string	$text	The input text.
	 * @return	string	The text that will be parsed.
	 */
	public function onStart($text) {
		// process of smileys and other special characters
		if ($this->getParam('convertSmileys'))
			$text = Smiley::convertSmileys($text);
		if ($this->getParam('convertSymbols'))
			$text = Smiley::convertSymbols($text);
		// if a specific pre-parse function was defined, it is called
		$func = $this->getParam('preParseFunction');
		if (isset($func))
			$text = $func($text);
		return ($text);
	}
	/**
	 * Method called for post-parse processing.
	 * @param	string	$finalText	The generated text.
	 * @return	string	The text after post-processing.
	 */
	public function onParse($finalText) {
		// if a specific post-parse function was defined, it is called
		$func = $this->getParam('postParseFunction');
		if (isset($func))
			$finalText = $func($finalText);
		// add footnotes' content if needed
		if ($this->getParam('addFootnotes')) {
			$footnotes = $this->getFootnotes();
			if (!empty($footnotes))
				$finalText .= "\n" . $footnotes;
		}
		return ($finalText);
	}
	/**
	 * Links processing.
	 * @param	string	$url		The URL to process.
	 * @param	string	$tagName	Name of the calling tag.
	 * @return	array	Array with the processed URL and the generated label.
	 *			Third parameter is about blank targeting of the link. It could be
	 *			null (use the default behaviour), true (add a blank targeting) or
	 *			false (no blank targeting).
	 */
	public function processLink($url, $tagName='') {
		$label = $url = trim($url);
		$targetBlank = $this->getParam('targetBlank');
		$nofollow = $this->getParam('nofollow');
		// shortening of long URLs
		if ($this->getParam('shortenLongUrl') && strlen($label) > 40)
			$label = substr($label, 0, 40) . '...';
		// Javascript XSS check
		if (substr($url, 0, strlen('javascript:')) === 'javascript:')
			$url = '#';
		else {
			// email check
			if (filter_var($url, FILTER_VALIDATE_EMAIL)) {
				$url = "mailto:$url";
				$targetBlank = $nofollow = false;
			} else if (substr($url, 0, strlen('mailto:')) === 'mailto:') {
				$label = substr($url, strlen('mailto:'));
				$targetBlank = $nofollow = false;
			}
			// if a specific URL process function was defined, it is called
			$func = $this->getParam('urlProcessFunction');
			if (isset($func))
				list($url, $label, $targetBlank, $nofollow) = $func($url, $label, $targetBlank, $nofollow);
		}

		return (array($url, $label, $targetBlank, $nofollow));
	}

	/* ******************** TOC MANAGEMENT *************** */
	/**
	 * Add a TOC entry.
	 * @param	int	$depth		Depth in the tree.
	 * @param	string	$title		Name of the new entry.
	 * @param	string	$identifier	Identifier of the new entry.
	 */
	public function addTocEntry($depth, $title, $identifier) {
		if (!isset($this->_toc))
			$this->_toc = array();
		$this->_addTocSubEntry($depth, $depth, $title, $identifier, $this->_toc);
	}
	/**
	 * Returns the TOC content. By default, the rendered HTML is returned, but the
	 * raw TOC tree is available.
	 * @param	bool	$raw	(optional) Set to True to get the raw TOC tree. False by default.
	 * @return	string|array	The TOC rendered HTML or the TOC tree.
	 */
	public function getToc($raw=false) {
		if ($raw === true)
			return ($this->_toc['sub']);
		$html = $this->_getRenderedToc($this->_toc['sub']);
		return ($html);
	}

	/* ******************** FOOTNOTES MANAGEMENT **************** */
	/**
	 * Add a footnote.
	 * @param	string	$text	Footnote's text.
	 * @param	string	$label	(optionnel) Footnote's label. If not given, an auto-incremented
	 *				number will be used.
	 * @return	array	Hash with 'id' and 'index' keys.
	 */
	public function addFootnote($text, $label=null) {
		if (isset($this->_parentConfig))
			return ($this->_parentConfig->addFootnote($text, $label));
		if (is_null($label))
			$this->_footnotes[] = $text;
		else
			$this->_footnotes[] = array(
				'label'	=> $label,
				'text'	=> $text
			);
		$index = count($this->_footnotes);
		return (array(
			'id'	=> $this->getParam('footnotesPrefix') . "-$index",
			'index'	=> $index
		));
	}
	/**
	 * Returns the footnotes content. By default, the rendered HTML is returned, but the
	 * raw list of footnotes is available.
	 * @param	bool	$raw	(optional) Set to True to get the raw list of footnotes.
	 *				False by default.
	 * @return	string|array	The footnotes' rendered HTML or the list of footnotes.
	 */
	public function getFootnotes($raw=false) {
		if ($raw === true)
			return ($this->_footnotes);
		if (empty($this->_footnotes))
			return (null);
		$footnotes = '';
		$index = 1;
		foreach ($this->_footnotes as $note) {
			$id = $this->getParam('footnotesPrefix') . "-$index";
			$noteHtml = "
";
			if (is_string($note))
				$noteHtml .= "$index. $note";
			else
				$noteHtml .= htmlspecialchars($note['label']) . ". " . $note['text'];
			$noteHtml .= "
\n";
			$footnotes .= $noteHtml;
			$index++;
		}
		$footnotes = "
\n$footnotes
\n";
		return ($footnotes);
	}

	/* ****************** PRIVATE METHODS ******************** */
	/**
	 * Add a sub-TOC entry.
	 * @param	int	$depth		Depth in the tree.
	 * @param	int	$level		Level of the title.
	 * @param	string	$title		Name of the new entry.
	 * @param	string	$identifier	Identifier of the new entry.
	 * @param	array	$list		List of sibbling nodes.
	 */
	private function _addTocSubEntry($depth, $level, $title, $identifier, &$list) {
		if (!isset($list['sub']))
			$list['sub'] = array();
		$offset = count($list['sub']);
		if ($depth === 1) {
			$list['sub'][$offset] = array(
				'id'	=> $identifier,
				'value'	=> $title
			);
			return;
		}
		$offset--;
		if (!isset($list['sub'][$offset]))
			$list['sub'][$offset] = array();
		$this->_addTocSubEntry($depth - 1, $level, $title, $identifier, $list['sub'][$offset]);
	}
	/**
	 * Returns a chunk of rendered TOC.
	 * @param	array	$list	List of TOC entries.
	 * @param	int	$depth	(optional) Depth in the tree. 1 by default.
	 * @return	string	The rendered chunk.
	 */
	private function _getRenderedToc($list, $depth=1) {
		if (!isset($list) || empty($list))
			return ('');
		$html = "
\n";
		foreach ($list as $entry) {
			$html .= "
\n";
			$html .= 'getParam('anchorsPrefix') . $this->titleToIdentifier($depth, $entry['value']) . '">'. $entry['value'] . "\n";
			if (isset($entry['sub']))
				$html .= $this->_getRenderedToc($entry['sub'], ($depth + 1));
			$html .= "
\n";
		}
		$html .= "
\n";
		return ($html);
	}
}


<?php

namespace PeskyCMF\Http;

use Illuminate\Http\JsonResponse;

/**
 * @method CmfJsonResponse setData($data = [])
 */
class CmfJsonResponse extends JsonResponse {

    static protected $messageKey = '_message';
    static protected $redirectKey = 'redirect';
    static protected $forcedRedirectKey = 'redirect_with_reload';
    static protected $redirectFallbakKey = 'redirect_fallback';
    static protected $errorsKey = 'errors';
    static protected $modalKey = 'modal';
    static protected $modalTitleKey = 'title';
    static protected $modalContentKey = 'content';
    static protected $modalFooterKey = 'footer';
    static protected $modalUrlKey = 'url';
    static protected $modalSizeKey = 'size';

    const MODAL_SIZE_MEDIUM = 'medium';
    const MODAL_SIZE_SMALL = 'small';
    const MODAL_SIZE_LARGE = 'large';

    /**
     * CmfJsonResponse constructor.
     * @param array $data
     * @param int $status
     * @param array $headers
     * @param int $options
     */
    public function __construct(array $data = null, $status = 200, array $headers = [], $options = 0) {
        parent::__construct($data === null ? [] : $data, $status, $headers, $options);
    }

    /**
     * @param array $additionalData
     * @return CmfJsonResponse
     */
    public function addData(array $additionalData) {
        $data = $this->getData(true);
        $data = array_merge($data, $additionalData);
        return $this->setData($data);
    }

    /**
     * @param string $message
     * @return $this
     */
    public function setMessage($message) {
        if (!empty($message)) {
            $data = $this->getData(true);
            $data[static::$messageKey] = $message;
            $this->setData($data);
        }
        return $this;
    }

    /**
     * @param string $url
     * @param string|null $fallbackUrl
     * @return $this
     */
    public function setRedirect($url, $fallbackUrl = null) {
        $data = $this->getData(true);
        $data[static::$redirectKey] = $url;
        if (!empty($fallbackUrl)) {
            $data[static::$redirectFallbakKey] = $fallbackUrl;
        }
        return $this->setData($data);
    }

    /**
     * Redirect using document.location instead of JS router
     * @param string $url
     * @return $this
     */
    public function setForcedRedirect($url) {
        $data = $this->getData(true);
        $data[static::$forcedRedirectKey] = $url;
        return $this->setData($data);
    }

    /**
     * @param string|null $fallbakUrl
     * @return $this
     */
    public function goBack($fallbakUrl = null) {
        return $this->setRedirect('back', $fallbakUrl);
    }

    /**
     * @return $this
     */
    public function reloadPage() {
        return $this->setRedirect('reload');
    }

    /**
     * @param array $errors
     * @param null|string $message
     * @return $this
     */
    public function setErrors(array $errors, $message = null) {
        $data = $this->getData(true);
        if (!empty($message)) {
            $data[static::$messageKey] = $message;
        }
        $data[static::$errorsKey] = $errors;
        return $this->setData($data);
    }

    /**
     * @param string $title
     * @param string $content
     * @param string $footer
     * @param null|string $url
     * @param string $modalSize
     * @return $this
     */
    public function setModalContent($title, $content, $footer = null, $url = null, $modalSize = self::MODAL_SIZE_MEDIUM) {
        $data = $this->getData(true);
        $data[static::$modalKey] = [
            static::$modalTitleKey => (string)$title,
            static::$modalContentKey => (string)$content,
        ];
        if (!empty($footer)) {
            $data[static::$modalKey][static::$modalFooterKey] = (string)$footer;
        }
        if ($modalSize !== self::MODAL_SIZE_MEDIUM) {
            $data[static::$modalKey][static::$modalSizeKey] = $modalSize;
        }
        if (!empty($url)) {
            $data[static::$modalKey][static::$modalUrlKey] = (string)$url;
        }
        return $this->setData($data);
    }

}


<?php

namespace Ekyna\Component\Commerce\Cart\EventListener;

use Ekyna\Component\Commerce\Cart\Event\CartEvents;
use Ekyna\Component\Commerce\Cart\Model\CartInterface;
use Ekyna\Component\Commerce\Cart\Model\CartStates;
use Ekyna\Component\Commerce\Common\EventListener\AbstractSaleListener;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class CartListener
 * @package Ekyna\Component\Commerce\Cart\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CartListener extends AbstractSaleListener
{
    /**
     * @var string
     */
    protected $expirationDelay;

    /**
     * Sets the expiration delay.
     *
     * @param string $delay
     */
    public function setExpirationDelay($delay)
    {
        $this->expirationDelay = $delay;
    }

    /**
     * @inheritDoc
     *
     * @param CartInterface $sale
     */
    protected function handleInsert(SaleInterface $sale)
    {
        $changed = parent::handleInsert($sale);

        $changed |= $this->updateExpiresAt($sale);

        return $changed;
    }

    /**
     * @inheritDoc
     *
     * @param CartInterface $sale
     */
    protected function handleUpdate(SaleInterface $sale)
    {
        $changed = parent::handleUpdate($sale);

        $changed |= $this->updateExpiresAt($sale);

        return $changed;
    }

    /**
     * Updates the cart expiration date.
     *
     * @param CartInterface $cart
     *
     * @return bool
     */
    protected function updateExpiresAt(CartInterface $cart)
    {
        $date = new \DateTime();
        $date->modify($this->expirationDelay);
        $cart->setExpiresAt($date);

        return true;
    }

    /**
     * @inheritdoc
     */
    protected function updateState(SaleInterface $sale)
    {
        if (parent::updateState($sale)) {
            /** @var CartInterface $sale */
            if (($sale->getState() === CartStates::STATE_ACCEPTED) && (null === $sale->getAcceptedAt())) {
                $sale->setAcceptedAt(new \DateTime());
            } elseif (($sale->getState() !== CartStates::STATE_ACCEPTED) && (null !== $sale->getAcceptedAt())) {
                $sale->setAcceptedAt(null);
            }

            return true;
        }

        return false;
    }

    /**
     * @inheritdoc
     *
     * @return CartInterface
     */
    protected function getSaleFromEvent(ResourceEventInterface $event)
    {
        $resource = $event->getResource();

        if (!$resource instanceof CartInterface) {
            throw new InvalidArgumentException("Expected instance of CartInterface");
        }

        return $resource;
    }

    /**
     * @inheritdoc
     */
    protected function scheduleContentChangeEvent(SaleInterface $sale)
    {
        if (!$sale instanceof CartInterface) {
            throw new InvalidArgumentException("Expected instance of CartInterface");
        }

        $this->persistenceHelper->scheduleEvent(CartEvents::CONTENT_CHANGE, $sale);
    }

    /**
     * @inheritdoc
     */
    protected function scheduleStateChangeEvent(SaleInterface $sale)
    {
        if (!$sale instanceof CartInterface) {
            throw new InvalidArgumentException("Expected instance of CartInterface");
        }

        $this->persistenceHelper->scheduleEvent(CartEvents::STATE_CHANGE, $sale);
    }
}


<?php

/*
 * Copyright (C) 2015 Ashley Marando
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 */

namespace Marando\AstroDate;

class Epoch {
  //----------------------------------------------------------------------------
  // Constants
  //----------------------------------------------------------------------------

  /**
   * Number of days in one Julian year
   */
  const DaysJulianYear = 365.25;

  /**
   * Number of days in one Besselian year
   */
  const DaysBesselianYear = 365.242198781;

  //----------------------------------------------------------------------------
  // Constructors
  //----------------------------------------------------------------------------

  public function __construct($jd, YearType $type = null) {
    $this->jd   = $jd;
    $this->type = $type ? $type : YearType::Julian();
  }

  // // // Static

  /**
   * Creates a new Epoch instance from a Julian day count
   *
   * @param float    $jd   Julian day count of the epoch
   * @param YearType $type Year type, Julian or Bessilian
   */
  public static function jd($jd, YearType $type = null) {
    return new static($jd, $type);
  }

  /**
   * Creates a new Epoch instance from an AstroDate instance
   *
   * @param  AstroDate $dt AstroDate instance
   * @return static
   */
  public static function dt(AstroDate $dt) {
    $epoch     = new static($dt->toTT()->toJD());
    $epoch->dt = $dt->copy();

    return $epoch;
  }

  /**
   * Creates a new epoch from a Julian year number
   * @param float $year Year number in Julian years
   */
  public static function J($year) {
    // Get JD of the epoch
    $jd = static::J2000()->jd + ($year - 2000) * static::DaysJulianYear;

    // Create and return new epoch
    $epoch       = new static($jd);
    $epoch->type = YearType::Julian();
    return $epoch;
  }

  /**
   * Creates a new epoch from a Besselian year number
   * @param float $year Year number in Besselian years
   */
  public static function B($year) {
    // Get JD of the epoch
    $jd = static::B1900()->jd + ($year - 1900) * static::DaysBesselianYear;

    // Create and return new epoch
    $epoch       = new static($jd);
    $epoch->type = YearType::Besselian();
    return $epoch;
  }

  /**
   * Represents the Modified Julian epoch
   * @return static
   */
  public static function JMod() {
    return new static(2400000.5);
  }

  /**
   * Represents the J1900 epoch (JD 2415020.0 TT)
   * @return static
   */
  public static function J1900() {
    return new static(2415020.0);
  }

  /**
   * Represents the J2000 epoch (2451545.0 TT)
   * @return static
   */
  public static function J2000() {
    return new static(2451545.0);
  }

  /**
   * Represents the B1900 epoch (2415020.31352 TT)
   * @return static
   */
  public static function B1900() {
    $epoch       = new static(2415020.31352);
    $epoch->type = YearType::Besselian();
    return $epoch;
  }

  /**
   * Represents the B1950 epoch (2433282.4235 TT)
   * @return static
   */
  public static function B1950() {
    $epoch       = new static(2433282.4235);
    $epoch->type = YearType::Besselian();
    return $epoch;
  }

  //----------------------------------------------------------------------------
  // Properties
  //----------------------------------------------------------------------------

  /**
   * Julian day count of the epoch
   * @var float
   */
  protected $jd;

  /**
   * Year type, Julian or Besselian
   * @var YearTYpe
   */
  protected $type;

  /**
   * AstroDate equivalent (if set from one)
   * @var AstroDate
   */
  protected $dt;

  public function __get($name) {
    switch ($name) {
      case 'jd':
      case 'type':
        return $this->{$name};

      case 'year':
        return $this->getYear();

      default:
        throw new Exception("{$name} is not a valid or writable property.");
    }
  }

  //----------------------------------------------------------------------------
  // Functions
  //----------------------------------------------------------------------------

  /**
   * Converts this instance to an AstroDate instance
   * @return AstroDate
   */
  public function toDate() {
    if ($this->dt)
      return $this->dt;
    else
      return AstroDate::jd($this->jd, TimeScale::TT());
  }

  // // // Protected

  /**
   * Finds the year of this epoch
   * @return float
   */
  protected function getYear() {
    $year = 0;
    if ($this->type == YearType::Besselian())
      $year = 1900 + ($this->jd - Epoch::B1900()->jd) / static::DaysBesselianYear;
    else
      $year = 2000 + ($this->jd - Epoch::J2000()->jd) / static::DaysJulianYear;

    return round($year, 6);
  }

  // // // Overrides

  /**
   * Represents this instance as a string
   * @return string
   */
  public function __toString() {
    $yearType = $this->type == YearType::Besselian() ? 'B' : 'J';

    $epoch = '';
    if (intval($this->year) == $this->year) {
      $epoch = "{$yearType}{$this->year}.0";
    }
    else {
      if (false)
        $epoch = "{$yearType}{$this->year}";  // Old method
      else
        $epoch = $this->toDate()->toTT()->format(AstroDate::FORMAT_EPOCH);
    }

    //return "Epoch $epoch";
    return $epoch;
  }

}


<?php

namespace Ekyna\Component\Commerce\Common\Model;

/**
 * Trait IdentityTrait|$this
 * @package Ekyna\Component\Commerce\Common\Model
 * @author  Étienne Dauvergne <contact@ekyna.com>
 */
trait IdentityTrait
{
    /**
     * @var string
     */
    protected $gender;

    /**
     * @var string
     */
    protected $firstName;

    /**
     * @var string
     */
    protected $lastName;

    /**
     * Returns the gender.
     *
     * @return string
     */
    public function getGender()
    {
        return $this->gender;
    }

    /**
     * Sets the gender.
     *
     * @param string $gender
     *
     * @return IdentityInterface|$this
     */
    public function setGender($gender)
    {
        $this->gender = $gender;

        return $this;
    }

    /**
     * Returns the firstName.
     *
     * @return string
     */
    public function getFirstName()
    {
        return $this->firstName;
    }

    /**
     * Sets the firstName.
     *
     * @param string $firstName
     *
     * @return IdentityInterface|$this
     */
    public function setFirstName($firstName)
    {
        $this->firstName = $firstName;

        return $this;
    }

    /**
     * Returns the lastName.
     *
     * @return string
     */
    public function getLastName()
    {
        return $this->lastName;
    }

    /**
     * Sets the lastName.
     *
     * @param string $lastName
     *
     * @return IdentityInterface|$this
     */
    public function setLastName($lastName)
    {
        $this->lastName = $lastName;

        return $this;
    }

    /**
     * Returns whether or not the identity is empty.
     *
     * @return bool
     */
    public function isIdentityEmpty()
    {
        return empty($this->gender) && empty($this->firstName) && empty($this->lastName);
    }

    /**
     * Clears the identity.
     *
     * @return IdentityInterface|$this
     */
    public function clearIdentity()
    {
        $this->gender = null;
        $this->firstName = null;
        $this->lastName = null;

        return $this;
    }
}


<?php

namespace Ekyna\Component\Commerce\Document\Calculator;

use Ekyna\Component\Commerce\Common\Calculator\Amount;
use Ekyna\Component\Commerce\Common\Calculator\AmountCalculatorInterface;
use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Document\Model;
use Ekyna\Component\Commerce\Exception\LogicException;

/**
 * Class DocumentCalculator
 * @package Ekyna\Component\Commerce\Document\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class DocumentCalculator implements DocumentCalculatorInterface
{
    /**
     * @var AmountCalculatorInterface
     */
    protected $calculator;

    /**
     * @var string
     */
    protected $currency;

    /**
     * @var bool
     */
    protected $changed;

    /**
     * Constructor.
     *
     * @param AmountCalculatorInterface $calculator
     */
    public function __construct(AmountCalculatorInterface $calculator)
    {
        $this->calculator = $calculator;
    }

    /**
     * @inheritdoc
     */
    public function calculate(Model\DocumentInterface $document)
    {
        $this->changed = false;
        $this->currency = $document->getCurrency();

        if (null === $sale = $document->getSale()) {
            throw new LogicException("Document can't be recalculated.");
        }

        // TODO Currency conversion
        $currency = $sale->getCurrency()->getCode();

        // Clear all sale's results and disable calculator cache
        $sale->clearResults();
        $this->calculator->setCache(false);

        // Goods lines / Gross result
        $gross = $this->calculateGoodLines($document);

        // Final result
        $final = Amount::createFinalFromGross($gross);

        // Discount lines
        foreach ($document->getLinesByType(Model\DocumentLineTypes::TYPE_DISCOUNT) as $line) {
            $this->calculateDiscountLine($line, $gross, $final);
        }

        // Shipment lines
        $shipmentBase = 0;
        foreach ($document->getLinesByType(Model\DocumentLineTypes::TYPE_SHIPMENT) as $line) {
            $result = $this->calculateShipmentLine($line, $final);

            $shipmentBase += $result->getBase();
        }

        // Round/finalize results.
        $gross->round();
        $final->finalize();

        // Document goods base (after discounts)
        if ($document->getGoodsBase() !== $gross->getBase()) {
            $document->setGoodsBase($gross->getBase());
            $this->changed = true;
        }

        // Document discount base.
        if ($document->getDiscountBase() !== $final->getDiscount()) {
            $document->setDiscountBase($final->getDiscount());
            $this->changed = true;
        }

        // Document shipment base.
        if ($document->getShipmentBase() !== $shipmentBase) {
            $document->setShipmentBase($shipmentBase);
            $this->changed = true;
        }

        // Document taxes total
        if ($document->getTaxesTotal() !== $final->getTax()) {
            $document->setTaxesTotal($final->getTax());
            $this->changed = true;
        }

        // Taxes details
        $taxesDetails = [];
        foreach ($final->getTaxAdjustments() as $tax) {
            $taxesDetails[] = [
                'name'   => $tax->getName(),
                'rate'   => $tax->getRate(),
                'amount' => $tax->getAmount(),
            ];
        }
        if ($document->getTaxesDetails() !== $taxesDetails) {
            $document->setTaxesDetails($taxesDetails);
            $this->changed = true;
        }

        // Document grand total
        if ($document->getGrandTotal() !== $final->getTotal()) {
            $document->setGrandTotal($final->getTotal());
            $this->changed = true;
        }

        // Clear all sale's results
        $sale->clearResults();

        return $this->changed;
    }

    /**
     * Calculates the good lines.
     *
     * @param Model\DocumentInterface $document
     *
     * @return Amount
     */
    protected function calculateGoodLines(Model\DocumentInterface $document): Amount
    {
        $gross = new Amount($document->getCurrency());

        foreach ($document->getLinesByType(Model\DocumentLineTypes::TYPE_GOOD) as $line) {
            if (null !== $result = $this->calculateGoodLine($line)) {
                $gross->merge($result);
            }
        }

        $gross->copyGrossToUnit();

        return $gross;
    }

    /**
     * Calculate the good line.
     *
     * @param Model\DocumentLineInterface $line
     *
     * @return Amount
     *
     * @throws LogicException
     */
    protected function calculateGoodLine(Model\DocumentLineInterface $line): ?Amount
    {
        if ($line->getType() !== Model\DocumentLineTypes::TYPE_GOOD) {
            throw new LogicException(sprintf(
                "Expected document line with type '%s'.",
                Model\DocumentLineTypes::TYPE_GOOD
            ));
        }

        if (null === $item = $line->getSaleItem()) {
            throw new LogicException("Document can't be recalculated.");
        }

        $result = $this->calculator->calculateSaleItem($item, $line->getQuantity());

        $this->syncLineWithResult($line, $result);

        if ($item->isPrivate()) {
            return null;
        }

        return $result;
    }

    /**
     * Calculate the discount line.
     *
     * @param Model\DocumentLineInterface $line
     * @param Amount                      $gross
     * @param Amount                      $final
     *
     * @throws LogicException
     */
    protected function calculateDiscountLine(Model\DocumentLineInterface $line, Amount $gross, Amount $final)
    {
        if ($line->getType() !== Model\DocumentLineTypes::TYPE_DISCOUNT) {
            throw new LogicException(sprintf(
                "Expected document line with type '%s'.",
                Model\DocumentLineTypes::TYPE_DISCOUNT
            ));
        }

        /** @var Common\SaleAdjustmentInterface $adjustment */
        if (null === $adjustment = $line->getSaleAdjustment()) {
            throw new LogicException("Document can't be recalculated.");
        }

        $result = $this->calculator->calculateSaleDiscount($adjustment, $gross, $final);

        $this->syncLineWithResult($line, $result);
    }

    /**
     * Calculate the shipment line.
     *
     * @param Model\DocumentLineInterface $line
     * @param Amount                      $final
     *
     * @return Amount
     * @throws LogicException
     */
    protected function calculateShipmentLine(Model\DocumentLineInterface $line, Amount $final): Amount
    {
        if ($line->getType() !== Model\DocumentLineTypes::TYPE_SHIPMENT) {
            throw new LogicException(sprintf(
                "Expected document line with type '%s'.",
                Model\DocumentLineTypes::TYPE_SHIPMENT
            ));
        }

        $sale = $line->getDocument()->getSale();

        $result = $this->calculator->calculateSaleShipment($sale, $final);

        if (null === $result) {
            throw new LogicException("Unexpected document shipment line.");
        }

        $this->syncLineWithResult($line, $result);

        return $result;
    }

    /**
     * Synchronizes the line amounts with the given result.
     *
     * @param Model\DocumentLineInterface $line
     * @param Amount                      $result
     */
    protected function syncLineWithResult(Model\DocumentLineInterface $line, Amount $result)
    {
        // TODO Currency conversions

        // Unit
        if ($line->getUnit() !== $result->getUnit()) {
            $line->setUnit($result->getUnit());
            $this->changed = true;
        }
        // Gross
        if ($line->getGross() !== $result->getGross()) {
            $line->setGross($result->getGross());
            $this->changed = true;
        }
        // Discount
        if ($line->getDiscount() !== $result->getDiscount()) {
            $line->setDiscount($result->getDiscount());
            $this->changed = true;
        }
        // Discount rates
        $discountRates = [];
        if (!empty($adjustments = $result->getDiscountAdjustments())) {
            foreach ($adjustments as $adjustment) {
                $discountRates[] = $adjustment->getRate();
            }
        }
        if ($discountRates !== $line->getDiscountRates()) {
            $line->setDiscountRates($discountRates);
            $this->changed = true;
        }
        // Base
        if ($line->getBase() !== $result->getBase()) {
            $line->setBase($result->getBase());
            $this->changed = true;
        }
        // Tax
        if ($line->getTax() !== $result->getTax()) {
            $line->setTax($result->getTax());
            $this->changed = true;
        }
        // Tax rates
        $taxRates = [];
        if (!empty($adjustments = $result->getTaxAdjustments())) {
            foreach ($adjustments as $adjustment) {
                $taxRates[] = $adjustment->getRate();
            }
        }
        if ($taxRates !== $line->getTaxRates()) {
            $line->setTaxRates($taxRates);
            $this->changed = true;
        }
        // Total
        if ($line->getTotal() !== $result->getTotal()) {
            $line->setTotal($result->getTotal());
            $this->changed = true;
        }
    }

    /**
     * Rounds the given amount.
     *
     * @param float $amount
     *
     * @return float
     */
    protected function round($amount)
    {
        return Money::round($amount, $this->currency);
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Doctrine\Fixtures;

use Doctrine\Common\DataFixtures\AbstractFixture as BaseFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Yaml\Yaml;

/**
 * Class AbstractFixture
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\Fixtures
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractFixture extends BaseFixture implements OrderedFixtureInterface
{
    /**
     * @var ObjectManager
     */
    private $manager;

    /**
     * @var \Symfony\Component\PropertyAccess\PropertyAccessorInterface
     */
    private $accessor;

    /**
     * Configures the fixtures loader.
     *
     * @return array
     */
    abstract protected function configure();

    /**
     * @inheritdoc
     */
    public function load(ObjectManager $manager)
    {
        $config = $this->configure();

        if (!isset($config['filename'])) {
            throw new \Exception("Missing 'filename' key in fixtures configuration.");
        }
        if (!isset($config['class'])) {
            throw new \Exception("Missing 'class' key in fixtures configuration.");
        }

        $this->manager = $manager;
        $this->accessor = PropertyAccess::createPropertyAccessor();

        $fixtures = Yaml::parse(file_get_contents(__DIR__ . '/data/' . $config['filename']));

        $metadata = $this->manager->getClassMetadata($config['class']);

        foreach ($fixtures as $reference => $data) {
            $entity = $this->buildEntity($metadata, $data);

            $manager->persist($entity);

            $this->addReference($reference, $entity);
        }

        $manager->flush();
    }

    /**
     * Builds the entity
     *
     * @param ClassMetadata $metadata
     * @param array         $data
     *
     * @return object
     * @throws \Exception
     */
    private function buildEntity(ClassMetadata $metadata, $data)
    {
        $class = $metadata->getName();
        $entity = new $class;

        foreach ($data as $propertyPath => $value) {
            // Field
            if ($metadata->hasField($propertyPath)) {
                $builtValue = $this->buildFieldValue($metadata, $propertyPath, $value);

            // Association
            } elseif ($metadata->hasAssociation($propertyPath)) {
                $builtValue = $this->buildAssociationValue($metadata, $propertyPath, $value);

            } else {
                throw new \Exception("Unexpected property path '$propertyPath' for class '$class'.");
            }

            $this->accessor->setValue($entity, $propertyPath, $builtValue);
        }

        return $entity;
    }

    /**
     * Builds the field value.
     *
     * @param ClassMetadata $metadata
     * @param string        $propertyPath
     * @param string        $value
     *
     * @return mixed
     * @throws \Exception
     */
    private function buildFieldValue(ClassMetadata $metadata, $propertyPath, $value)
    {
        $type = $metadata->getTypeOfField($propertyPath);

        switch ($type) {
            case 'smallint':
            case 'integer':
            case 'bigint':
                if (!is_int($value)) {
                    throw new \Exception('Expected integer.');
                }
                return intval($value);

            case 'boolean':
                if (!is_bool($value)) {
                    throw new \Exception('Expected boolean.');
                }
                return (bool)$value;

            case 'float':
            case 'double':
            case 'decimal':
                if (!is_numeric($value)) {
                    throw new \Exception('Expected float.');
                }
                return floatval($value);

            case 'datetime':
                return new \DateTime($value);

            case 'string':
                return (string)$value;
        }

        throw new \Exception("Unsupported field type '$type' for path '$propertyPath'.");
    }

    /**
     * Builds the association value.
     *
     * @param ClassMetadata $metadata
     * @param string        $propertyPath
     * @param string        $value
     *
     * @return array|object
     * @throws \Exception
     */
    private function buildAssociationValue(ClassMetadata $metadata, $propertyPath, $value)
    {
        $childMetadata = $this->manager->getClassMetadata(
            $metadata->getAssociationTargetClass($propertyPath)
        );

        // Single association
        if ($metadata->isSingleValuedAssociation($propertyPath)) {
            if (is_string($value) && '#' === substr($value, 0, 1)) {
                return $this->getReference(substr($value, 1));
            } elseif (is_array($value)) {
                return $this->buildEntity($childMetadata, $value);
            }
            throw new \Exception("Unexpected value for single association '$propertyPath'.");

        // Collection association
        } elseif ($metadata->isCollectionValuedAssociation($propertyPath)) {
            if (!is_array($value)) {
                throw new \Exception('Expected array.');
            }
            $builtValue = [];
            foreach ($value as $childData) {
                if (is_string($childData) && '#' === substr($childData, 0, 1)) {
                    array_push($builtValue, $this->getReference(substr($childData, 1)));
                } elseif (is_array($value)) {
                    array_push($builtValue, $this->buildEntity($childMetadata, $childData));
                } else {
                    throw new \Exception("Unexpected value for association '$propertyPath'.");
                }
            }
            return $builtValue;
        }
        throw new \Exception("Unexpected association path '$propertyPath'.");
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\EventListener;

use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class AbstractAdjustmentListener
 * @package Ekyna\Component\Commerce\Common\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractAdjustmentListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * Sets the persistence helper.
     *
     * @param PersistenceHelperInterface $persistenceHelper
     */
    public function setPersistenceHelper(PersistenceHelperInterface $persistenceHelper)
    {
        $this->persistenceHelper = $persistenceHelper;
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws \Ekyna\Component\Resource\Exception\ResourceExceptionInterface
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $adjustment = $this->getAdjustmentFromEvent($event);

        $this->scheduleSaleContentChangeEvent($adjustment);
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws \Ekyna\Component\Resource\Exception\ResourceExceptionInterface
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $adjustment = $this->getAdjustmentFromEvent($event);

        if ($this->persistenceHelper->isChanged($adjustment, ['amount', 'mode', 'type'])) {
            $this->scheduleSaleContentChangeEvent($adjustment);
        }
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws \Ekyna\Component\Resource\Exception\ResourceExceptionInterface
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $adjustment = $this->getAdjustmentFromEvent($event);

        $this->scheduleSaleContentChangeEvent($adjustment);
    }

    /**
     * Pre update event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onPreUpdate(ResourceEventInterface $event)
    {
        $this->throwIllegalOperationIfAdjustmentIsImmutable($event);
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $this->throwIllegalOperationIfAdjustmentIsImmutable($event);

        $adjustment = $this->getAdjustmentFromEvent($event);

        // Pre load adjustments collection
        if (null === $adjustable = $adjustment->getAdjustable()) {
            return;
        }

        $adjustable->getAdjustments()->toArray();
    }

    /**
     * Throws an illegal operation exception if the adjustment is immutable.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    private function throwIllegalOperationIfAdjustmentIsImmutable(ResourceEventInterface $event)
    {
        if ($event->getHard()) {
            return;
        }

        $adjustment = $this->getAdjustmentFromEvent($event);

        // Stop if adjustment is immutable.
        if ($adjustment->isImmutable()) {
            throw new IllegalOperationException('ekyna_commerce.sale.message.immutable_element');
        }
    }

    /**
     * Dispatches the sale content change event.
     *
     * @param Model\AdjustmentInterface $adjustment
     *
     * @throws \Ekyna\Component\Resource\Exception\ResourceExceptionInterface
     */
    protected function scheduleSaleContentChangeEvent(Model\AdjustmentInterface $adjustment)
    {
        if ($adjustment instanceof Model\SaleAdjustmentInterface) {
            if (null === $sale = $this->getSaleFromAdjustment($adjustment)) {
                // Sale may be scheduled for delete.
                return;
                //throw new RuntimeException("Failed to retrieve the sale.");
            }
        } elseif ($adjustment instanceof Model\SaleItemAdjustmentInterface) {
            if (null === $item = $this->getItemFromAdjustment($adjustment)) {
                // Sale item may be scheduled for delete.
                return;
                //throw new RuntimeException("Failed to retrieve the sale item.");
            }
            if (null === $sale = $this->getSaleFromItem($item)) {
                // Sale may be scheduled for delete.
                return;
                //throw new RuntimeException("Failed to retrieve the sale.");
            }
        } else {
            throw new InvalidArgumentException("Unexpected adjustment type.");
        }

        $this->persistenceHelper->scheduleEvent($this->getSaleChangeEvent(), $sale);
    }

    /**
     * Returns the sale item from the given sale item adjustment (looking into change set).
     *
     * @param Model\SaleItemAdjustmentInterface $adjustment
     *
     * @return Model\SaleItemInterface|null
     */
    protected function getItemFromAdjustment(Model\SaleItemAdjustmentInterface $adjustment)
    {
        if (null !== $item = $adjustment->getItem()) {
            return $item;
        }

        $cs = $this->persistenceHelper->getChangeSet($adjustment, 'item');

        if (!empty($cs)) {
            return $cs[0];
        }

        return null;
    }

    /**
     * Returns the sale from the given sale item (looking into change set).
     *
     * @param Model\SaleItemInterface $item
     *
     * @return Model\SaleInterface|null
     */
    protected function getSaleFromItem(Model\SaleItemInterface $item)
    {
        if (null !== $sale = $item->getSale()) {
            return $sale;
        }

        $path = $this->getSalePropertyPath();
        $cs = $this->persistenceHelper->getChangeSet($item);

        if (isset($cs[$path])) {
            return $cs[$path][0];
        } elseif (null !== $parent = $item->getParent()) {
            return $this->getSaleFromItem($parent);
        } elseif (isset($cs['parent'])) {
            return $this->getSaleFromItem($cs['parent'][0]);
        }

        return null;
    }

    /**
     * Returns the sale from the given sale adjustment (looking into change set).
     *
     * @param Model\SaleAdjustmentInterface $adjustment
     *
     * @return Model\SaleInterface|null
     */
    protected function getSaleFromAdjustment(Model\SaleAdjustmentInterface $adjustment)
    {
        if (null !== $sale = $adjustment->getSale()) {
            return $sale;
        }

        $cs = $this->persistenceHelper->getChangeSet($adjustment, $this->getSalePropertyPath());

        if (!empty($cs)) {
            return $cs[0];
        }

        return null;
    }

    /**
     * Returns the sale item property path.
     *
     * @return string
     */
    abstract protected function getSalePropertyPath();

    /**
     * Returns the sale change event name.
     *
     * @return string
     */
    abstract protected function getSaleChangeEvent();

    /**
     * Returns the adjustment from the resource event.
     *
     * @param ResourceEventInterface $event
     *
     * @return Model\AdjustmentInterface
     */
    abstract protected function getAdjustmentFromEvent(ResourceEventInterface $event);
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository;

use Doctrine\ORM\Event\OnClearEventArgs;
use Ekyna\Component\Commerce\Common\Model\CountryInterface;
use Ekyna\Component\Commerce\Common\Repository\CountryRepositoryInterface;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Resource\Doctrine\ORM\ResourceRepository;

/**
 * Class CountryRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CountryRepository extends ResourceRepository implements CountryRepositoryInterface
{
    /**
     * @var string
     */
    private $defaultCode;

    /**
     * @var string[]
     */
    private $enabledCodes;

    /**
     * @var string[]
     */
    private $allCodes;

    /**
     * @var CountryInterface
     */
    private $defaultCountry;

    /**
     * @var array
     */
    private $cachedCodes;

    /**
     * @var array
     */
    private $cache = [];

    /**
     * Returns the defaultCode.
     *
     * @return string
     */
    public function getDefaultCode(): string
    {
        return $this->defaultCode;
    }

    /**
     * Sets the default code.
     *
     * @param string $code
     */
    public function setDefaultCode(string $code)
    {
        $this->defaultCode = strtoupper($code);
    }

    /**
     * Sets the cached codes.
     *
     * @param array $codes
     */
    public function setCachedCodes($codes)
    {
        $this->cachedCodes = $codes;
    }

    /**
     * @inheritDoc
     */
    public function findDefault()
    {
        if (null !== $this->defaultCountry) {
            return $this->defaultCountry;
        }

        if (null !== $country = $this->findOneByCode($this->defaultCode)) {
            return $this->defaultCountry = $country;
        }

        throw new RuntimeException('Default country not found.');
    }

    /**
     * @inheritDoc
     */
    public function findOneByCode($code)
    {
        $code = strtoupper($code);

        if (isset($this->cache[$code])) {
            return $this->cache[$code];
        }

        return $this->cache[$code] = $this
            ->getQueryBuilder('c')
            ->andWhere('c.code = :code')
            ->getQuery()
            ->setParameter('code', $code)
            ->getOneOrNullResult();
    }

    /**
     * @inheritDoc
     */
    public function findEnabledCodes()
    {
        if (null !== $this->enabledCodes) {
            return $this->enabledCodes;
        }

        // TODO Caching

        $result = $this
            ->getQueryBuilder('c')
            ->select('c.code')
            ->andWhere('c.enabled = :enabled')
            ->setParameter('enabled', true)
            ->getQuery()
            ->getScalarResult();

        return $this->enabledCodes = array_column($result, 'code');
    }

    /**
     * @inheritDoc
     */
    public function findAllCodes()
    {
        if (null !== $this->allCodes) {
            return $this->allCodes;
        }

        // TODO Caching

        $result = $this
            ->getQueryBuilder('c')
            ->select('c.code')
            ->getQuery()
            ->getScalarResult();

        return $this->allCodes = array_column($result, 'code');
    }

    /**
     * @inheritDoc
     */
    public function getIdentifiers($cached = false)
    {
        $qb = $this->createQueryBuilder('c');
        $qb
            ->select('c.id')
            ->orderBy('c.id');

        if ($cached) {
            $qb
                ->andWhere($qb->expr()->in('c.code', ':codes'))
                ->setParameter('codes', $this->cachedCodes);
        }

        return array_column($qb->getQuery()->getScalarResult(), 'id');
    }

    /**
     * On clear event handler.
     *
     * @param OnClearEventArgs $event
     */
    public function onClear(OnClearEventArgs $event)
    {
        if ((null === $event->getEntityClass()) || ($this->getClassName() === $event->getEntityClass())) {
            $this->defaultCountry = null;
            $this->cache = [];
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Gateway;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Shipment\Model as Shipment;

/**
 * Class AbstractProvider
 * @package Ekyna\Component\Commerce\Shipment\Gateway
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractProvider implements ProviderInterface
{
    /**
     * @var RegistryInterface
     */
    private $registry;

    /**
     * @var array|GatewayInterface[]
     */
    private $gateways = [];

    /**
     * @var bool
     */
    private $initialized = false;

    /**
     * @inheritDoc
     */
    public function setRegistry(RegistryInterface $registry)
    {
        $this->registry = $registry;
    }

    /**
     * @inheritdoc
     */
    public function hasGateway($name)
    {
        $this->initialize();

        return isset($this->gateways[$name]);
    }

    /**
     * @inheritdoc
     */
    public function getGateway($name)
    {
        $this->initialize();

        if (!$this->hasGateway($name)) {
            throw new InvalidArgumentException(sprintf("Shipment gateway '%s' is not registered.", $name));
        }

        return $this->gateways[$name];
    }

    /**
     * @inheritDoc
     */
    public function allGateways()
    {
        $this->initialize();

        return $this->gateways;
    }

    /**
     * Loads the gateways.
     */
    abstract protected function loadGateways();

    /**
     * Creates and registers a gateway.
     *
     * @param string $platformName
     * @param string $name
     * @param array  $config
     */
    protected function createAndRegisterGateway($platformName, $name, array $config)
    {
        $platform = $this->registry->getPlatform($platformName);

        $gateway = $platform->createGateway($name, $config);

        if ($gateway instanceof Shipment\AddressResolverAwareInterface) {
            $gateway->setAddressResolver($this->registry->getAddressResolver());
        }
        if ($gateway instanceof Shipment\WeightCalculatorAwareInterface) {
            $gateway->setWeightCalculator($this->registry->getWeightCalculator());
        }
        if ($gateway instanceof PersisterAwareInterface) {
            $gateway->setPersister($this->registry->getPersister());
        }

        $this->gateways[$name] = $gateway;
    }

    /**
     * Initializes the provider.
     */
    private function initialize()
    {
        if ($this->initialized) {
            return;
        }

        $this->loadGateways();

        $this->initialized = true;
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints;

use Ekyna\Component\Commerce\Subject\Entity\SubjectIdentity as Entity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * Class SubjectIdentityValidator
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SubjectIdentityValidator extends ConstraintValidator
{
    /**
     * {@inheritdoc}
     */
    public function validate($identity, Constraint $constraint)
    {
        if (null === $identity) {
            return;
        }

        if (!$identity instanceof Entity) {
            throw new UnexpectedTypeException($identity, Entity::class);
        }
        if (!$constraint instanceof SubjectIdentity) {
            throw new UnexpectedTypeException($constraint, SubjectIdentity::class);
        }

        $hasProvider = empty($identity->getProvider());
        $hasIdentifier = empty($identity->getIdentifier());

        if (($hasProvider || $hasIdentifier) && !($hasProvider && $hasIdentifier)) {
            $this->context
                ->buildViolation($constraint->identity_subject_must_be_selected)
                ->atPath('provider')
                ->addViolation();
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer;

use Ekyna\Component\Commerce\Common\Model\AttachmentInterface;
use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

/**
 * Class AbstractAttachmentNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractAttachmentNormalizer extends AbstractResourceNormalizer
{
    use FormatterAwareTrait;

    /**
     * Normalizes the attachment.
     *
     * @param AttachmentInterface $attachment
     *
     * @return array
     */
    protected function normalizeAttachment(AttachmentInterface $attachment)
    {
        $formatter = $this->getFormatter();

        return [
            'id'           => $attachment->getId(),
            'title'        => $attachment->getTitle(),
            'type'         => $attachment->getType(),
            'size'         => $attachment->getSize(),
            'internal'     => $attachment->isInternal(),
            'file'         => pathinfo($attachment->getPath(), PATHINFO_BASENAME),
            'created_at'   => ($date = $attachment->getCreatedAt()) ? $date->format('Y-m-d H:i:s') : null,
            'f_created_at' => ($date = $attachment->getCreatedAt()) ? $formatter->dateTime($date) : null,
            'updated_at'   => ($date = $attachment->getUpdatedAt()) ? $date->format('Y-m-d H:i:s') : null,
            'f_updated_at' => ($date = $attachment->getUpdatedAt()) ? $formatter->dateTime($date) : null,
        ];
    }
}


<?php

namespace Ekyna\Component\Commerce\Pricing\Api\Provider;

use Ekyna\Component\Commerce\Pricing\Api\VatNumberResult;

/**
 * Class Europa
 * @package Ekyna\Component\Commerce\Pricing\Api\Provider
 * @author  Etienne Dauvergne <contact@ekyna.com>
 * @see http://ec.europa.eu/taxation_customs/vies/?locale=fr
 */
class Europa implements VatNumberValidatorInterface
{
    const SERVICE_ID = 'ekyna_commerce.pricing.api.provider.europa';

    const ENDPOINT = 'http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';

    const COUNTRY_CODES = [
        'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'EL', 'HU', 'IE',
        'IT', 'LV', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'BG',
    ];

    /**
     * @var bool
     */
    private $debug;

    /**
     * @var \SoapClient;
     */
    private $client;

    /**
     * Constructor.
     *
     * @param bool $debug
     */
    public function __construct($debug = false)
    {
        $this->debug = $debug;
    }

    /**
     * @inheritDoc
     */
    public function validateVatNumber($vatNumber)
    {
        $vatNumber = preg_replace('~[^A-Z0-9]+~', '', strtoupper($vatNumber));

        // Abort if the country code is not supported
        $country = substr($vatNumber, 0, 2);
        if (!in_array($country, static::COUNTRY_CODES, true)) {
            return null;
        }

        if ($client = $this->getClient()) {
            try {
                /** @noinspection PhpUndefinedMethodInspection */
                $response = $client->checkVat([
                    'countryCode' => $country,
                    'vatNumber'   => substr($vatNumber, 2),
                ]);

                return new VatNumberResult(
                    $response->valid,
                    $response->countryCode,
                    $response->vatNumber,
                    $response->name,
                    $response->address
                );
            } catch (\SoapFault $oExcept) {
                if ($this->debug) {
                    @trigger_error('Failed to retrieve the VAT details: ' . $oExcept->getMessage());
                }
            }
        }

        return null;
    }

    /**
     * Returns the soap client.
     *
     * @return \SoapClient
     */
    private function getClient()
    {
        if (null !== $this->client) {
            return $this->client;
        }

        try {
            return $this->client = new \SoapClient(static::ENDPOINT);
        } catch (\SoapFault $oExcept) {
            if ($this->debug) {
                @trigger_error('Failed to connect to the europa web service: ' . $oExcept->getMessage());
            }
        }

        return $this->client = null;
    }
}