<?php

namespace PeskyCMF\Providers;

use PeskyCMF\Config\CmfConfig;
use Vluzrmos\LanguageDetector\LanguageDetector;
use Vluzrmos\LanguageDetector\Providers\LanguageDetectorServiceProvider;

class PeskyCmfLanguageDetectorServiceProvider extends LanguageDetectorServiceProvider {

    /** @var array */
    protected $configsOverride = [];
    /** @var null|string */
    protected $defaultLanguage = null;

    public function register() {
        parent::register();

        \Event::listen('locale.changed', function ($locale) {
            $this->applyNewLanguage($locale);
        });
    }

    public function importConfigsFromPeskyCmf(CmfConfig $cmfConfig) {
        $this->defaultLanguage = $cmfConfig::default_locale();
        $this->configsOverride = $cmfConfig::language_detector_configs();
        $this->detectAndApplyLanguage();
    }

    public function boot() {
        if ($this->defaultLanguage) {
            $this->app['request']->setDefaultLocale($this->defaultLanguage);
            if (method_exists($this->app['translator'], 'setFallback')) {
                $this->app['translator']->setFallback($this->defaultLanguage);
            }
        }
        parent::boot();
    }

    protected function config($key, $default = null) {
        return array_get($this->configsOverride, $key, function () use ($key, $default) {
            return parent::config($key, $default);
        });
    }

    /**
     * Detect and apply language for the application.
     * Failsafe
     */
    protected function detectAndApplyLanguage() {
        if ($this->config('autodetect', true)) {
            /** @var LanguageDetector $detector */
            $detector = $this->getLanguageDetector();
            $language = $detector->getLanguageFromCookie();
            if (!$language || strlen($language) > 5 || !in_array($language, $this->getSupportedLanguages(), true)) {
                $language = $detector->getDriver()->detect();
                if (!$language || strlen($language) > 5) {
                    $language = $this->request->getDefaultLocale();
                }
            }
            $this->applyNewLanguage($language, true);
        }
    }

    protected function applyNewLanguage($language, $force = false) {
        $langsMapping = $this->config('languages', []);
        // change $language according to mapping (key - proxied language, value - real language)
        $language = array_get($langsMapping, $language, $language);
        // test if $language is supported by confings
        if (!in_array($language, $langsMapping, true)) {
            if (!$force) {
                return; //< leave current locale untouched
            }
            $language = $this->defaultLanguage ?: $this->request->getDefaultLocale();
        }
        /** @var LanguageDetector $detector */
        $detector = $this->getLanguageDetector();
        $detector->addCookieToQueue($language);
        $detector->apply($language);
    }

}


<?php

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

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * Class GenderValidator
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints
 * @author Étienne Dauvergne <contact@ekyna.com>
 */
class GenderValidator extends ConstraintValidator
{
    /**
     * @var string
     */
    private $genderClass;

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

    /**
     * {@inheritdoc}
     */
    public function validate($gender, Constraint $constraint)
    {
        if (null === $gender) {
            return;
        }

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

        if (!call_user_func($this->genderClass.'::isValid', $gender)) {
            $this->context
                ->buildViolation($constraint->invalid_gender)
                ->addViolation();
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Support\EventListener;

use Ekyna\Component\Commerce\Exception\UnexpectedValueException;
use Ekyna\Component\Commerce\Support\Model\TicketMessageInterface;
use Ekyna\Component\Commerce\Support\Model\TicketStates;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

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

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

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $message = $this->getMessageFromEvent($event);

        $this->updateTicket($message);
    }

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

        $this->updateTicket($message);
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onDelete(ResourceEventInterface $event)
    {
        // TODO Update ticket regarding to last message (not this one)
    }

    /**
     * Updates the ticket.
     *
     * @param TicketMessageInterface $message
     */
    protected function updateTicket(TicketMessageInterface $message)
    {
        $ticket = $message->getTicket()->setUpdatedAt(new \DateTime());

        if ($message->isLatest() && ($ticket->getState() !== TicketStates::STATE_CLOSED)) {
            if ($message->isCustomer()) {
                if ($ticket->getState() === TicketStates::STATE_PENDING) {
                    $ticket->setState(TicketStates::STATE_OPENED);
                }
            } elseif ($ticket->getState() === TicketStates::STATE_OPENED) {
                $ticket->setState(TicketStates::STATE_PENDING);
            }
        }

        $this->persistenceHelper->persistAndRecompute($ticket, false);
    }

    /**
     * Returns the message from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return TicketMessageInterface
     */
    protected function getMessageFromEvent(ResourceEventInterface $event)
    {
        $message = $event->getResource();

        if (!$message instanceof TicketMessageInterface) {
            throw new UnexpectedValueException("Expected instance of " . TicketMessageInterface::class);
        }

        return $message;
    }
}


<?php

namespace mii\cache;

use mii\util\FS;

class File extends Cache
{
    public $path = '@tmp/cache';

    public $directory_level = 1;

    public $chmode = 0775;

    /**
     * Check for existence of the APC extension This method cannot be invoked externally. The driver must
     * be instantiated using the `Cache::instance()` method.
     *
     * @param  array $config configuration
     * @throws CacheException
     */
    public function init(array $config = []): void {

        parent::init($config);

        $this->path = \Mii::resolve($this->path);
        if(!is_dir($this->path)) {
            FS::mkdir($this->path, 0777);
        }
    }

    /**
     * Retrieve a cached value entry by id.
     *
     * @param   string $id id of cache to entry
     * @param   string $default default value to return if cache miss
     * @return  mixed
     * @throws  CacheException
     */
    public function get($id, $default = NULL) {

        $filename = $this->cache_file($id);

        if (@filemtime($filename) > time()) {
            $fp = @fopen($filename, 'r');
            if ($fp !== false) {
                @flock($fp, LOCK_SH);
                $value = unserialize(@stream_get_contents($fp));
                @flock($fp, LOCK_UN);
                @fclose($fp);
                return $value;
            }
        }

        return $default;
    }

    /**
     * Set a value to cache with id and lifetime
     *
     *     $data = 'bar';
     *
     *     // Set 'bar' to 'foo' in apc group, using default expiry
     *     Cache::instance('apc')->set('foo', $data);
     *
     *     // Set 'bar' to 'foo' in apc group for 30 seconds
     *     Cache::instance('apc')->set('foo', $data, 30);
     *
     * @param   string $id id of cache entry
     * @param   string $data data to set to cache
     * @param   integer $lifetime lifetime in seconds
     * @return  boolean
     */
    public function set($id, $data, $lifetime = NULL) {
        if ($lifetime === NULL) {
            $lifetime = $this->default_expire;
        }

        $filename = $this->cache_file($id);

        if ($this->directory_level > 0) {
            FS::mkdir(dirname($filename), $this->chmode);
        }
        if (@file_put_contents($filename, serialize($data), LOCK_EX) !== false) {
            if ($this->chmode !== null) {
                @chmod($filename, $this->chmode);
            }
            if ($lifetime <= 0) {
                $lifetime = 60*60*24*7;
            }
            return @touch($filename, $lifetime + time());
        }
        $error = error_get_last();

        \Mii::warning("Unable to write cache file '{$filename}': {$error['message']}", 'mii');
        return false;

    }

    /**
     * Delete a cache entry based on id
     *
     *     // Delete 'foo' entry from the apc group
     *     Cache::instance('apc')->delete('foo');
     *
     * @param   string $id id to remove from cache
     * @return  boolean
     */
    public function delete($id) {
        return @unlink($this->cache_file($id));
    }

    /**
     * Delete all cache entries.
     *
     */
    public function delete_all(): bool {
        return $this->recursive_remove($this->path);
    }

    private function recursive_remove($path) {
        $result = true;
        try {
            foreach ((new \DirectoryIterator($path)) as $fi) {
                if ($fi->isDir() && !$fi->isDot()) {
                    $result = $this->recursive_remove($fi->getPathname());
                    rmdir($fi->getPathname());
                }

                if ($fi->isFile()) {
                    unlink($fi->getPathname());
                }
            }
        } catch (\Throwable $t) {

            \Mii::warning("Unable to clear cache: {$t->getMessage()}", 'mii');
            return false;
        }
        return $result;
    }

    /**
     * Increments a given value by the step value supplied.
     * Useful for shared counters and other persistent integer based
     * tracking.
     *
     * @param   string $id of cache entry to increment
     * @param   int $step value to increment by
     * @return  integer
     * @return  boolean
     */
    public function increment($id, $step = 1) {

    }

    /**
     * Decrements a given value by the step value supplied.
     * Useful for shared counters and other persistent integer based
     * tracking.
     *
     * @param   string $id of cache entry to decrement
     * @param   int $step value to decrement by
     * @return  integer
     * @return  boolean
     */
    public function decrement($id, $step = 1) {

    }

    /**
     * Returns the cache file path given the cache key.
     * @param string $key cache key
     * @return string the cache file path
     */
    protected function cache_file($key)
    {
        $key = sha1($key);

        if ($this->directory_level > 0) {
            $base = $this->path;
            for ($i = 0; $i < $this->directory_level; ++$i) {
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
                    $base .= DIRECTORY_SEPARATOR . $prefix;
                }
            }
            return $base . DIRECTORY_SEPARATOR . $key;
        }
        return $this->path . DIRECTORY_SEPARATOR . $key;
    }

}


<?php

namespace Ekyna\Component\Commerce\Common\Util;

use Ekyna\Component\Commerce\Common\Model\AddressInterface;

/**
 * Class AddressUtil
 * @package Ekyna\Component\Commerce\Common\Util
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class AddressUtil
{
    /**
     * Returns whether this address equals the given address or not.
     *
     * @param AddressInterface $source
     * @param AddressInterface $target
     *
     * @return boolean
     */
    static public function equals(AddressInterface $source, AddressInterface $target)
    {
        if (!($source->getCompany() === $target->getCompany()
            && $source->getGender() === $target->getGender()
            && $source->getFirstName() === $target->getFirstName()
            && $source->getLastName() === $target->getLastName()
            && $source->getStreet() === $target->getStreet()
            && $source->getComplement() === $target->getComplement()
            && $source->getSupplement() === $target->getSupplement()
            && $source->getExtra() === $target->getExtra()
            && $source->getCity() === $target->getCity()
            && $source->getPostalCode() === $target->getPostalCode()
            && $source->getDigicode1() === $target->getDigicode1()
            && $source->getDigicode2() === $target->getDigicode2()
            && $source->getIntercom() === $target->getIntercom())) {
            return false;
        }

        $sourceCountryId = $source->getCountry() ? $source->getCountry()->getId() : null;
        $targetCountryId = $target->getCountry() ? $target->getCountry()->getId() : null;
        if ($sourceCountryId != $targetCountryId) {
            return false;
        }

        $sourceStateId = $source->getState() ? $source->getState()->getId() : null;
        $targetStateId = $target->getState() ? $target->getState()->getId() : null;
        if ($sourceStateId != $targetStateId) {
            return false;
        }

        $sourcePhone = (string) $source->getPhone();
        $targetPhone = (string) $target->getPhone();
        if ($sourcePhone !== $targetPhone) {
            return false;
        }

        $sourceMobile = (string) $source->getMobile();
        $targetMobile = (string) $target->getMobile();
        if ($sourceMobile !== $targetMobile) {
            return false;
        }

        return true;
    }

    /**
     * Copy the source address data into the target address.
     *
     * @param AddressInterface $source
     * @param AddressInterface $target
     */
    static public function copy(AddressInterface $source, AddressInterface $target)
    {
        $target
            ->setCompany($source->getCompany())
            ->setGender($source->getGender())
            ->setFirstName($source->getFirstName())
            ->setLastName($source->getLastName())
            ->setStreet($source->getStreet())
            ->setComplement($source->getComplement())
            ->setSupplement($source->getSupplement())
            ->setExtra($source->getExtra())
            ->setCity($source->getCity())
            ->setPostalCode($source->getPostalCode())
            ->setCountry($source->getCountry())
            ->setState($source->getState())
            ->setDigicode1($source->getDigicode1())
            ->setDigicode2($source->getDigicode2())
            ->setIntercom($source->getIntercom())
            ->setLatitude($source->getLatitude())
            ->setLongitude($source->getLongitude());

        if (is_object($phone = $source->getPhone())) {
            $target->setPhone(clone $phone);
        } else {
            $target->setPhone($phone);
        }

        if (is_object($mobile = $source->getMobile())) {
            $target->setMobile(clone $mobile);
        } else {
            $target->setMobile($mobile);
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Pricing\Api;

/**
 * Class VatNumberResult
 * @package Ekyna\Component\Commerce\Customer\Validator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class VatNumberResult
{
    /**
     * @var bool
     */
    private $valid;

    /**
     * @var string
     */
    private $country;

    /**
     * @var string
     */
    private $number;

    /**
     * @var string
     */
    private $name;

    /**
     * @var string
     */
    private $address;

    /**
     * @var string
     */
    private $date;

    /**
     * Constructor.
     *
     * @param bool   $valid
     * @param string $country
     * @param string $number
     * @param string $name
     * @param string $address
     */
    public function __construct($valid = false, $country = '', $number = '', $name = '', $address = '')
    {
        $this->valid = $valid;
        $this->country = $country;
        $this->number = $number;
        $this->name = $name;
        $this->address = $address;
        $this->date = date('c');
    }

    /**
     * Returns the valid.
     *
     * @return bool
     */
    public function isValid()
    {
        return $this->valid;
    }

    /**
     * Returns the details.
     *
     * @return array
     */
    public function getDetails()
    {
        return [
            'valid' => $this->valid,
            'country' => $this->country,
            'number' => $this->number,
            'name' => $this->name,
            'address' => $this->address,
            'date' => $this->date,
        ];
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Model;

/**
 * Class OpeningHour
 * @package Ekyna\Component\Commerce\Shipment\Gateway\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class OpeningHour
{
    /**
     * @var int
     */
    private $day;

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

    /**
     * Returns the day.
     *
     * @return int
     */
    public function getDay()
    {
        return $this->day;
    }

    /**
     * Sets the day.
     *
     * @param int $day (ISO-8601 day of week number, 1 for monday, 7 for sunday)
     *
     * @return OpeningHour
     */
    public function setDay(int $day)
    {
        $this->day = $day;

        return $this;
    }

    /**
     * Returns the ranges.
     *
     * @return array
     */
    public function getRanges()
    {
        return $this->ranges;
    }

    /**
     * Sets the ranges.
     *
     * @param string $from
     * @param string $to
     *
     * @return OpeningHour
     */
    public function addRanges(string $from, string $to)
    {
        $this->ranges[] = [
            'from' => $from,
            'to'   => $to,
        ];

        return $this;
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Calculator;

/**
 * Class Tax
 * @package Ekyna\Component\Commerce\Common\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Tax
{
    /**
     * @var string
     */
    private $name;

    /**
     * @var float
     */
    private $rate;

    /**
     * @var float
     */
    private $base = 0;

    /**
     * @var float
     */
    private $precision = 0;

    /**
     * Constructor.
     *
     * @param string $name
     * @param float  $rate
     * @param float  $base
     * @param int    $precision
     */
    public function __construct($name, $rate, $base = .0, $precision = 2)
    {
        $this->name = $name;
        $this->rate = (float)$rate;
        $this->precision = $precision;

        $this->addBase($base);
    }

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

    /**
     * Returns the rate.
     *
     * @return float
     */
    public function getRate()
    {
        return $this->rate;
    }

    /**
     * Returns the base.
     *
     * @return float
     */
    public function getBase()
    {
        return $this->base;
    }

    /**
     * Adds the base.
     *
     * @param float $base
     *
     * @return Tax
     */
    public function addBase($base)
    {
        $this->base += (float)$base;

        return $this;
    }

    /**
     * Multiply the tax (base).
     *
     * @param float $quantity
     *
     * @return Tax
     */
    public function multiply($quantity)
    {
        $this->base = round($this->base * $quantity, $this->precision);

        return $this;
    }

    /**
     * Returns the tax amount.
     *
     * @return float
     */
    public function getAmount()
    {
        return $this->round($this->base * $this->rate / 100);
    }

    /**
     * Rounds the given amount.
     *
     * @param float $amount
     *
     * @return float
     */
    private function round($amount)
    {
        return round($amount, $this->precision);
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Model;

use Doctrine\Common\Collections\ArrayCollection;

/**
 * Trait NotifiableTrait
 * @package Ekyna\Component\Commerce\Common\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
trait NotifiableTrait
{
    /**
     * @var bool
     */
    protected $autoNotify;

    /**
     * @var ArrayCollection|NotificationInterface[]
     */
    protected $notifications;

    /**
     * Initializes the notifications.
     */
    protected function initializeNotifications()
    {
        $this->autoNotify = true;
        $this->notifications = new ArrayCollection();
    }

    /**
     * Returns whether notify is enabled.
     *
     * @return bool
     */
    public function isAutoNotify()
    {
        return $this->autoNotify;
    }

    /**
     * Sets whether notify is enabled.
     *
     * @param bool $enabled
     *
     * @return $this|NotificationInterface
     */
    public function setAutoNotify($enabled)
    {
        $this->autoNotify = $enabled;

        return $this;
    }

    /**
     * Returns whether the notifiable has notifications or not, optionally filtered by type.
     *
     * @param string $type
     *
     * @return bool
     */
    public function hasNotifications($type = null)
    {
        if (null !== $type) {
            NotificationTypes::isValidType($type);

            return $this->getNotifications($type)->count();
        }

        return 0 < $this->notifications->count();
    }

    /**
     * Returns the notifications, optionally filtered by type.
     *
     * @param string $type
     *
     * @return ArrayCollection|NotificationInterface[]
     */
    public function getNotifications($type = null)
    {
        if (null !== $type) {
            NotificationTypes::isValidType($type);

            return $this
                ->notifications
                ->filter(function (NotificationInterface $n) use ($type) {
                    return $n->getType() === $type;
                });
        }

        return $this->notifications;
    }
}


<?php

namespace Ekyna\Component\Commerce\Supplier\EventListener;

use Ekyna\Component\Commerce\Common\Currency\CurrencyConverterInterface;
use Ekyna\Component\Commerce\Common\Generator\NumberGeneratorInterface;
use Ekyna\Component\Commerce\Common\Resolver\StateResolverInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Supplier\Calculator\SupplierOrderCalculatorInterface;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderInterface;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderStates;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class SupplierOrderListener
 * @package Ekyna\Component\Commerce\Supplier\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierOrderListener extends AbstractListener
{
    /**
     * @var NumberGeneratorInterface
     */
    protected $numberGenerator;

    /**
     * @var SupplierOrderCalculatorInterface
     */
    protected $calculator;

    /**
     * @var StateResolverInterface
     */
    protected $stateResolver;

    /**
     * @var CurrencyConverterInterface
     */
    protected $currencyConverter;

    /**
     * Constructor.
     *
     * @param NumberGeneratorInterface         $numberGenerator
     * @param SupplierOrderCalculatorInterface $calculator
     * @param StateResolverInterface           $stateResolver
     * @param CurrencyConverterInterface       $currencyConverter
     */
    public function __construct(
        NumberGeneratorInterface $numberGenerator,
        SupplierOrderCalculatorInterface $calculator,
        StateResolverInterface $stateResolver,
        CurrencyConverterInterface $currencyConverter
    ) {
        $this->numberGenerator = $numberGenerator;
        $this->calculator = $calculator;
        $this->stateResolver = $stateResolver;
        $this->currencyConverter = $currencyConverter;
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $order = $this->getSupplierOrderFromEvent($event);

        $changed = $this->updateNumber($order);

        $changed |= $this->updateState($order);

        $changed |= $this->updateTotals($order);

        $changed |= $this->updateExchangeRate($order);

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($order);
        }
    }

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

        $changed = $this->updateNumber($order);

        $changed |= $this->updateState($order);

        $changed |= $this->updateTotals($order);

        $changed |= $this->updateExchangeRate($order);

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($order);
        }

        // Deletable <=> Stockable state change case.
        if ($this->persistenceHelper->isChanged($order, 'state')) {
            $stateCs = $this->persistenceHelper->getChangeSet($order, 'state');

            // If order's state has changed to a non stockable state
            if (SupplierOrderStates::hasChangedFromStockable($stateCs)) {
                // Delete stock unit (if exists) for each supplier order items.
                foreach ($order->getItems() as $item) {
                    $this->stockUnitLinker->unlinkItem($item);
                    //$this->deleteSupplierOrderItemStockUnit($item);
                }
            } // Else if order state's has changed to a stockable state
            elseif (SupplierOrderStates::hasChangedToStockable($stateCs)) {
                // Create stock unit (if not exists) for each supplier order items.
                foreach ($order->getItems() as $item) {
                    $this->stockUnitLinker->linkItem($item);
                    //$this->createSupplierOrderItemStockUnit($item);
                }
            }
        }

        // If order's estimated date of arrival has changed and order's state is stockable
        if (
            $this->persistenceHelper->isChanged($order, 'estimatedDateOfArrival')
            && SupplierOrderStates::isStockableState($order->getState())
        ) {
            // Update stock units estimated date of arrival
            foreach ($order->getItems() as $item) {
                if (!$item->hasSubjectIdentity()) {
                    continue;
                }

                if (null === $stockUnit = $item->getStockUnit()) {
                    throw new RuntimeException("Failed to retrieve stock unit.");
                }

                $this->stockUnitUpdater->updateEstimatedDateOfArrival($stockUnit, $order->getEstimatedDateOfArrival());
            }
        }
    }

    /**
     * Content change event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onContentChange(ResourceEventInterface $event)
    {
        $order = $this->getSupplierOrderFromEvent($event);

        $changed = $this->updateState($order);

        $changed |= $this->updateTotals($order);

        if ($changed) {
            $this->persistenceHelper->persistAndRecompute($order);
        }
    }

    /**
     * Initialize event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInitialize(ResourceEventInterface $event)
    {
        $order = $this->getSupplierOrderFromEvent($event);

        if (null !== $supplier = $order->getSupplier()) {
            if ($order->getCurrency() !== $supplier->getCurrency()) {
                $order->setCurrency($supplier->getCurrency());
            }
            if (null === $order->getCarrier()) {
                $order->setCarrier($supplier->getCarrier());
            }
        }
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $order = $this->getSupplierOrderFromEvent($event);

        $this->assertDeletable($order);
    }

    /**
     * Updates the number.
     *
     * @param SupplierOrderInterface $order
     *
     * @return bool Whether or not the supplier order number has been changed.
     */
    protected function updateNumber(SupplierOrderInterface $order)
    {
        if (0 == strlen($order->getNumber())) {
            $this->numberGenerator->generate($order);

            return true;
        }

        return false;
    }

    /**
     * Updates the state.
     *
     * @param SupplierOrderInterface $order
     *
     * @return bool Whether or not the supplier order has been changed.
     */
    protected function updateState(SupplierOrderInterface $order)
    {
        $changed = $this->stateResolver->resolve($order);

        // If order state is 'completed' and 'competed at' date is not set
        if ($order->getState() === SupplierOrderStates::STATE_COMPLETED
            && null === $order->getCompletedAt()
        ) {
            // Set the 'completed at' date
            $order->setCompletedAt(new \DateTime());
            $changed = true;
        }

        return $changed;
    }

    /**
     * Updates the payment and forwarder totals.
     *
     * @param SupplierOrderInterface $order
     *
     * @return bool Whether or not the supplier order has been changed.
     */
    protected function updateTotals(SupplierOrderInterface $order)
    {
        $changed = false;

        $tax = $this->calculator->calculatePaymentTax($order);
        if ($tax != $order->getTaxTotal()) {
            $order->setTaxTotal($tax);
            $changed = true;
        }

        $payment = $this->calculator->calculatePaymentTotal($order);
        if ($payment != $order->getPaymentTotal()) {
            $order->setPaymentTotal($payment);
            $changed = true;
        }

        if (null !== $order->getCarrier()) {
            $forwarder = $this->calculator->calculateForwarderTotal($order);
            if ($forwarder != $order->getForwarderTotal()) {
                $order->setForwarderTotal($forwarder);
                $changed = true;
            }
        } else {
            if (0 != $order->getForwarderFee()) {
                $order->setForwarderFee(0);
                $changed = true;
            }
            if (0 != $order->getCustomsTax()) {
                $order->setCustomsTax(0);
                $changed = true;
            }
            if (0 != $order->getCustomsVat()) {
                $order->setCustomsVat(0);
                $changed = true;
            }
            if (0 != $order->getForwarderTotal()) {
                $order->setForwarderTotal(0);
                $changed = true;
            }
            if (null !== $order->getForwarderDate()) {
                $order->setForwarderDate(null);
                $changed = true;
            }
            if (null !== $order->getForwarderDueDate()) {
                $order->setForwarderDueDate(null);
                $changed = true;
            }
        }

        return $changed;
    }

    /**
     * Updates the order exchange rate.
     *
     * @param SupplierOrderInterface $order
     *
     * @return bool Whether the payment has been changed or not.
     */
    protected function updateExchangeRate(SupplierOrderInterface $order)
    {
        // TODO Remove when supplier order payments will be implemented.
        if (null !== $order->getExchangeRate()) {
            return false;
        }

        if (!SupplierOrderStates::isStockableState($order->getState())) {
            return false;
        }

        $date = new \DateTime();

        $rate = $this->currencyConverter->getRate(
            $this->currencyConverter->getDefaultCurrency(),
            $order->getCurrency()->getCode(),
            $date
        );

        $order
            ->setExchangeRate($rate)
            ->setExchangeDate($date);

        return true;
    }

    /**
     * Returns the supplier order from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return SupplierOrderInterface
     * @throws InvalidArgumentException
     */
    protected function getSupplierOrderFromEvent(ResourceEventInterface $event)
    {
        $order = $event->getResource();

        if (!$order instanceof SupplierOrderInterface) {
            throw new InvalidArgumentException("Expected instance of SupplierOrderInterface.");
        }

        return $order;
    }
}


<?php

namespace Ekyna\Component\Commerce\Invoice\Builder;

use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Document\Builder\DocumentBuilder;
use Ekyna\Component\Commerce\Document\Model as Document;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Invoice\Calculator\InvoiceCalculatorInterface;
use Ekyna\Component\Commerce\Invoice\Model as Invoice;
use Ekyna\Component\Commerce\Shipment\Calculator\ShipmentCalculatorInterface;
use libphonenumber\PhoneNumberUtil;

/**
 * Class InvoiceBuilder
 * @package Ekyna\Component\Commerce\Invoice\Builder
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class InvoiceBuilder extends DocumentBuilder implements InvoiceBuilderInterface
{
    /**
     * @var SaleFactoryInterface
     */
    private $saleFactory;

    /**
     * @var InvoiceCalculatorInterface
     */
    private $invoiceCalculator;

    /**
     * @var ShipmentCalculatorInterface
     */
    private $shipmentCalculator;

    /**
     * Constructor.
     *
     * @param SaleFactoryInterface        $factory
     * @param InvoiceCalculatorInterface  $invoiceCalculator
     * @param ShipmentCalculatorInterface $shipmentCalculator
     * @param PhoneNumberUtil             $phoneNumberUtil
     */
    public function __construct(
        SaleFactoryInterface $factory,
        InvoiceCalculatorInterface $invoiceCalculator,
        ShipmentCalculatorInterface $shipmentCalculator,
        PhoneNumberUtil $phoneNumberUtil = null
    ) {
        parent::__construct($phoneNumberUtil);

        $this->saleFactory = $factory;
        $this->invoiceCalculator = $invoiceCalculator;
        $this->shipmentCalculator = $shipmentCalculator;
    }

    /**
     * Returns the sale factory.
     *
     * @return SaleFactoryInterface
     */
    public function getSaleFactory()
    {
        return $this->saleFactory;
    }

    /**
     * Returns the invoice calculator.
     *
     * @return InvoiceCalculatorInterface
     */
    public function getInvoiceCalculator()
    {
        return $this->invoiceCalculator;
    }

    /**
     * @inheritdoc
     */
    public function buildGoodLine(Common\SaleItemInterface $item, Document\DocumentInterface $invoice)
    {
        if (!$invoice instanceof Invoice\InvoiceInterface) {
            throw new InvalidArgumentException("Expected instance of " . Invoice\InvoiceInterface::class);
        }

        // Compound with only private children
        if ($item->isCompound()) {
            $available = $expected = null;
            foreach ($item->getChildren() as $childItem) {
                if (null !== $childLine = $this->buildGoodLine($childItem, $invoice)) {
                    $saleItemQty = $childItem->getQuantity();

                    $a = $childLine->getAvailable() / $saleItemQty;
                    if (null === $available || $available > $a) {
                        $available = $a;
                    }

                    $e = $childLine->getExpected() / $saleItemQty;
                    if (null === $expected || $expected > $e) {
                        $expected = $e;
                    }
                }
            }

            if (0 < $available) {
                return $this->findOrCreateGoodLine($invoice, $item, $available, $expected);
            }

            return null;
        }

        $line = null;

        // Skip compound with only public children
        if (!($item->isCompound() && !$item->hasPrivateChildren())) {
            if (Invoice\InvoiceTypes::isInvoice($invoice)) {
                // Invoice case
                $available = $this->invoiceCalculator->calculateInvoiceableQuantity($item, $invoice);
            } else {
                // Credit case
                $available = $this->invoiceCalculator->calculateCreditableQuantity($item, $invoice);
            }

            if (0 < $available) {
                $expected = null;
                if (Invoice\InvoiceTypes::isInvoice($invoice)) {
                    $expected = min($available, $this->shipmentCalculator->calculateShippedQuantity($item));
                }

                $line = $this->findOrCreateGoodLine($invoice, $item, $available, $expected);
            }
        }

        // Build children
        if ($item->hasChildren()) {
            foreach ($item->getChildren() as $childLine) {
                $this->buildGoodLine($childLine, $invoice);
            }
        }

        return $line;
    }

    /**
     * @inheritDoc
     */
    public function buildDiscountLine(Common\SaleAdjustmentInterface $adjustment, Document\DocumentInterface $invoice)
    {
        if (!$invoice instanceof Invoice\InvoiceInterface) {
            throw new InvalidArgumentException("Expected instance of " . Invoice\InvoiceInterface::class);
        }

        $line = null;
        $expected = null;
        if (Invoice\InvoiceTypes::isInvoice($invoice)) {
            // Invoice case
            $expected = $available = $this->invoiceCalculator->calculateInvoiceableQuantity($adjustment, $invoice);
        } else {
            // Credit case
            $available = $this->invoiceCalculator->calculateCreditableQuantity($adjustment, $invoice);
        }

        if (0 < $available) {
            /** @var Invoice\InvoiceLineInterface $line */
            $line = parent::buildDiscountLine($adjustment, $invoice);
            $line
                ->setAvailable($available)
                ->setExpected($expected)
                ->setQuantity(1);
        }

        return $line;
    }

    /**
     * @inheritDoc
     */
    public function buildShipmentLine(Document\DocumentInterface $invoice)
    {
        if (!$invoice instanceof Invoice\InvoiceInterface) {
            throw new InvalidArgumentException("Expected instance of " . Invoice\InvoiceInterface::class);
        }

        $line = null;
        $expected = null;
        $sale = $invoice->getSale();
        if (Invoice\InvoiceTypes::isInvoice($invoice)) {
            // Invoice case
            $expected = $available = $this->invoiceCalculator->calculateInvoiceableQuantity($sale, $invoice);
        } else {
            // Credit case
            $available = $this->invoiceCalculator->calculateCreditableQuantity($sale, $invoice);
        }

        if (0 < $available) {
            /** @var Invoice\InvoiceLineInterface $line */
            $line = parent::buildShipmentLine($invoice);
            $line
                ->setAvailable($available)
                ->setExpected($expected)
                ->setQuantity(max(1, $available));
        }

        return $line;
    }

    /**
     * Finds or create the invoice line.
     *
     * @param Invoice\InvoiceInterface $invoice
     * @param Common\SaleItemInterface $item
     * @param float                    $available
     * @param float                    $expected
     *
     * @return Invoice\InvoiceLineInterface
     */
    public function findOrCreateGoodLine(
        Invoice\InvoiceInterface $invoice,
        Common\SaleItemInterface $item,
        $available,
        $expected = null
    ) {
        $line = null;

        if (0 >= $available) {
            return $line;
        }

        // Existing line lookup
        foreach ($invoice->getLinesByType(Document\DocumentLineTypes::TYPE_GOOD) as $invoiceLine) {
            if ($invoiceLine->getSaleItem() === $item) {
                $line = $invoiceLine;
            }
        }

        // Not found, create it
        if (null === $line) {
            $line = $this->createLine($invoice);
            $line
                ->setInvoice($invoice)
                ->setType(Document\DocumentLineTypes::TYPE_GOOD)
                ->setSaleItem($item)
                ->setDesignation($item->getDesignation())
                ->setDescription($item->getDescription())
                ->setReference($item->getReference());
        }

        // Set available and expected quantity
        $line->setAvailable($available);
        $line->setExpected($expected);

        if (Invoice\InvoiceTypes::isInvoice($invoice) && null === $invoice->getId()) {
            // Set default quantity for new non return shipment items
            $line->setQuantity(min($expected, $available));
        }

        return $line;
    }

    /**
     * @inheritdoc
     */
    protected function createLine(Document\DocumentInterface $invoice)
    {
        /** @var Invoice\InvoiceInterface $invoice */
        return $this->saleFactory->createLineForInvoice($invoice);
    }
}


<?php

namespace Ekyna\Component\Commerce\Supplier\EventListener;

use Ekyna\Component\Commerce\Exception;
use Ekyna\Component\Commerce\Supplier\Model\SupplierDeliveryInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class SupplierDeliveryListener
 * @package Ekyna\Component\Commerce\Supplier\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierDeliveryListener extends AbstractListener
{
    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $delivery = $this->getSupplierDeliveryFromEvent($event);

        if (null === $order = $delivery->getOrder()) {
            $changeSet = $this->persistenceHelper->getChangeSet($delivery);
            if (array_key_exists('order', $changeSet)) {
                $order = $changeSet['order'][0];
            }
        }
        if (null === $order) {
            throw new Exception\RuntimeException("Failed to retrieve supplier order.");
        }

        // Clear association
        $delivery->setOrder(null);

        // Trigger the supplier order update
        if (!$this->persistenceHelper->isScheduledForRemove($order)) {
            $this->scheduleSupplierOrderContentChangeEvent($order);
        }
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws Exception\IllegalOperationException
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $delivery = $this->getSupplierDeliveryFromEvent($event);

        $this->assertDeletable($delivery);

        // Initialize the supplier order's deliveries collection before the delivery removal.
        // @see http://stackoverflow.com/questions/41102378/scheduled-entity-in-onflush-is-different-instance#answer-41361138
        $delivery->getOrder()->getDeliveries()->count();
    }

    /**
     * Returns the supplier delivery item from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return SupplierDeliveryInterface
     * @throws Exception\InvalidArgumentException
     */
    protected function getSupplierDeliveryFromEvent(ResourceEventInterface $event)
    {
        $delivery = $event->getResource();

        if (!$delivery instanceof SupplierDeliveryInterface) {
            throw new Exception\InvalidArgumentException("Expected instance of SupplierDeliveryInterface.");
        }

        return $delivery;
    }
}


<?php declare(strict_types=1);

namespace Ekyna\Component\Commerce\Common\Calculator;

/**
 * Class Margin
 * @package Ekyna\Component\Commerce\Common\Calculator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Margin
{
    /**
     * @var float
     */
    private $purchaseCost;

    /**
     * @var float
     */
    private $sellingPrice;

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

    /**
     * Constructor.
     *
     * @param float $cost
     * @param float $price
     * @param bool  $average
     */
    public function __construct(float $cost = 0, float $price = 0, bool $average = false)
    {
        $this->purchaseCost = $cost;
        $this->sellingPrice = $price;
        $this->average = $average;
    }

    /**
     * Returns the purchase cost.
     *
     * @return float
     */
    public function getPurchaseCost(): float
    {
        return $this->purchaseCost;
    }

    /**
     * Adds the purchase cost.
     *
     * @param float $cost
     *
     * @return Margin
     */
    public function addPurchaseCost(float $cost): Margin
    {
        $this->purchaseCost += $cost;

        return $this;
    }

    /**
     * Returns the selling price.
     *
     * @return float
     */
    public function getSellingPrice(): float
    {
        return $this->sellingPrice;
    }

    /**
     * Adds the selling price
     *
     * @param float $price
     *
     * @return Margin
     */
    public function addSellingPrice(float $price): Margin
    {
        $this->sellingPrice += $price;

        return $this;
    }

    /**
     * Returns whether this is an average margin.
     *
     * @return bool
     */
    public function isAverage(): bool
    {
        return $this->average;
    }

    /**
     * Sets whether this is an average margin.
     *
     * @param bool $average
     *
     * @return Margin
     */
    public function setAverage(bool $average): Margin
    {
        $this->average = $average;

        return $this;
    }

    /**
     * Returns the amount.
     *
     * @return float
     */
    public function getAmount():float
    {
        return $this->sellingPrice - $this->purchaseCost;
    }

    /**
     * Returns the percentage.
     *
     * @return float
     */
    public function getPercent(): float
    {
        $amount = $this->getAmount();

        if (0 < $this->sellingPrice) {
            return round($amount * 100 / $this->sellingPrice, 2);
        }

        return 0;
    }

    /**
     * Merges the given margin.
     *
     * @param Margin $margin
     */
    public function merge(Margin $margin): void
    {
        $this->purchaseCost += $margin->getPurchaseCost();
        $this->sellingPrice += $margin->getSellingPrice();
        $this->average = $this->average || $margin->isAverage();
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Generator;

use Ekyna\Component\Commerce\Common\Model\NumberSubjectInterface;
use Ekyna\Component\Commerce\Exception\RuntimeException;

/**
 * Class DefaultNumberGenerator
 * @package Ekyna\Component\Commerce\Order\Generator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class DefaultNumberGenerator implements NumberGeneratorInterface
{
    /**
     * @var string
     */
    private $filePath;

    /**
     * @var resource
     */
    private $handle;

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

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

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

    /**
     * Constructor.
     *
     * @param string $filePath The number file path
     * @param string $prefix   The number prefix
     * @param int    $length   The total number length
     * @param bool   $debug
     */
    public function __construct($filePath, $prefix = 'ym', $length = 10, $debug = false)
    {
        $this->filePath = $filePath;
        $this->prefix = $prefix;
        $this->length = $length;
        $this->debug = $debug;
    }

    /**
     * {@inheritdoc}
     */
    public function generate(NumberSubjectInterface $subject)
    {
        if (!empty($subject->getNumber())) {
            return $this;
        }

        $number = $this->readNumber();

        $number = $this->generateNumber($number);

        $this->writeNumber($number);

        $subject->setNumber($number);

        return $this;
    }

    /**
     * Generates the number.
     *
     * @param string $number
     *
     * @return string
     */
    protected function generateNumber($number)
    {
        if (!empty($this->prefix)) {
            if (0 !== strpos($number, $this->prefix)) {
                $number = 0;
            } else {
                $number = intval(substr($number, strlen($this->prefix)));
            }
        } else {
            $number = intval($number);
        }

        if ($this->debug && 999999 > $number) {
            $number = 999999;
        }

        return $this->prefix . str_pad($number + 1, $this->length - strlen($this->prefix), '0', STR_PAD_LEFT);
    }

    /**
     * Reads the previous number.
     *
     * @return bool|string
     */
    private function readNumber()
    {
        // Open
        if (false === $this->handle = fopen($this->filePath, 'c+')) {
            throw new RuntimeException("Failed to open file {$this->filePath}.");
        }
        // Exclusive lock
        if (!flock($this->handle, LOCK_EX)) {
            throw new RuntimeException("Failed to lock file {$this->filePath}.");
        }

        return fread($this->handle, $this->length);
    }

    /**
     * Writes the new number.
     *
     * @param string $number
     */
    private function writeNumber($number)
    {
        // Truncate
        if (!ftruncate($this->handle, 0)) {
            throw new RuntimeException("Failed to truncate file {$this->filePath}.");
        }
        // Reset
        if (0 > fseek($this->handle, 0)) {
            throw new RuntimeException("Failed to move pointer at the beginning of the file {$this->filePath}.");
        }
        // Write
        if (!fwrite($this->handle, $number)) {
            throw new RuntimeException("Failed to write file {$this->filePath}.");
        }
        // Flush
        if (!fflush($this->handle)) {
            throw new RuntimeException("Failed to flush file {$this->filePath}.");
        }
        // Unlock
        if (!flock($this->handle, LOCK_UN)) {
            throw new RuntimeException("Failed to unlock file {$this->filePath}.");
        }
        // Close
        fclose($this->handle);
    }
}


<?php

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

use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Class SaleStepValidator
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Validator
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SaleStepValidator implements SaleStepValidatorInterface
{
    /**
     * @var ValidatorInterface
     */
    protected $validator;

    /**
     * @var \Symfony\Component\Validator\ConstraintViolationListInterface
     */
    protected $violationList;

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

    /**
     * @inheritdoc
     */
    public function validate(SaleInterface $sale, $step)
    {
        $this->validateStep($step);

        $this->violationList = $this->validator->validate(
            $sale,
            $this->getConstraintsForStep($step),
            $this->getGroupsForStep($step)
        );

        return 0 == $this->violationList->count();
    }

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

    /**
     * Returns the validation constraints for the given step.
     *
     * @param string $step
     *
     * @return array|\Symfony\Component\Validator\Constraint[]
     */
    protected function getConstraintsForStep($step)
    {
        $constraints = [new Valid()];

        if ($step === static::SHIPMENT_STEP) {
            $constraints[] = new Constraints\SaleShipmentStep();
        }

        if ($step === static::PAYMENT_STEP) {
            $constraints[] = new Constraints\SaleShipmentStep();
            $constraints[] = new Constraints\RelayPoint();
            $constraints[] = new Constraints\SalePaymentStep();
        }

        return $constraints;
    }

    /**
     * Returns the validation groups for the given step.
     *
     * @param string $step
     *
     * @return array
     */
    protected function getGroupsForStep($step)
    {
        $groups = ['Default'];

        if ($step === static::CHECKOUT_STEP) {
            $groups[] = 'Checkout';
            $groups[] = 'Identity';
            $groups[] = 'Availability';
        } elseif ($step === static::SHIPMENT_STEP) {
            $groups[] = 'Availability';
        }

        return $groups;
    }

    /**
     * Validates the step.
     *
     * @param string $step
     */
    protected function validateStep($step)
    {
        if (!in_array($step, [static::CHECKOUT_STEP, static::SHIPMENT_STEP, static::PAYMENT_STEP])) {
            throw new InvalidArgumentException("Unexpected step name");
        }
    }
}


<?php

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

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

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

    /**
     * @inheritDoc
     *
     * @param TicketInterface $ticket
     */
    public function normalize($ticket, $format = null, array $context = [])
    {
        if ($this->contextHasGroup(['Default', 'Ticket'], $context)) {
            $formatter = $this->getFormatter();
            $customer = $ticket->getCustomer();

            $data = [
                'id'           => $ticket->getId(),
                'number'       => $ticket->getNumber(),
                'state'        => $ticket->getState(),
                'subject'      => $ticket->getSubject(),
                // TODO customer, order, quote ?
                'created_at'   => ($date = $ticket->getCreatedAt()) ? $date->format('Y-m-d H:i:s') : null,
                'f_created_at' => ($date = $ticket->getCreatedAt()) ? $formatter->dateTime($date) : null,
                'updated_at'   => ($date = $ticket->getUpdatedAt()) ? $date->format('Y-m-d H:i:s') : null,
                'f_updated_at' => ($date = $ticket->getUpdatedAt()) ? $formatter->dateTime($date) : null,
                'customer' => [
                    'id' => $customer->getId(),
                    'first_name' => $customer->getFirstName(),
                    'last_name' => $customer->getLastName(),
                    'company' => $customer->getCompany(),
                ],
                'orders' => [],
                'quotes' => [],
            ];

            foreach ($ticket->getQuotes() as $quote) {
                $data['quotes'][] = [
                    'id'     => $quote->getId(),
                    'number' => $quote->getNumber(),
                ];
            }

            foreach ($ticket->getOrders() as $order) {
                $data['orders'][] = [
                    'id'     => $order->getId(),
                    'number' => $order->getNumber(),
                ];
            }

            if ($this->contextHasGroup('Ticket', $context)) {
                $data += ['messages' => []];

                foreach ($this->filterMessages($ticket) as $message) {
                    $data['messages'][] = $this->normalizeObject($message, $format, $context);
                }
            }

            return $data;
        }

        return parent::normalize($ticket, $format, $context);
    }

    /**
     * Filters the ticket messages.
     *
     * @param TicketInterface $ticket
     *
     * @return \Ekyna\Component\Commerce\Support\Model\TicketMessageInterface[]
     */
    protected function filterMessages(TicketInterface $ticket)
    {
        return $ticket->getMessages()->toArray();
    }

    /**
     * @inheritDoc
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        //$object = parent::denormalize($data, $class, $format, $context);

        throw new \Exception('Not yet implemented');
    }

    /**
     * @inheritDoc
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof TicketInterface;
    }

    /**
     * @inheritDoc
     */
    public function supportsDenormalization($data, $type, $format = null)
    {
        return class_exists($type) && is_subclass_of($type, TicketInterface::class);
    }
}


<?php

namespace PeskyCMF\Db;

abstract class MigrationForTableCreation extends Migration {

    public $table = '';
    public $file = __FILE__;
    public $schema = 'public';

    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up() {
        $query = $this->getSqlFromFile($this->file);
        $this->createTableInSchema($this->schema, $query, $this->table);
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down() {
        $this->dropTableFromSchema($this->schema, $this->table);
    }

    public function getSqlFromFile($file, $rollback = false) {
        return preg_replace(
            ['%"(' . $this->schema . '|public|:schema)"\."NewTable"%m'],
            ['":schema".":table"'],
            parent::getSqlFromFile($file, $rollback)
        );
    }

}


<?php

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

use Doctrine\ORM\Event\OnClearEventArgs;
use Ekyna\Component\Commerce\Customer\Model\CustomerGroupInterface;
use Ekyna\Component\Commerce\Customer\Repository\CustomerGroupRepositoryInterface;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Resource\Doctrine\ORM\TranslatableResourceRepository;

/**
 * Class CustomerGroupRepository
 * @package Ekyna\Component\Commerce\Bridge\Doctrine\ORM\Repository
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CustomerGroupRepository extends TranslatableResourceRepository implements CustomerGroupRepositoryInterface
{
    /**
     * @var CustomerGroupInterface
     */
    private $defaultGroup;

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

        if (null !== $this->defaultGroup = $this->findOneBy(['default' => true])) {
            return $this->defaultGroup;
        }

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

    /**
     * @inheritDoc
     */
    public function getIdentifiers()
    {
        $qb = $this->createQueryBuilder('g');

        $result = $qb
            ->select('g.id')
            ->orderBy('g.id')
            ->getQuery()
            ->getScalarResult();

        return array_map(function ($r) { return $r['id']; }, $result);
    }

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


<?php

namespace Ekyna\Component\Commerce\Supplier\EventListener;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Supplier\Model\SupplierDeliveryItemInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class SupplierDeliveryItemListener
 * @package Ekyna\Component\Commerce\Supplier\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierDeliveryItemListener extends AbstractListener
{
    // TODO assert that order is at least at ordered state (in delivery listener too ...)

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $item = $this->getSupplierDeliveryItemFromEvent($event);

        // Credit stock unit received quantity
        if (null === $orderItem = $item->getOrderItem()) {
            throw new RuntimeException("OrderItem must be set.");
        }

        if (null !== $stockUnit = $orderItem->getStockUnit()) {
            $stockUnit->addGeocode($item->getGeocode());

            $this->stockUnitUpdater->updateReceived($stockUnit, $item->getQuantity(), true);
        } elseif ($orderItem->hasSubjectIdentity()) {
            throw new RuntimeException("Failed to retrieve stock unit.");
        }

        // Dispatch supplier order content change event
        if (null === $order = $orderItem->getOrder()) {
            throw new RuntimeException("Order must be set.");
        }

        $this->scheduleSupplierOrderContentChangeEvent($order);
    }

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

        if (null === $orderItem = $item->getOrderItem()) {
            throw new RuntimeException("OrderItem must be set.");
        }

        if ($this->persistenceHelper->isChanged($item, 'geocode')) {
            if (null !== $stockUnit = $orderItem->getStockUnit()) {
                $gCs = $this->persistenceHelper->getChangeSet($item, 'geocode');

                $stockUnit->removeGeocode($gCs[0]);
                $stockUnit->addGeocode($gCs[1]);

                $this->persistenceHelper->persistAndRecompute($stockUnit, false);
            } elseif ($orderItem->hasSubjectIdentity()) {
                throw new RuntimeException("Failed to retrieve stock unit.");
            }
        }

        if ($this->persistenceHelper->isChanged($item, 'quantity')) {
            $this->handleQuantityChange($item);

            // Dispatch supplier order content change event
            if (null === $order = $orderItem->getOrder()) {
                throw new RuntimeException("Order must be set.");
            }
            $this->scheduleSupplierOrderContentChangeEvent($order);

            // Remove item with zero quantity without event schedule
            if (0 == $item->getQuantity()) {
                $this->persistenceHelper->remove($item, false);
            }
        }
    }

    /**
     * Delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onDelete(ResourceEventInterface $event)
    {
        $item = $this->getSupplierDeliveryItemFromEvent($event);

        $this->assertDeletable($item);

        if (null === $orderItem = $item->getOrderItem()) {
            throw new RuntimeException("OrderItem must be set.");
        }

        if (null !== $stockUnit = $orderItem->getStockUnit()) {
            $stockUnit->removeGeocode($item->getGeocode());
        } elseif ($orderItem->hasSubjectIdentity()) {
            throw new RuntimeException("Failed to retrieve stock unit.");
        }

        if ($this->persistenceHelper->isChanged($item, ['quantity'])) {
            $this->handleQuantityChange($item);
        } else {
            if (null !== $stockUnit) {
                // Debit stock unit received quantity
                $this->stockUnitUpdater->updateReceived($stockUnit, -$item->getQuantity(), true);
            }

            // Trigger the supplier order update
            // TODO get from change set
            if (null === $order = $orderItem->getOrder()) {
                throw new RuntimeException("Failed to retrieve supplier order.");
            }

            if (!$this->persistenceHelper->isScheduledForRemove($order)) {
                $this->scheduleSupplierOrderContentChangeEvent($order);
            }
        }

        // Clear association
        $item->setDelivery(null);
    }

    /**
     * Handle the quantity change.
     *
     * @param SupplierDeliveryItemInterface $item
     */
    protected function handleQuantityChange(SupplierDeliveryItemInterface $item)
    {
        $changeSet = $this->persistenceHelper->getChangeSet($item);

        // Delta quantity (difference between new and old)
        if (null === $orderItem = $item->getOrderItem()) {
            throw new RuntimeException("Failed to retrieve order item.");
        }
        if (null !== $stockUnit = $orderItem->getStockUnit()) {
            // TODO use packaging format
            if (0 != $deltaQuantity = floatval($changeSet['quantity'][1]) - floatval($changeSet['quantity'][0])) {
                // Update stock unit received quantity
                $this->stockUnitUpdater->updateReceived($stockUnit, $deltaQuantity, true);
            }
        } elseif ($orderItem->hasSubjectIdentity()) {
            throw new RuntimeException("Failed to retrieve stock unit.");
        }

        // Trigger the supplier order update
        if (null === $order = $orderItem->getOrder()) {
            throw new RuntimeException("Failed to retrieve order.");
        }
        $this->scheduleSupplierOrderContentChangeEvent($order);
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $item = $this->getSupplierDeliveryItemFromEvent($event);

        $this->assertDeletable($item);

        // Initialize the supplier deliveries's items collection before the item removal.
        if (null !== $delivery = $item->getDelivery()) {
            $delivery->getItems();
        }
    }

    /**
     * Returns the supplier delivery item from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return SupplierDeliveryItemInterface
     * @throws InvalidArgumentException
     */
    protected function getSupplierDeliveryItemFromEvent(ResourceEventInterface $event)
    {
        $item = $event->getResource();

        if (!$item instanceof SupplierDeliveryItemInterface) {
            throw new InvalidArgumentException("Expected instance of SupplierDeliveryItemInterface.");
        }

        return $item;
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\DependencyInjection;

use Ekyna\Component\Commerce\Bridge\Payum\Action;
use Ekyna\Component\Commerce\Bridge\Payum\CreditBalance as Credit;
use Ekyna\Component\Commerce\Bridge\Payum\Offline as Offline;
use Ekyna\Component\Commerce\Bridge\Payum\OutstandingBalance as Outstanding;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\ExpressionLanguage\Expression;

/**
 * Class PayumCompilerPass
 * @package Ekyna\Component\Commerce\Bridge\Symfony\DependencyInjection
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class PayumBuilderPass implements CompilerPassInterface
{
    /**
     * @inheritdoc
     */
    public function process(ContainerBuilder $container)
    {
        if (!$container->hasDefinition('payum.builder')) {
            return;
        }

        $this->registerFactories($container);
        $this->registerActions($container);
    }

    /**
     * Registers the required factories.
     *
     * @param ContainerBuilder $container
     */
    private function registerFactories(ContainerBuilder $container)
    {
        $defaultConfig = [];

        $builder = $container->getDefinition('payum.builder');

        $builder->addMethodCall('addGatewayFactoryConfig', [
            Offline\Constants::FACTORY_NAME,
            $defaultConfig,
        ]);
        $builder->addMethodCall('addGatewayFactory', [
            Offline\Constants::FACTORY_NAME,
            [Offline\OfflineGatewayFactory::class, 'build'],
        ]);

        $builder->addMethodCall('addGatewayFactoryConfig', [
            Outstanding\Constants::FACTORY_NAME,
            $defaultConfig,
        ]);
        $builder->addMethodCall('addGatewayFactory', [
            Outstanding\Constants::FACTORY_NAME,
            [Outstanding\OutstandingGatewayFactory::class, 'build'],
        ]);

        $builder->addMethodCall('addGatewayFactoryConfig', [
            Credit\Constants::FACTORY_NAME,
            $defaultConfig,
        ]);
        $builder->addMethodCall('addGatewayFactory', [
            Credit\Constants::FACTORY_NAME,
            [Credit\CreditGatewayFactory::class, 'build'],
        ]);
    }

    /**
     * Registers the payum actions.
     *
     * @param ContainerBuilder $container
     */
    private function registerActions(ContainerBuilder $container)
    {
        // Payzen convert action
        if (class_exists('Ekyna\Component\Payum\Payzen\PayzenGatewayFactory')) {
            // Convert action
            $definition = new Definition('Ekyna\Component\Commerce\Bridge\Payum\Payzen\Action\ConvertAction');
            $definition->addTag('payum.action', ['factory' => 'payzen', 'prepend' => true]);
            $container->setDefinition('ekyna_commerce.payum.action.payzen.convert_payment', $definition);

            // Fraud level action
            $definition = new Definition('Ekyna\Component\Commerce\Bridge\Payum\Payzen\Action\FraudLevelAction');
            $definition->addTag('payum.action', ['factory' => 'payzen', 'prepend' => true]);
            $container->setDefinition('ekyna_commerce.payum.action.payzen.fraud_level', $definition);
        }

        // Sips
        if (class_exists('Ekyna\Component\Payum\Sips\SipsGatewayFactory')) {
            // Convert action
            $definition = new Definition('Ekyna\Component\Commerce\Bridge\Payum\Sips\Action\ConvertAction');
            $definition->addTag('payum.action', ['factory' => 'atos_sips', 'prepend' => true]);
            $container->setDefinition('ekyna_commerce.payum.action.sips.convert_payment', $definition);
        }

        // PayPal EC NVP
        if (class_exists('Payum\Paypal\ExpressCheckout\Nvp\PaypalExpressCheckoutGatewayFactory')) {
            // Convert action
            $definition = new Definition('Ekyna\Component\Commerce\Bridge\Payum\Paypal\Action\EcNvpConvertAction');
            $definition->setArgument(0, new Reference('ekyna_commerce.common.amount_calculator'));
            if ($container->has('ekyna_setting.manager') && class_exists('Ekyna\Bundle\AdminBundle\Settings\GeneralSettingsSchema')) {
                $definition->setArgument(1, new Expression(
                    "service('ekyna_setting.manager').getParameter('general.site_name')"
                ));
            }
            $definition->addTag('payum.action', ['factory' => 'paypal_express_checkout', 'prepend' => true]);
            $container->setDefinition('ekyna_commerce.payum.action.paypal_ec_nvp.convert_payment', $definition);
        }

        // Global actions
        $actions = [
            'capture_payment' => Action\CaptureAction::class,
            'notify_payment'  => Action\NotifyAction::class,
            'status_payment'  => Action\StatusAction::class,
        ];
        foreach ($actions as $name => $class) {
            $definition = new Definition($class);
            $definition->addTag('payum.action', ['all' => true, 'prepend' => true]);

            $container->setDefinition('ekyna_commerce.payum.action.' . $name, $definition);
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Dispatcher;

use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Exception\StockLogicException;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentStates;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentSubjectInterface;
use Ekyna\Component\Commerce\Stock\Logger\StockLoggerInterface;
use Ekyna\Component\Commerce\Stock\Manager\StockUnitManagerInterface;
use Ekyna\Component\Commerce\Stock\Model\StockAssignmentInterface;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class StockAssignmentDispatcher
 * @package Ekyna\Component\Commerce\Stock\Dispatcher
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class StockAssignmentDispatcher implements StockAssignmentDispatcherInterface
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var SaleFactoryInterface
     */
    protected $saleFactory;

    /**
     * @var StockUnitManagerInterface
     */
    protected $unitManager;

    /**
     * @var StockLoggerInterface
     */
    protected $logger;

    /**
     * Constructor.
     *
     * @param PersistenceHelperInterface $persistenceHelper
     * @param SaleFactoryInterface       $saleFactory
     * @param StockUnitManagerInterface  $unitManager
     * @param StockLoggerInterface       $logger
     */
    public function __construct(
        PersistenceHelperInterface $persistenceHelper,
        SaleFactoryInterface $saleFactory,
        StockUnitManagerInterface $unitManager,
        StockLoggerInterface $logger
    ) {
        $this->persistenceHelper = $persistenceHelper;
        $this->saleFactory = $saleFactory;
        $this->unitManager = $unitManager;
        $this->logger = $logger;
    }

    /**
     * @inheritdoc
     */
    public function moveAssignments(
        StockUnitInterface $sourceUnit,
        StockUnitInterface $targetUnit,
        $quantity,
        $direction = SORT_DESC
    ) {
        if (0 >= $quantity) {
            throw new StockLogicException("Quantity must be greater than zero.");
        }

        if ($sourceUnit === $targetUnit) {
            throw new StockLogicException("Source and target units are the same.");
        }

        // Don't move more than reservable (don't create overflow on target unit)
        $quantity = min($quantity, $targetUnit->getReservableQuantity());
        if (0 >= $quantity) {
            // Abort because sold quantity would become greater than ordered
            return 0;
        }

        $moved = 0;

        /**
         * TODO Use combination to move the less assignments:
         * @see \Ekyna\Component\Commerce\Stock\Prioritizer\UnitCandidate::getCombination()
         */
        $sourceAssignments = $this->sortAssignments($sourceUnit->getStockAssignments()->toArray(), $direction);
        /** @var StockAssignmentInterface[] $targetAssignments */
        $targetAssignments = $targetUnit->getStockAssignments()->toArray();

        foreach ($sourceAssignments as $assignment) {
            /**
             * TODO Refactor with:
             * @see \Ekyna\Component\Commerce\Stock\Prioritizer\StockPrioritizer::moveAssignment()
             */

            $saleItem = $assignment->getSaleItem();

            // Don't move assignments of preparation sales.
            $sale = $saleItem->getSale();
            if (
                $sale instanceof ShipmentSubjectInterface &&
                $sale->getShipmentState() === ShipmentStates::STATE_PREPARATION
            ) {
                continue;
            }

            // Don't move shipped quantity
            $delta = min($quantity, $assignment->getSoldQuantity() - $assignment->getShippedQuantity());
            if (0 >= $delta) {
                continue;
            }

            // Add quantity to target unit
            $this->logger->unitSold($targetUnit, $delta);
            $targetUnit->setSoldQuantity($targetUnit->getSoldQuantity() + $delta);

            // Remove quantity from source unit
            $this->logger->unitSold($sourceUnit, -$delta);
            $sourceUnit->setSoldQuantity($sourceUnit->getSoldQuantity() - $delta);

            // Look for a target assignment with the same sale item
            $merge = null;
            foreach ($targetAssignments as $m) {
                if ($m->getSaleItem() === $saleItem) {
                    $merge = $m;
                    break;
                }
            }

            if ($delta == $assignment->getSoldQuantity()) {
                if (null !== $merge) {
                    // Credit quantity to mergeable assignment
                    $this->logger->assignmentSold($merge, $delta);
                    $merge->setSoldQuantity($merge->getSoldQuantity() + $delta);
                    $this->persistAssignment($merge);

                    // Debit quantity from source assignment
                    $this->logger->assignmentSold($assignment, 0, false); // TODO log removal ?
                    $assignment->setSoldQuantity(0);
                    $this->persistAssignment($assignment); // Remove
                } else {
                    // Move source assignment to target unit
                    $this->logger->assignmentUnit($assignment, $targetUnit);
                    $assignment->setStockUnit($targetUnit);
                    $this->persistAssignment($assignment);
                }
            } else {
                // Debit quantity from source assignment
                $this->logger->assignmentSold($assignment, -$delta);
                $assignment->setSoldQuantity($assignment->getSoldQuantity() - $delta);
                $this->persistAssignment($assignment);

                if (null !== $merge) {
                    // Credit quantity to mergeable assignment
                    $this->logger->assignmentSold($merge, $delta);
                    $merge->setSoldQuantity($merge->getSoldQuantity() + $delta);
                    $this->persistAssignment($merge);
                } else {
                    // Credit quantity to new assignment
                    $create = $this->saleFactory->createStockAssignmentForItem($saleItem);
                    $this->logger->assignmentSold($create, $delta, false);
                    $create
                        ->setSoldQuantity($delta)
                        ->setSaleItem($saleItem)
                        ->setStockUnit($targetUnit);

                    $this->persistAssignment($create);
                }
            }

            $moved += $delta;
            $quantity -= $delta;
            if (0 == $quantity) {
                break;
            }
        }

        $this->unitManager->persistOrRemove($targetUnit);
        $this->unitManager->persistOrRemove($sourceUnit);

        return $moved;
    }

    /**
     * Persists (or removes) the stock assignment.
     *
     * @param StockAssignmentInterface $assignment
     */
    private function persistAssignment(StockAssignmentInterface $assignment)
    {
        // Remove if empty
        if (0 == $assignment->getSoldQuantity()) {
            $assignment
                ->setSaleItem(null)
                ->setStockUnit(null);

            $this->persistenceHelper->remove($assignment, true);

            return;
        }

        // Persist without scheduling event
        $this->persistenceHelper->persistAndRecompute($assignment, true);
    }

    /**
     * Sort assignments from the most recent to the most ancient.
     *
     * @param StockAssignmentInterface[] $assignments
     * @param int                        $direction
     *
     * @return StockAssignmentInterface[]
     */
    private function sortAssignments(array $assignments, $direction = SORT_DESC)
    {
        usort($assignments, function (StockAssignmentInterface $a, StockAssignmentInterface $b) use ($direction) {
            $aDate = $a->getSaleItem()->getSale()->getCreatedAt();
            $bDate = $b->getSaleItem()->getSale()->getCreatedAt();

            if ($aDate == $bDate) {
                return 0;
            }

            if ($direction === SORT_ASC) {
                return $aDate < $bDate ? -1 : 1;
            }

            return $aDate > $bDate ? -1 : 1;
        });

        return $assignments;
    }
}


<?php

namespace Ekyna\Component\Commerce\Pricing\Model;

use Ekyna\Component\Commerce\Common\Model\AdjustmentDataInterface;
use Ekyna\Component\Commerce\Common\Model\AdjustmentModes;
use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;

/**
 * Class Price
 * @package Ekyna\Component\Commerce\Pricing\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Price
{
    /**
     * @var float
     */
    private $base;

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

    /**
     * @var string
     */
    private $mode;

    /**
     * @var AdjustmentDataInterface[]
     */
    private $discounts;

    /**
     * @var AdjustmentDataInterface[]
     */
    private $taxes;

    /**
     * Constructor.
     *
     * @param float  $amount
     * @param string $currency
     * @param string $mode
     */
    public function __construct(float $amount, string $currency, string $mode)
    {
        $this->base = $amount;
        $this->currency = $currency;
        $this->mode = $mode;

        $this->discounts = [];
        $this->taxes = [];
    }

    /**
     * Returns the amount.
     *
     * @return float
     */
    public function getBase()
    {
        return $this->base;
    }

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

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

    /**
     * Returns whether this price has taxes.
     *
     * @return bool
     */
    public function hasTaxes()
    {
        return !empty($this->taxes);
    }

    /**
     * Adds the taxation adjustment.
     *
     * @param AdjustmentDataInterface $tax
     */
    public function addTax(AdjustmentDataInterface $tax)
    {
        $this->taxes[] = $tax;
    }

    /**
     * Returns whether this price has discounts.
     *
     * @return bool
     */
    public function hasDiscounts()
    {
        return !empty($this->discounts);
    }

    /**
     * Adds the discount adjustment.
     *
     * @param AdjustmentDataInterface $discount
     */
    public function addDiscount(AdjustmentDataInterface $discount)
    {
        $this->discounts[] = $discount;
    }

    /**
     * Returns the total.
     *
     * @param bool $discounted Whether to return the discounted price.
     *
     * @return float
     */
    public function getTotal($discounted = true)
    {
        $base = $this->base;

        if ($discounted && $this->hasDiscounts()) {
            foreach ($this->discounts as $discount) {
                $base -= $this->calculateAdjustment($discount, $base);
            }
        }

        $total = $base;

        if (!empty($this->taxes) && $this->mode === VatDisplayModes::MODE_ATI) {
            foreach ($this->taxes as $tax) {
                $total += $this->calculateAdjustment($tax, $base);
            }
        }

        return $total;
    }

    /**
     * Calculates the adjustment amount.
     *
     * @param AdjustmentDataInterface $adjustment
     * @param                         $base
     *
     * @return float
     */
    private function calculateAdjustment(AdjustmentDataInterface $adjustment, $base)
    {
        if ($adjustment->getMode() === AdjustmentModes::MODE_PERCENT) {
            return Money::round($base * $adjustment->getAmount() / 100, $this->currency);
        }

        if ($adjustment->getMode() === AdjustmentModes::MODE_FLAT) {
            return $adjustment->getAmount();
        }

        throw new InvalidArgumentException("Unexpected adjustment mode.");
    }
}


<?php

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

use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Commerce\Common\Util\FormatterFactory;
use Ekyna\Component\Commerce\Supplier\Model\SupplierOrderItemInterface;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

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

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

    /**
     * @inheritdoc
     *
     * @param SupplierOrderItemInterface $item
     */
    public function normalize($item, $format = null, array $context = [])
    {
        $data = parent::normalize($item, $format, $context);

        if ($this->contextHasGroup('Summary', $context)) {
            $order = $item->getOrder();

            $received = 0;
            foreach ($order->getDeliveries() as $delivery) {
                foreach ($delivery->getItems() as $di) {
                    if ($di->getOrderItem() === $item) {
                        $received += $di->getQuantity();
                    }
                }
            }

            $formatter = $this->getFormatter();

            $data = array_replace($data, [
                'designation' => $item->getDesignation(),
                'net_price'   => $formatter->currency($item->getNetPrice(), $order->getCurrency()->getCode()),
                'ordered'     => $formatter->number($item->getQuantity()),
                'received'    => $formatter->number($received),
            ]);
        }

        return $data;
    }

    /**
     * @inheritdoc
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        //$object = parent::denormalize($data, $class, $format, $context);

        throw new \Exception('Not yet implemented');
    }

    /**
     * @inheritdoc
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof SupplierOrderItemInterface;
    }

    /**
     * @inheritdoc
     */
    public function supportsDenormalization($data, $type, $format = null)
    {
        return class_exists($type) && is_subclass_of($type, SupplierOrderItemInterface::class);
    }
}