<?php

namespace Ekyna\Component\Commerce\Stock\EventListener;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Stock\Model\StockAdjustmentInterface;
use Ekyna\Component\Commerce\Stock\Model\StockAdjustmentReasons;
use Ekyna\Component\Commerce\Stock\Updater\StockUnitUpdaterInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

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

    /**
     * @var StockUnitUpdaterInterface
     */
    protected $stockUnitUpdater;

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

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

        if (null === $stockUnit = $stockAdjustment->getStockUnit()) {
            throw new RuntimeException("Stock unit must be set at this point.");
        }

        $quantity = StockAdjustmentReasons::isDebitReason($stockAdjustment->getReason())
            ? -$stockAdjustment->getQuantity()
            : $stockAdjustment->getQuantity();

        $this->stockUnitUpdater->updateAdjusted($stockUnit, $quantity, true);
    }

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

        if (null === $stockUnit = $stockAdjustment->getStockUnit()) {
            throw new RuntimeException("Stock unit must be set at this point.");
        }

        $cs = $this->persistenceHelper->getChangeSet($stockAdjustment);

        if (isset($cs['reason']) && ($cs['reason'][0] != $cs['reason'][1])) {
            // (Default) From DEBIT to CREDIT
            if (isset($cs['quantity'])) {
                $delta = $cs['quantity'][0] + $cs['quantity'][1];
            } else {
                $delta = 2 * $stockAdjustment->getQuantity();
            }
            if (
                !StockAdjustmentReasons::isDebitReason($cs['reason'][0])
                && StockAdjustmentReasons::isDebitReason($cs['reason'][1])
            ) {
                // From CREDIT to DEBIT
                $delta = - $delta;
            }
        } elseif (isset($cs['quantity'])) {
            // OLD quantity - NEW quantity
            $delta = $cs['quantity'][1] - $cs['quantity'][0];

            // Opposite delta if debit
            if (StockAdjustmentReasons::isDebitReason($stockAdjustment->getReason())) {
                $delta = -$delta;
            }
        } else {
            return;
        }

        $this->stockUnitUpdater->updateAdjusted($stockUnit, $delta, true);
    }

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

        if (null === $stockUnit = $stockAdjustment->getStockUnit()) {
            throw new RuntimeException("Stock unit must be set at this point.");
        }

        $quantity = StockAdjustmentReasons::isDebitReason($stockAdjustment->getReason())
            ? $stockAdjustment->getQuantity()
            : -$stockAdjustment->getQuantity();

        $this->stockUnitUpdater->updateAdjusted($stockUnit, $quantity, true);
    }

    /**
     * Returns the stock adjustment from the resource event.
     *
     * @param ResourceEventInterface $event
     *
     * @return StockAdjustmentInterface
     */
    protected function getStockAdjustmentFromEvent(ResourceEventInterface $event)
    {
        $stockAdjustment = $event->getResource();

        if (!$stockAdjustment instanceof StockAdjustmentInterface) {
            throw new InvalidArgumentException("Expected instance of " . StockAdjustmentInterface::class);
        }

        return $stockAdjustment;
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\View;

/**
 * Class AbstractView
 * @package Ekyna\Component\Commerce\Common\View
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractView
{
    /**
     * @var array
     */
    public $vars = [
        'attr' => [],
    ];

    /**
     * Adds the css classes to the view.
     *
     * @param string $class
     *
     * @return $this
     */
    public function addClass($class)
    {
        $classes = $this->getClasses();

        if (!in_array($class, $classes)) {
            $classes[] = $class;
        }

        $this->setClasses($classes);

        return $this;
    }

    /**
     * Removes the css classes from the view.
     *
     * @param string $class
     *
     * @return $this
     */
    public function removeClass($class)
    {
        $classes = $this->getClasses();

        if (false !== $index = array_search($class, $classes)) {
            unset($classes[$index]);
        }

        $this->setClasses($classes);

        return $this;
    }

    /**
     * Returns the css classes.
     *
     * @return array
     */
    private function getClasses()
    {
        if (isset($this->vars['attr']['class'])) {
            return explode(' ', trim($this->vars['attr']['class']));
        }

        return [];
    }

    /**
     * Sets the css classes.
     *
     * @param array $classes
     */
    private function setClasses(array $classes)
    {
        if (!empty($classes)) {
            $this->vars['attr']['class'] = ' ' . trim(implode(' ', $classes));
        } else {
            unset($this->vars['attr']['class']);
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Customer\Updater;

use Ekyna\Component\Commerce\Customer\Model\CustomerInterface;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Payment\Model\PaymentInterface;
use Ekyna\Component\Commerce\Payment\Model\PaymentStates;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class CustomerUpdater
 * @package Ekyna\Component\Commerce\Customer\Updater
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CustomerUpdater implements CustomerUpdaterInterface
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

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

    /**
     * @inheritdoc
     */
    public function handlePaymentInsert(PaymentInterface $payment)
    {
        if ($this->supports($payment) && $this->isAcceptedPayment($payment)) {
            return $this->updateCustomerBalance($payment);
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function handlePaymentUpdate(PaymentInterface $payment)
    {
        if (!$this->supports($payment)) {
            return false;
        }

        $stateCs = $this->persistenceHelper->getChangeSet($payment, 'state');
        $amountCs = $this->persistenceHelper->getChangeSet($payment, 'amount');

        $acceptedStates = $this->getAcceptedStates($payment);

        // By state change
        if (!empty($stateCs) && $stateCs[0] !== $stateCs[1]) {
            $fromAccepted = in_array($stateCs[0], $acceptedStates, true);
            $toAccepted = in_array($stateCs[1], $acceptedStates, true);

            // If payment state has changed from or to a accepted state
            if ($fromAccepted xor $toAccepted) {
                // Update the customer balance
                return $this->updateCustomerBalance($payment, isset($amountCs[0]) ? $amountCs[0] : null);
            }
        }

        // By Amount change
        if (!empty($amountCs)) {
            $amountDelta = $amountCs[1] - $amountCs[0]; // New - Old
            // If amount changed and payment is accepted
            if (0 != $amountDelta && in_array($payment->getState(), $acceptedStates, true)) {
                return $this->updateCustomerBalance($payment, $amountDelta);
            }
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function handlePaymentDelete(PaymentInterface $payment)
    {
        if ($this->supports($payment) && $this->isAcceptedPayment($payment)) {
            $payment->setState(PaymentStates::STATE_CANCELED);

            return $this->updateCustomerBalance($payment);
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function updateCreditBalance(CustomerInterface $customer, $amount, $relative = false)
    {
        // Switch to parent if available
        if ($customer->hasParent()) {
            $customer = $customer->getParent();
        }

        $old = $customer->getCreditBalance();
        $new = $relative ? $old + $amount : $amount;

        if ($old != $new) {
            $customer->setCreditBalance($new);
            $this->persistenceHelper->persistAndRecompute($customer, false);

            return true;
        }

        return false;
    }

    /**
     * @inheritdoc
     */
    public function updateOutstandingBalance(CustomerInterface $customer, $amount, $relative = false)
    {
        // Switch to parent if available
        if ($customer->hasParent()) {
            $customer = $customer->getParent();
        }

        $old = $customer->getOutstandingBalance();
        $new = $relative ? $old + $amount : $amount;

        if ($old != $new) {
            $customer->setOutstandingBalance($new);
            $this->persistenceHelper->persistAndRecompute($customer, false);

            return true;
        }

        return false;
    }

    /**
     * Updates the customer's balance regarding to the payment method.
     *
     * @param PaymentInterface $payment
     * @param float            $amount The previous amount (update case)
     *
     * @return bool Whether the customer has been changed.
     */
    protected function updateCustomerBalance(PaymentInterface $payment, $amount = null)
    {
        if (null === $customer = $payment->getSale()->getCustomer()) {
            // TODO Deals with customer change
            return false;
        }

        $amount = $amount ?: $payment->getAmount();
        if ($this->isAcceptedPayment($payment)) {
            $amount = -$amount;
        }

        if ($payment->getMethod()->isCredit()) {
            return $this->updateCreditBalance($customer, $amount, true);
        } elseif ($payment->getMethod()->isOutstanding()) {
            return $this->updateOutstandingBalance($customer, $amount, true);
        }

        return false;
    }

    /**
     * Returns whether the credit/outstanding payment is accepted.
     *
     * @param PaymentInterface $payment
     *
     * @return bool
     */
    protected function isAcceptedPayment(PaymentInterface $payment)
    {
        return in_array($payment->getState(), $this->getAcceptedStates($payment), true);
    }

    /**
     * Returns the payment accepted states.
     *
     * @param PaymentInterface $payment
     *
     * @return array
     */
    protected function getAcceptedStates(PaymentInterface $payment)
    {
        $acceptedStates = PaymentStates::getPaidStates();

        if ($payment->getMethod()->isOutstanding()) {
            $acceptedStates[] = PaymentStates::STATE_EXPIRED;
        }

        return $acceptedStates;
    }

    /**
     * Returns whether or not the payment is supported.
     *
     * @param PaymentInterface $payment
     *
     * @return bool
     */
    protected function supports(PaymentInterface $payment)
    {
        if (null === $method = $payment->getMethod()) {
            throw new RuntimeException("Payment method must be set.");
        }

        if ($method->isCredit() || $method->isOutstanding()) {
            return true;
        }

        return false;
    }
}


<?php
namespace AuthActions\Lib;

use Cake\Core\Configure;
use Cake\Event\EventManager;

trait AuthActionsTrait {

/**
 * @var AuthActions
 */
	protected $_AuthActions;

/**
 * @var UserRights
 */
	protected $_UserRights;

/**
 * Initializer, must be called in beforeFilter()
 *
 * @return void
 */
	public function initAuthActions() {
		EventManager::instance()->attach(function (\Cake\Event\Event $event) {
			// Make the AuthComponent, AuthActions and UserRights available to the view.
			// FIXME - find a clean way to do this
			if (!$event->subject() instanceof \Cake\Controller\ErrorController) {
				$viewAuthActions = [
					'AuthActions' => $event->subject()->getAuthActions(),
					'UserRights' => $event->subject()->getUserRights()
				];
				$event->subject()->set('viewAuthActions', $viewAuthActions);
			}
		}, 'Controller.beforeRender');

		if ($this->getAuthActions()->isPublicAction($this->request->params['plugin'], $this->request->params['controller'], $this->request->params['action'])) {
			$this->Auth->allow();
		}
	}

/**
 * Instance getter for the AuthActions instance
 *
 * @return AuthActions
 */
	public function getAuthActions() {
		if (!$this->_AuthActions) {
			if (Configure::load('auth_actions') === false) {
				trigger_error('AuthActions: Could not load config/auth_actions.php', E_USER_WARNING);
			}

			$actionConfig = Configure::read('auth_actions');
			$publicActionsConfig = Configure::read('public_actions');
			$options = Configure::read('auth_settings');
			if (!is_array($options)) {
				$options = [];
			}
			if (!is_array($publicActionsConfig)) {
				$publicActionsConfig = [];
			}

			$this->_AuthActions = new AuthActions($actionConfig, $publicActionsConfig, $options);
		}
		return $this->_AuthActions;
	}

/**
 * Instance getter for the UserRights instance
 *
 * @return UserRights
 */
	public function getUserRights() {
		if (!$this->_UserRights) {
			if (Configure::load('user_rights') === false) {
				trigger_error('UserRights: Could not load config/user_rights.php', E_USER_WARNING);
			}

			$rightsConfig = Configure::read('user_rights');
			if (!is_array($rightsConfig)) {
				$rightsConfig = [];
			}

			$this->_UserRights = new UserRights($rightsConfig);
		}
		return $this->_UserRights;
	}

/**
 * See AuthActions::isAuthorized()
 *
 * @param array $user User to check
 * @return bool
 */
	public function isAuthorized($user) {
		return $this->getAuthActions()->isAuthorized(
			$this->Auth->user(),
			$this->request->params['plugin'],
			$this->request->params['controller'],
			$this->request->params['action']
		);
	}

/**
 * See UserRights::userHasRight()
 *
 * @param string $right right name
 * @return bool
 */
	public function hasRight($right) {
		if ($this->Auth->user() !== null) {
			return $this->getUserRights()->userHasRight($this->Auth->user(), $right);
		}
		return false;
	}
}


<?php

namespace PeskyCMF\Http\Middleware;

use Illuminate\Http\Request;
use PeskyCMF\Db\HttpRequestLogs\CmfHttpRequestLogsTable;
use PeskyCMF\HttpCode;
use PeskyCMF\Scaffold\ScaffoldLoggerInterface;
use Symfony\Component\HttpFoundation\Response;

class LogHttpRequest {

    /**
     * Middleware examples:
     * 1. Use default auth guard and all HTTP methods: \PeskyCMF\Http\Middleware\LogHttpRequest::class
     * 2. Use custom auth guard and all HTTP methods: \PeskyCMF\Http\Middleware\LogHttpRequest::class . ':api'
     * 3. Use custom auth guard and custom HTTP methods list: \PeskyCMF\Http\Middleware\LogHttpRequest::class . ':api,post,put,delete'
     * 4. Use default auth guard and custom HTTP methods list: \PeskyCMF\Http\Middleware\LogHttpRequest::class . ':,post,put,delete'
     *
     * If you use $enableByDefault = false then to activate logging for a route - add 'log' action to a route.
     * 'log' must be a string and will be recorded to DB in order to group requests by short name like 'user.me'.
     * Route example: Route::get('/user/me', ['log' => 'user.me'])
     *
     * If you use $enableByDefault = true then in order to disable logging you need to set 'log' action to false:
     * Route example: Route::get('/user/me', ['log' => false])
     *
     * Note: if there was a server error during any request - it will be forcefully logged ignoring any restrictions.
     * @param Request $request
     * @param \Closure $next
     * @param null|string $authGuard
     * @param bool $enableByDefault - true: logs requests even if route has no 'log' action in its description
     * @param array $methods - list of HTTP methods to log. If empty - log all methods
     * @return mixed
     */
    public function handle($request, \Closure $next, $authGuard = null, $enableByDefault = true, ...$methods) {
        $isAllowed = empty($methods) || preg_match('%' . implode('|', $methods) . '%i', $request->getMethod());
        // reset logs to allow requests via test cases
        CmfHttpRequestLogsTable::resetCurrentLog();
        app()->offsetUnset(ScaffoldLoggerInterface::class);
        if ($isAllowed) {
            try {
                $log = CmfHttpRequestLogsTable::logRequest($request, (bool)$enableByDefault);
                app()->instance(ScaffoldLoggerInterface::class, $log);
            } catch (\Throwable $exception) {
                \Log::error($exception);
            }
        }
        $response = $next($request);
        if ($response instanceof Response) {
            if (!$isAllowed && $response->getStatusCode() >= 400) {
                // now we can wrap into if ($isAllowed) {} and will not lose server errors logging
                $isAllowed = true;
            }
            if ($isAllowed) {
                try {
                    if ($response->getStatusCode() === HttpCode::UNAUTHORISED) {
                        $user = null;
                    } else {
                        $user = \Auth::guard($authGuard ?: null)->user();
                    }
                    CmfHttpRequestLogsTable::logResponse($request, $response, $user);
                } catch (\Throwable $exception) {
                    \Log::error($exception);
                }
            }
        } else {
            \Log::error('LogHttpRequest: cannot log this response (not a Symfony response)', ['response' => $response]);
        }
        return $response;
    }

}


<?php

namespace PeskyCMF\Db;

abstract class MigrationByQuery extends Migration {

    public $tables = [''];
    public $file = __FILE__;
    public $upIntro = '';
    public $downIntro = '';
    public $ignoreErrors = false;
    public $schema = 'public';

    /**
     * Run the migrations.
     *
     * @throws \Exception
     * @throws \PeskyORM\Exception\DbException
     */
    public function up() {
        $this->out($this->upIntro);
        $query = $this->getSqlFromFile($this->file);
        try {
            $this->executeQueryOnSchema($this->schema, $query, $this->tables, $this->getUpTestQuery());
        } catch (\Exception $exc) {
            if ($this->ignoreErrors) {
                $this->out('Exception: ' . $exc->getMessage());
            } else {
                throw $exc;
            }
        }
    }

    /**
     * Reverse the migrations.
     *
     * @throws \Exception
     * @throws \PeskyORM\Exception\DbException
     */
    public function down() {
        $this->out($this->downIntro);
        $query = $this->getSqlFromFile($this->file, true);
        try {
            $this->executeQueryOnSchema($this->schema, $query, $this->tables, $this->getDownTestQuery());
        } catch (\Exception $exc) {
            if ($this->ignoreErrors) {
                $this->out('Exception: ' . $exc->getMessage());
            } else {
                throw $exc;
            }
        }
    }

    public function getUpTestQuery() {
        return null;
    }

    public function getDownTestQuery() {
        return null;
    }

}


<?php

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

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

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

    /**
     * @inheritdoc
     *
     * @param StockAdjustmentInterface $adjustment
     */
    public function normalize($adjustment, $format = null, array $context = [])
    {
        $data = [];

        if ($this->contextHasGroup('StockView', $context)) {
            $data = array_replace($data, [
                'quantity' => $this->getFormatter()->number($adjustment->getQuantity()),
                'reason'   => $adjustment->getReason(),
                'note'     => $adjustment->getNote(),
            ]);
        }

        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 StockAdjustmentInterface;
    }

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


<?php

namespace Ekyna\Component\Commerce\Accounting\EventListener;

use Ekyna\Component\Commerce\Accounting\Model\AccountingInterface;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Resource\Event\ResourceEventInterface;

/**
 * Class AccountingListener
 * @package Ekyna\Component\Commerce\Accounting\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AccountingListener
{
    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $accounting = $this->getAccountingFromEvent($event);

        $this->buildName($accounting);
    }

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

        $this->buildName($accounting);
    }

    /**
     * Returns the accounting from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return AccountingInterface
     * @throws InvalidArgumentException
     */
    protected function getAccountingFromEvent(ResourceEventInterface $event)
    {
        $resource = $event->getResource();

        if (!$resource instanceof AccountingInterface) {
            throw new InvalidArgumentException('Expected instance of ' . AccountingInterface::class);
        }

        return $resource;
    }

    /**
     * Builds the accounting name.
     *
     * @param AccountingInterface $accounting
     */
    abstract protected function buildName(AccountingInterface $accounting);
}


<?php

namespace Ekyna\Component\Commerce\Common\Model;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;

/**
 * Class Unit
 * @package Ekyna\Component\Commerce\Common\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class Units
{
    const PIECE       = 'piece';

    // Length
    const METER       = 'meter';
    const CENTIMETER  = 'centimeter';
    const MILLIMETER  = 'millimeter';
    const INCH        = 'inch';
    const FOOT        = 'foot';

    // Weight
    const KILOGRAM    = 'kilogram';
    const GRAM        = 'gram';

    // Volume
    const CUBIC_METER = 'cubic_meter';
    const LITER       = 'liter';
    const MILLILITER  = 'milliliter';

    // Duration
    const DAY         = 'day';
    const HOUR        = 'hour';
    const MINUTE      = 'minute';
    const SECOND      = 'second';

    /**
     * Returns all the units.
     *
     * @return string[]
     */
    static function getUnits()
    {
        return [
            static::PIECE,
            // Length
            static::METER,
            static::CENTIMETER,
            static::MILLIMETER,
            static::INCH,
            static::FOOT,
            // Weight
            static::KILOGRAM,
            static::GRAM,
            // Volume
            static::CUBIC_METER,
            static::LITER,
            static::MILLILITER,
            // Duration
            static::DAY,
            static::HOUR,
            static::MINUTE,
            static::SECOND,
        ];
    }

    /**
     * Returns whether the given unit is valid.
     *
     * @param string $unit
     * @param bool   $throw
     *
     * @return bool
     */
    static function isValid($unit, $throw = false)
    {
        if (in_array($unit, static::getUnits(), true)) {
            return true;
        }

        if ($throw) {
            throw new InvalidArgumentException("Invalid unit '$unit'.");
        }

        return false;
    }

    /**
     * Returns the symbol for the given unit.
     *
     * @param string $unit
     *
     * @return int
     */
    static function getSymbol($unit)
    {
        switch ($unit) {
            case static::PIECE:
                return 'pcs';

            // Length
            case static::METER:
                return 'm';
            case static::CENTIMETER:
                return 'cm';
            case static::MILLIMETER:
                return 'mm';
            case static::INCH:
                return 'in';
            case static::FOOT:
                return 'ft';

            // Weight
            case static::KILOGRAM:
                return 'kg';
            case static::GRAM:
                return 'g';

            // Volume
            case static::CUBIC_METER:
                return 'm³';
            case static::LITER:
                return 'L';
            case static::MILLILITER:
                return 'mL';

            // Duration
            case static::DAY:
                return 'days';
            case static::HOUR:
                return 'hours';
            case static::MINUTE:
                return 'minutes';
            case static::SECOND:
                return 's';

            default:
                throw new InvalidArgumentException("Invalid unit '$unit'.");
        }
    }

    /**
     * Returns the rounding precision for the given unit.
     *
     * @param string $unit
     *
     * @return int
     */
    static function getPrecision($unit)
    {
        switch ($unit) {
            case static::PIECE:
            case static::MILLIMETER:
            case static::GRAM:
            case static::MILLILITER:
            case static::DAY:
            case static::MINUTE:
            case static::SECOND:
                return 0;
            case static::CENTIMETER:
                return 1;
            case static::INCH:
            case static::FOOT:
            case static::HOUR:
                return 2;
            case static::METER:
            case static::KILOGRAM:
            case static::CUBIC_METER:
            case static::LITER:
                return 3;
            default:
                throw new InvalidArgumentException("Invalid unit '$unit'.");
        }
    }

    /**
     * Rounds the given value for the given unit.
     *
     * @param float  $value
     * @param string $unit
     *
     * @return float|int
     */
    static function round($value, $unit = 'piece')
    {
        if (0 < $precision = static::getPrecision($unit)) {
            $divider = pow(10, $precision);

            return round(floor($value * $divider) / $divider, $precision);
        }

        return floor($value);
    }

    /**
     * Disabled constructor.
     *
     * @codeCoverageIgnore
     */
    final private function __construct()
    {
    }
}


<?php

namespace Ekyna\Component\Commerce\Support\EventListener;

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

/**
 * Class TicketAttachmentEventListener
 * @package Ekyna\Component\Commerce\Support\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class TicketAttachmentEventListener
{
    /**
     * @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)
    {
        $attachment = $this->getAttachmentFromEvent($event);

        $this->updateMessage($attachment->getMessage());
    }

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

        $this->updateMessage($attachment->getMessage());
    }

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

        if (null === $message = $attachment->getMessage()) {
            $message = $this->persistenceHelper->getChangeSet($attachment, 'message')[0];
        }

        $this->updateMessage($message);
    }

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

        $this->persistenceHelper->persistAndRecompute($message, true);
    }

    /**
     * Returns the attachment from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return TicketAttachmentInterface
     */
    protected function getAttachmentFromEvent(ResourceEventInterface $event)
    {
        $attachment = $event->getResource();

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

        return $attachment;
    }

}


<?php

namespace Ekyna\Component\Commerce\Stock\Model;

/**
 * Class StockUnitStates
 * @package Ekyna\Component\Commerce\Stock\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class StockUnitStates
{
    const STATE_NEW     = 'new';
    const STATE_PENDING = 'pending';
    const STATE_READY   = 'ready';
    const STATE_CLOSED  = 'closed';

    /**
     * Returns all the states.
     *
     * @return array
     */
    static public function getStates()
    {
        return [
            static::STATE_NEW,
            static::STATE_PENDING,
            static::STATE_READY,
            static::STATE_CLOSED,
        ];
    }

    /**
     * Returns whether the given state is valid or not.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isValidState($state)
    {
        return in_array($state, static::getStates(), true);
    }

    /**
     * Returns whether the state A has a better availability than the state B.
     *
     * @param string $stateA
     * @param string $stateB
     *
     * @return bool
     */
    static public function isBetterAvailability($stateA, $stateB)
    {
        if ($stateA === $stateB) {
            return false;
        }

        switch ($stateA) {
            // 'pending' is better than 'new' or 'closed'
            case static::STATE_PENDING:
                return in_array($stateB, [static::STATE_NEW, static::STATE_CLOSED], true);
            // 'ready' is better than 'new', 'pending' or 'closed'
            case static::STATE_READY:
                return in_array($stateB, [static::STATE_NEW, static::STATE_PENDING, static::STATE_CLOSED], true);
        }

        return false;
    }

    /**
     * Disabled constructor.
     *
     * @codeCoverageIgnore
     */
    final private function __construct()
    {
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Payum\Paypal\Action;

use Ekyna\Component\Commerce\Common\Calculator\AmountCalculatorInterface;
use Ekyna\Component\Commerce\Common\Model;
use Ekyna\Component\Commerce\Common\Util\Money;
use Ekyna\Component\Commerce\Payment\Model\PaymentInterface;
use Payum\Core\Action\ActionInterface;
use Payum\Core\Bridge\Spl\ArrayObject;
use Payum\Core\Exception\RequestNotSupportedException;
use Payum\Core\GatewayAwareInterface;
use Payum\Core\GatewayAwareTrait;
use Payum\Core\Request\Convert;

/**
 * Class EcNvpConvertAction
 * @package Ekyna\Component\Commerce\Bridge\Payum\Paypal\Action
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class EcNvpConvertAction implements ActionInterface, GatewayAwareInterface
{
    use GatewayAwareTrait;

    /**
     * @var AmountCalculatorInterface
     */
    private $calculator;

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

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

    /**
     * @var int
     */
    private $line;

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

    /**
     * @inheritDoc
     */
    public function execute($request)
    {
        RequestNotSupportedException::assertSupports($this, $request);

        /** @var PaymentInterface $payment */
        $payment = $request->getSource();

        $this->currency = $payment->getCurrency()->getCode();

        $details = (array)ArrayObject::ensureArrayObject($payment->getDetails());

        $details['NOSHIPPING'] = 1;

        if (!empty($this->brandName)) {
            $details['BRANDNAME'] = $this->brandName;
        }

        $details['PAYMENTREQUEST_0_INVNUM'] = $payment->getNumber();
        $details['PAYMENTREQUEST_0_CURRENCYCODE'] = $this->currency;
        $details['PAYMENTREQUEST_0_AMT'] = $this->format($payment->getAmount());

        $this->addSaleDetails($details, $payment->getSale());

        $request->setResult($details);
    }

    /**
     * Adds the sale details.
     *
     * @param array               $details
     * @param Model\SaleInterface $sale
     */
    private function addSaleDetails(array &$details, Model\SaleInterface $sale)
    {
        if ($sale->getCurrency()->getCode() !== $this->currency) {
            return;
        }

        if (0 !== Money::compare($sale->getGrandTotal(), $details['PAYMENTREQUEST_0_AMT'], $this->currency)) {
            return;
        }

        $this->calculator->calculateSale($sale);

        $this->line = 0;
        $lineTotals = 0;

        // Items
        foreach ($sale->getItems() as $item) {
            $lineTotals += $this->addItemDetails($details, $item);
        }

        // Discounts
        foreach ($sale->getAdjustments(Model\AdjustmentTypes::TYPE_DISCOUNT) as $discount) {
            $lineTotals += $this->addDiscountDetails($details, $discount);
        }

        // Lines total
        $details['PAYMENTREQUEST_0_ITEMAMT'] = $this->format($lineTotals);

        // Shipping
        $details['PAYMENTREQUEST_0_SHIPPINGAMT'] = $this->format($sale->getShipmentResult()->getTotal());

        // Taxes
        //$details['PAYMENTREQUEST_0_TAXAMT'] = $this->format($sale->getFinalResult()->getTax());
    }

    /**
     * Builds the item details.
     *
     * @param array                   $details
     * @param Model\SaleItemInterface $item
     *
     * @return float
     */
    private function addItemDetails(array &$details, Model\SaleItemInterface $item)
    {
        $total = 0;

        if (!($item->isCompound() && !$item->hasPrivateChildren())) {
            $itemResult = $item->getResult();

            $details['L_PAYMENTREQUEST_0_NAME' . $this->line] = $item->getTotalQuantity() . 'x ' . $item->getDesignation();
            $details['L_PAYMENTREQUEST_0_NUMBER' . $this->line] = $item->getReference();
            if (!empty($description = $item->getDescription())) {
                $details['L_PAYMENTREQUEST_0_DESC' . $this->line] = $description;
            }
            $details['L_PAYMENTREQUEST_0_AMT' . $this->line] = $this->format($itemResult->getTotal());
            //$details['L_PAYMENTREQUEST_0_TAXAMT' . $this->line] = $this->format($itemResult->getTax());
            //$details['L_PAYMENTREQUEST_0_ITEMURL' . $this->itemNum] = '';

            $total = $itemResult->getTotal();

            $this->line++;
        }

        foreach ($item->getChildren() as $child) {
            $total += $this->addItemDetails($details, $child);
        }

        return $total;
    }

    /**
     * Adds the discount details.
     *
     * @param array                         $details
     * @param Model\SaleAdjustmentInterface $discount
     *
     * @return float
     */
    private function addDiscountDetails(array &$details, Model\SaleAdjustmentInterface $discount)
    {
        $discountResult = $discount->getResult();

        $details['L_PAYMENTREQUEST_0_NAME' . $this->line] = $discount->getDesignation();
        $details['L_PAYMENTREQUEST_0_AMT' . $this->line] = '-' . $this->format($discountResult->getTotal());
        //$details['L_PAYMENTREQUEST_0_TAXAMT' . $this->line] = '-' . $this->format($discountResult->getTax());

        $this->line++;

        return -$discountResult->getTotal();
    }

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

    /**
     * {@inheritDoc}
     */
    public function supports($request)
    {
        return $request instanceof Convert
            && $request->getSource() instanceof PaymentInterface
            && $request->getTo() == 'array';
    }
}


<?php

namespace Ekyna\Component\Commerce\Document\Util;

use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Document\Model\DocumentTypes;

/**
 * Class SaleDocumentUtil
 * @package Ekyna\Component\Commerce\Document\Util
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class SaleDocumentUtil
{
    /**
     * Returns the types of the sale editable documents.
     *
     * @param SaleInterface $sale
     *
     * @return array
     */
    static public function getSaleEditableDocumentTypes(SaleInterface $sale)
    {
        $types = [];

        foreach (DocumentTypes::getTypes() as $type) {
            if (!static::isSaleSupportsDocumentType($sale, $type)) {
                continue;
            }

            foreach ($sale->getAttachments() as $attachment) {
                if ($attachment->getType() === $type) {
                    continue 2;
                }
            }

            $types[] = $type;
        }

        return $types;
    }

    /**
     * Returns whether the sale supports the given document type.
     *
     * @param SaleInterface $sale
     * @param string        $type
     *
     * @return bool
     */
    static public function isSaleSupportsDocumentType(SaleInterface $sale, $type)
    {
        if (!DocumentTypes::isValidType($type)) {
            return false;
        }

        if (empty($classes = DocumentTypes::getClasses($type))) {
            return false;
        }

        foreach ($classes as $class) {
            if (is_subclass_of($sale, $class)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Disabled constructor.
     *
     * @codeCoverageIgnore
     */
    final private function __construct()
    {
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Model;

use Ekyna\Bundle\AdminBundle\Model\UserInterface;

/**
 * Class Recipient
 * @package Ekyna\Component\Commerce\Common\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class Recipient
{
    const TYPE_WEBSITE       = 'website';
    const TYPE_USER          = 'user';
    const TYPE_ADMINISTRATOR = 'administrator';
    const TYPE_IN_CHARGE     = 'in_charge';
    const TYPE_CUSTOMER      = 'customer';
    const TYPE_SALESMAN      = 'salesman';
    const TYPE_ACCOUNTABLE   = 'accountable';
    const TYPE_SUPPLIER      = 'supplier';

    /**
     * Returns the valid types.
     *
     * @return array
     */
    public static function getTypes()
    {
        return [
            self::TYPE_WEBSITE,
            self::TYPE_USER,
            self::TYPE_ADMINISTRATOR,
            self::TYPE_IN_CHARGE,
            self::TYPE_CUSTOMER,
            self::TYPE_SALESMAN,
            self::TYPE_ACCOUNTABLE,
            self::TYPE_SUPPLIER,
        ];
    }

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

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

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

    /**
     * @var UserInterface
     */
    private $user;

    /**
     * Constructor.
     *
     * @param string        $email
     * @param string        $name
     * @param string        $type
     * @param UserInterface $user
     */
    public function __construct($email = null, $name = null, $type = null, UserInterface $user = null)
    {
        $this->email = $email;
        $this->name = $name;
        $this->type = $type;
        $this->user = $user;
    }

//    /**
//     * Returns the string representation.
//     *
//     * @return string
//     */
//    public function __toString()
//    {
//        return $this->getChoiceLabel();
//    }
//
//    /**
//     * Returns the choice label.
//     *
//     * @return string
//     */
//    public function getChoiceLabel()
//    {
//        $label = '';
//
//        if (!empty($this->type)) {
//            $label = '[' . $this->type . '] ';
//        }
//
//        if (!empty($this->name)) {
//            $label .= sprintf('%s <%s>', $this->name, $this->email);
//        } else {
//            $label .= $this->email;
//        }
//
//        return $label;
//    }

    /**
     * Sets the email.
     *
     * @param string $email
     *
     * @return Recipient
     */
    public function setEmail($email)
    {
        $this->email = $email;

        return $this;
    }

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

    /**
     * Sets the name.
     *
     * @param string $name
     *
     * @return Recipient
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

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

    /**
     * Sets the type.
     *
     * @param string $type
     *
     * @return Recipient
     */
    public function setType($type)
    {
        $this->type = $type;

        return $this;
    }

    /**
     * Returns the role.
     *
     * @return string
     */
    public function getType()
    {
        return $this->type;
    }

    /**
     * Returns the user.
     *
     * @return UserInterface
     */
    public function getUser()
    {
        return $this->user;
    }

    /**
     * Sets the user.
     *
     * @param UserInterface $user
     *
     * @return Recipient
     */
    public function setUser($user)
    {
        $this->user = $user;

        return $this;
    }
}


<?php

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

use Ekyna\Component\Commerce\Common\Event\SaleTransformEvent;
use Ekyna\Component\Commerce\Common\Event\SaleTransformEvents;
use Ekyna\Component\Commerce\Order\Model\OrderInterface;
use Ekyna\Component\Commerce\Order\Model\OrderStates;
use Ekyna\Component\Resource\Event\ResourceMessage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Class SaleTransformSubscriber
 * @package Ekyna\Component\Commerce\Bridge\Symfony\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SaleTransformSubscriber implements EventSubscriberInterface
{
    /**
     * Pre copy event handler.
     *
     * @param SaleTransformEvent $event
     */
    public function onPreCopy(SaleTransformEvent $event)
    {
        $source = $event->getSource();

        if ($source instanceof OrderInterface) {
            // Prevent if order is not 'new'
            if ($source->getState() !== OrderStates::STATE_NEW) {
                $event->addMessage(new ResourceMessage(
                    'ekyna_commerce.sale.message.transform_prevented',
                    ResourceMessage::TYPE_ERROR
                ));
            }
        }
    }

    /**
     * Post copy event handler.
     *
     * @param SaleTransformEvent $event
     */
    public function onPostCopy(SaleTransformEvent $event)
    {
        $source = $event->getSource();
        $target = $event->getTarget();

        // Origin number
        $target->setOriginNumber($source->getNumber());

        // Sample
        if ($source instanceof OrderInterface && $target instanceof OrderInterface) {
            $target->setSample($source->isSample());
        }

        // Abort if source sale has no customer
        if (null === $customer = $source->getCustomer()) {
            return;
        }

        // If target sale is order and source customer has parent
        if ($target instanceof OrderInterface && $customer->hasParent()) {
            // TODO Duplicate code
            /** @see \Ekyna\Component\Commerce\Order\EventListener\OrderListener::fixCustomers() */

            // Sets the parent as customer
            $target->setCustomer($customer->getParent());

            // Sets the origin customer
            if (null === $target->getOriginCustomer()) {
                $target->setOriginCustomer($customer);
            }
        }
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents()
    {
        return [
            SaleTransformEvents::PRE_COPY  => ['onPreCopy', 2048],
            SaleTransformEvents::POST_COPY => ['onPostCopy', 2048],
        ];
    }
}


<?php

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

use Ekyna\Component\Commerce\Supplier\Model\SupplierInterface;
use Ekyna\Component\Resource\Serializer\AbstractResourceNormalizer;

/**
 * Class SupplierNormalizer
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Serializer\Normalizer
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class SupplierNormalizer extends AbstractResourceNormalizer
{
    /**
     * @inheritdoc
     *
     * @param SupplierInterface $supplier
     */
    public function normalize($supplier, $format = null, array $context = [])
    {
        $data = parent::normalize($supplier, $format, $context);

        if ($this->contextHasGroup('Summary', $context)) {
            $data = array_replace($data, [
                'description' => $supplier->getDescription(),
            ]);
        }

        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 SupplierInterface;
    }

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


<?php

namespace mii\web;

use mii\core\Component;
use mii\core\InvalidRouteException;

class Request extends Component
{

    // HTTP Methods
    const GET = 'GET';
    const POST = 'POST';
    const PUT = 'PUT';
    const DELETE = 'DELETE';
    const HEAD = 'HEAD';
    const OPTIONS = 'OPTIONS';
    const TRACE = 'TRACE';
    const CONNECT = 'CONNECT';

    /**
     * @var  string  method: GET, POST, PUT, DELETE, HEAD, etc
     */
    protected $_method = 'GET';

    /**
     * The name of the HTTP header for sending CSRF token.
     */
    const CSRF_HEADER = 'X-CSRF-Token';

    /**
     * @var  string  the URI of the request
     */
    protected $_uri;

    protected $_hostname;

    public $csrf_validation = true;

    public $enable_csrf_cookie = true;

    public $csrf_token_name = 'csrf_token';

    protected $_csrf_token;

    /**
     * @var  string  Magic salt to add to the cookie
     */
    protected $cookie_salt = null;

    /**
     * @var  integer  Number of seconds before the cookie expires
     */
    protected $cookie_expiration = 0;

    /**
     * @var  string  Restrict the path that the cookie is available to
     */
    public $cookie_path = '/';

    /**
     * @var  string  Restrict the domain that the cookie is available to
     */
    public $cookie_domain = null;

    /**
     * @var  boolean  Only transmit cookies over secure connections
     */
    public $cookie_secure = false;

    /**
     * @var  boolean  Only transmit cookies over HTTP, disabling Javascript access
     */
    public $cookie_httponly = false;

    /**
     * @var  array   parameters from the route
     */
    public $params = [];

    /**
     * @var  string  controller to be executed
     */
    public $controller;

    /**
     * @var  string  action to be executed in the controller
     */
    public $action;

    public function init(array $config = []): void {

        foreach ($config as $key => $value)
            $this->$key = $value;

        $uri = parse_url('http://domain.com'.$_SERVER['REQUEST_URI'], PHP_URL_PATH);

        if (\Mii::$app->base_url && strpos($uri, \Mii::$app->base_url) === 0) {
            // Remove the base URL from the URI
            $uri = (string) substr($uri, strlen(\Mii::$app->base_url));
        }

        $this->uri($uri);

        if (isset($_SERVER['REQUEST_METHOD'])) {
            // Use the server request method
            $this->method($_SERVER['REQUEST_METHOD']);
        }

        if($this->_method === 'PUT') {
            parse_str($this->raw_body(), $_POST);
        }
    }

    /**
     * Sets and gets the uri from the request.
     *
     * @param   string $uri
     * @return  string
     */
    public function uri($uri = NULL): string {
        if ($uri === NULL) {
            return empty($this->_uri) ? '/' : $this->_uri;
        }

        return $this->_uri = $uri;
    }

    public function get_hostname(): string {

        if (!$this->_hostname) {
            $http = $this->is_secure() ? 'https' : 'http';
            $domain = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : $_SERVER['SERVER_NAME'];
            $this->_hostname = $http . '://' . $domain;
        }

        return $this->_hostname;
    }

    public function get_user_agent(): string {
        return $_SERVER['HTTP_USER_AGENT'];
    }

    public function get_content_type()
    {
        if (isset($_SERVER['CONTENT_TYPE'])) {
            return $_SERVER['CONTENT_TYPE'];
        }
    }

    /**
     * Gets or sets the HTTP method. Usually GET, POST, PUT or DELETE in
     * traditional CRUD applications.
     *
     * @param   string $method Method to use for this request
     * @return  mixed
     */
    public function method($method = NULL) {
        if ($method === NULL) {
            // Act as a getter
            return $this->_method;
        }

        // Act as a setter
        $this->_method = strtoupper($method);

        return $this;
    }

    /**
     * Return if the request is sent via secure channel (https).
     * @return boolean if the request is sent via secure channel (https)
     */
    public function is_secure() {
        return isset($_SERVER['HTTPS']) && (strcasecmp($_SERVER['HTTPS'], 'on') === 0 || $_SERVER['HTTPS'] == 1)
            || isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strcasecmp($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') === 0;
    }

    /**
     *  Returns GET parameter with a given name. If name isn't specified, returns an array of all GET parameters.
     *
     * @param   string $name the parameter name
     * @param   string $value the default parameter value if the parameter does not exist.
     * @return  mixed
     */
    public function get($key = null, $default = null) {
        if ($key) {
            return isset($_GET[$key]) ? $_GET[$key] : $default;
        }

        return $_GET;
    }

    public function param($key, $default = null) {
        if (isset($this->params[$key]))
            return $this->params[$key];

        return $default;
    }

    public function validate_csrf_token() {

        if (!$this->csrf_validation || in_array($this->method(), ['GET', 'HEAD', 'OPTIONS'], true)) {
            return true;
        }

        $token = '';

        if (isset($_POST[$this->csrf_token_name])) {

            $token = $_POST[$this->csrf_token_name];

        } elseif (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) { // must be like self::CSRF_HEADER

            $token = $_SERVER['HTTP_X_CSRF_TOKEN'];

        } else {
            return false;
        }

        return $this->csrf_token() === $token;
    }

    public function csrf_token($new = false) {

        if ($this->_csrf_token === null || $new) {
            if ($new || ($this->_csrf_token = $this->load_csrf_token()) === null) {
                // Generate a new unique token
                $this->_csrf_token = bin2hex(random_bytes(20));

                // Store the new token
                if ($this->enable_csrf_cookie) {
                    $this->set_cookie($this->csrf_token_name, $this->_csrf_token);
                } else {
                    \Mii::$app->session->set($this->csrf_token_name, $this->_csrf_token);
                }
            }
        }

        return $this->_csrf_token;
    }

    public function load_csrf_token() {
        if ($this->enable_csrf_cookie) {
            return $this->get_cookie($this->csrf_token_name);
        } else {
            return \Mii::$app->session->get($this->csrf_token_name);
        }
    }

    /**
     * Gets HTTP POST parameters of the request.
     *
     * @param   mixed $key Parameter name
     * @param   string $default Default value if parameter does not exist
     * @return  mixed
     */
    public function post($key = null, $default = null) {
        if ($key === null) {
            return $_POST;
        }

        return isset($_POST[$key]) ? $_POST[$key] : $default;
    }

    /**
     * Returns whether this is an AJAX (XMLHttpRequest) request.
     * @return boolean whether this is an AJAX (XMLHttpRequest) request.
     */
    public function is_ajax() {
        return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest';
    }

    /**
     * Returns whether this is a PJAX request
     * @return boolean whether this is a PJAX request
     */
    public function is_pjax() {
        return $this->is_ajax() && !empty($_SERVER['HTTP_X_PJAX']);
    }

    public function is_post() {
        return $this->method() === 'POST';
    }

    private $_raw_body;

    /**
     * Returns the raw HTTP request body.
     * @return string the request body
     */
    public function raw_body() {
        if ($this->_raw_body === null) {
            $this->_raw_body = file_get_contents('php://input');
        }

        return $this->_raw_body;
    }

    private $_json_params;

    public function json_params() {
        if ($this->_json_params === null) {
            $this->_json_params = file_get_contents('php://input');
        }

        return $this->_json_params;
    }

    private $_json_items;

    public function json($key, $default = null) {

        if(!$this->_json_items && strtolower($this->get_content_type()) === 'application/json') {
            $this->_json_items = json_decode(file_get_contents('php://input'), true);
        } else {
            $this->_json_items = [];
        }

        if ($key === null) {
            return $this->_json_items;
        }

        return isset($this->_json_items[$key]) ? $this->_json_items[$key] : $default;
    }

    /**
     * Gets the value of a signed cookie. Cookies without signatures will not
     * be returned. If the cookie signature is present, but invalid, the cookie
     * will be deleted.
     *
     * @param   string $key cookie name
     * @param   mixed $default default value to return
     * @return  string
     */
    public function get_cookie(string $key, $default = null) {
        if (!isset($_COOKIE[$key])) {
            // The cookie does not exist
            return $default;
        }

        // Get the cookie value
        $cookie = $_COOKIE[$key];

        // Find the position of the split between salt and contents
        $split = strlen($this->salt($key, ''));

        if (isset($cookie[$split]) && $cookie[$split] === '~') {
            // Separate the salt and the value
            list ($hash, $value) = explode('~', $cookie, 2);

            if ($this->salt($key, $value) === $hash) {
                // Cookie signature is valid
                return $value;
            }

            // The cookie signature is invalid, delete it
            $this->delete_cookie($key);
        }

        return $default;
    }

    /**
     * Sets a signed cookie. Note that all cookie values must be strings and no
     * automatic serialization will be performed!
     *
     * @param   string $name name of cookie
     * @param   string $value value of cookie
     * @param   integer $expiration lifetime in seconds
     * @return  boolean
     */
    public function set_cookie($name, $value, $expiration = null) {
        if ($expiration === null) {
            // Use the default expiration
            $expiration = $this->cookie_expiration;
        }

        if ($expiration !== 0) {
            // The expiration is expected to be a UNIX timestamp
            $expiration += time();
        }

        // Add the salt to the cookie value
        $value = $this->salt($name, $value) . '~' . $value;

        return setcookie($name, $value, $expiration, $this->cookie_path, $this->cookie_domain, $this->cookie_secure, $this->cookie_httponly);
    }

    /**
     * Deletes a cookie by making the value null and expiring it.
     *
     *     Cookie::delete('theme');
     *
     * @param   string $name cookie name
     * @return  boolean
     */
    public function delete_cookie($name) {
        // Remove the cookie
        unset($_COOKIE[$name]);

        // Nullify the cookie and make it expire
        return setcookie($name, null, -86400, $this->cookie_path, $this->cookie_domain, $this->cookie_secure, $this->cookie_httponly);
    }

    /**
     * Generates a salt string for a cookie based on the name and value.
     *
     * @param   string $name name of cookie
     * @param   string $value value of cookie
     * @return  string
     */
    public function salt(string $name, string $value): string {
        // Require a valid salt
        if (!$this->cookie_salt) {
            throw new \InvalidArgumentException(
                'A valid cookie salt is required. Please set Cookie::$salt before calling this method.' .
                'For more information check the documentation'
            );
        }

        // Determine the user agent
        $agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower($_SERVER['HTTP_USER_AGENT']) : 'unknown';

        return sha1($agent . $name . $value . $this->cookie_salt);
    }
}


<?php

namespace PeskyCMF\Scaffold\Form;

use PeskyORM\Core\DbExpr;
use PeskyORM\Exception\InvalidDataException;
use PeskyORM\ORM\RecordInterface;
use PeskyORM\ORM\Relation;

class ManyToManyRelationRecordsFormInput extends FormInput {

    /**
     * @var array
     */
    protected $dbQueryConditionsForDefaultOptionsLoader = [];
    /**
     * @var null|string|DbExpr
     */
    protected $optionLabelColumnForDefaultOptionsLoader;

    /**
     * Name of the column in 'linker table' that contains primary key values of the foreign table
     * For example you have 3 tables: items (main table), categories (foreing table), item_categories (linker table);
     * Here you have many-to-many relation between items and categories that resolved via 'linker table'
     * item_categories that contains only 3 columns: id, item_id (link to items.id), category_id (link to categoris.id);
     * You need to pass 'category_id' via $columnName argument
     * @param $columnName
     * @return $this
     */
    public function setRelationsLinkingColumn($columnName) {
        $this->relationColumn = $columnName;
        return $this;
    }

    /**
     * @return string
     */
    public function getType() {
        return static::TYPE_MULTISELECT;
    }

    public function getValidators($isCreation) {
        return [
            $this->getName() => 'array|nullable',
            $this->getName(). '.' . $this->getRelationColumn() => 'integer',
        ];
    }

    public function setRelation(Relation $relation, $columnName) {
        $this->relation = $relation;
        if ($columnName !== $relation->getForeignColumnName()) {
            $this->relationColumn = $columnName;
        }
        return $this;
    }

    public function getRelationColumn() {
        if (empty($this->relationColumn)) {
            throw new \UnexpectedValueException(
                "Relations linking column was not provided for '{$this->getName()}' input. "
                . 'Use setRelationsLinkingColumn(\'column_name\') method to provide it'
            );
        }
        return parent::getRelationColumn();
    }

    public function modifySubmitedValueBeforeValidation($value, array $data) {
        if (empty($value)) {
            $value = [];
        }
        if (is_array($value)) {
            $newFkValues = array_values($value);
            $value = [];
            foreach ($newFkValues as $fkValue) {
                $value[] = [$this->getRelationColumn() => (int)$fkValue];
            }
        }
        return parent::modifySubmitedValueBeforeValidation($value, $data);
    }

    public function doDefaultValueConversionByType($value, $type, array $record) {
        if (!is_array($value)) {
            throw new \InvalidArgumentException("Invalid data received for relation '{$this->getRelation()->getName()}'. Array expected.");
        }
        $ret = [];
        $column = $this->getRelationColumn();
        /** @var array $value */
        foreach ($value as $foreignRecord) {
            if (!array_key_exists($column, $foreignRecord)) {
                throw new \InvalidArgumentException(
                    "Invalid data received for relation '{$this->getRelation()->getName()}'. Value for column {$column} not found."
                );
            }
            $ret[] = $foreignRecord[$column];
        }
        return $ret;
    }

    public function hasOptionsLoader() {
        return true;
    }

    public function setOptions($options) {
        throw new \BadMethodCallException(
            "Plain options is forbidden for ManyToManyRelationRecordsFormInput '{$this->getName()}'. Use options loader."
        );
    }

    public function getOptionsLoader() {
        if (!$this->hasRelation()) {
            throw new \BadMethodCallException(
                "ManyToManyRelationRecordsFormInput '{$this->getName()}' must be linked to Relation in order to funciton properly"
            );
        }
        if (!$this->optionsLoader) {
            /** @var Relation|null $dataSourceRelation */
            $dataSourceRelation = null;
            $relations = $this
                ->getRelation()
                ->getForeignTable()
                ->getTableStructure()
                ->getColumn($this->getRelationColumn())
                ->getRelations();
            foreach ($relations as $relation) {
                if ($relation->getType() === Relation::HAS_ONE) {
                    $dataSourceRelation = $relation;
                    break;
                }
            }
            if (!$dataSourceRelation) {
                throw new \UnexpectedValueException(
                    "Failed to detect data source Relation for ManyToManyRelationRecordsFormInput '{$this->getName()}'. "
                    . "Column '{$this->getRelation()->getForeignTable()->getName()}.{$this->getRelationColumn()}'"
                    . ' has no Relation with type HAS ONE'
                );
            }
            $table = $dataSourceRelation->getForeignTable();
            $this->optionsLoader = function () use ($dataSourceRelation, $table) {
                $labelColumn = $this->getOptionLabelColumnForDefaultOptionsLoader($dataSourceRelation->getDisplayColumnName());
                if ($labelColumn instanceof \Closure) {
                    $records = $table::select('*', value($this->getDbQueryConditionsForDefaultOptionsLoader()));
                    $records->optimizeIteration();
                    $options = [];
                    /** @var RecordInterface $record */
                    foreach ($records as $record) {
                        $options[$record->getPrimaryKeyValue()] = $labelColumn($record);
                    }
                    return $options;
                } else {
                    return $table::selectAssoc(
                        $table::getPkColumnName(),
                        $labelColumn,
                        value($this->getDbQueryConditionsForDefaultOptionsLoader())
                    );
                }
            };
        }
        return $this->optionsLoader;
    }

    /**
     * @return array
     */
    protected function getDbQueryConditionsForDefaultOptionsLoader() {
        return $this->dbQueryConditionsForDefaultOptionsLoader;
    }

    /**
     * Set conditions for default options loader
     * @param array|\Closure $conditonsAndOptions
     * @return $this
     * @throws \InvalidArgumentException
     */
    public function setDbQueryConditionsForDefaultOptionsLoader($conditonsAndOptions) {
        if (
            !is_array($conditonsAndOptions)
            && !($conditonsAndOptions instanceof DbExpr)
            && !($conditonsAndOptions instanceof \Closure)
        ) {
            throw new \InvalidArgumentException(
                '$conditonsAndOptions argument must be a string, DbExpr or a Closure'
            );
        }
        $this->dbQueryConditionsForDefaultOptionsLoader = $conditonsAndOptions;
        return $this;
    }

    /**
     * Set source for options labels
     * @param string|\Closure $columnNameOrClosure
     *      - string: column name
     *      - \Closure: function (RecordInterface $record) { return 'value' }
     * @return $this
     * @throws \InvalidArgumentException
     */
    public function setOptionLabelColumnForDefaultOptionsLoader($columnNameOrClosure) {
        if (
            !is_string($columnNameOrClosure)
            && !($columnNameOrClosure instanceof DbExpr)
            && !($columnNameOrClosure instanceof \Closure)
        ) {
            throw new \InvalidArgumentException(
                '$columnNameOrClosure argument must be a string, DbExpr or a Closure'
            );
        }
        $this->optionLabelColumnForDefaultOptionsLoader = $columnNameOrClosure;
        return $this;
    }

    /**
     * @param mixed $default
     * @return string|DbExpr|null
     */
    protected function getOptionLabelColumnForDefaultOptionsLoader($default = null) {
        return $this->optionLabelColumnForDefaultOptionsLoader ?: $default;
    }

}


<?php

namespace mii\web;

use mii\core\Component;

class UploadHandler extends Component
{

    private $_files;

    public function init(array $config = []): void {
        parent::init($config);

        $this->files();
    }

    public function get_file(string $name): ?UploadedFile {
        return isset($this->_files[$name]) ? $this->_files[$name] : null;
    }

    /**
     * @param string $name
     * @return array UploadedFile
     */
    public function get_files(string $name): array {
        if (isset($this->_files[$name])) {
            return [$this->_files[$name]];
        }
        $results = [];
        foreach ($this->_files as $key => $file) {
            if (strpos($key, "{$name}[") === 0) {
                $results[] = $file;
            }
        }
        return $results;
    }

    public function files(): array {

        if ($this->_files === null) {
            $this->_files = [];
            if (isset($_FILES) && is_array($_FILES)) {
                foreach ($_FILES as $class => $info) {
                    $this->load_files_recursive($class, $info['name'], $info['tmp_name'], $info['type'], $info['size'], $info['error']);
                }
            }
        }
        return $this->_files;
    }

    /**
     * Creates UploadedFile instances from $_FILE recursively.
     * @param string $key key for identifying uploaded file: class name and sub-array indexes
     * @param mixed $names file names provided by PHP
     * @param mixed $tmp_names temporary file names provided by PHP
     * @param mixed $types file types provided by PHP
     * @param mixed $sizes file sizes provided by PHP
     * @param mixed $errors uploading issues provided by PHP
     */
    private function load_files_recursive($key, $names, $tmp_names, $types, $sizes, $errors) {
        if (is_array($names)) {
            foreach ($names as $i => $name) {
                static::load_files_recursive($key . '[' . $i . ']', $name, $tmp_names[$i], $types[$i], $sizes[$i], $errors[$i]);
            }
        } elseif ((int)$errors !== UPLOAD_ERR_NO_FILE) {
            $this->_files[$key] = new UploadedFile([
                'name' => $names,
                'tmp_name' => $tmp_names,
                'type' => $types,
                'size' => $sizes,
                'error' => $errors,
            ]);
        }
    }

    private function make_upload_dir() {

    }

}


<?php

namespace PeskyCMF\Db\Traits;

use Illuminate\Contracts\Encryption\DecryptException;
use PeskyCMF\Db\CmfDbRecord;
use PeskyORM\Core\DbExpr;
use PeskyORM\ORM\Column;

trait ResetsPasswordsViaAccessKey {

    /**
     * @param int|null $expiresIn - minutes until expiration. Default: config('auth.passwords.' . \Auth::getDefaultDriver() . '.expire', 60)
     * @param array|null $additionalColumns - additional columns to encode. Default: $this->getAdditionalColumnsForPasswordRecoveryAccessKey()
     * @return string
     */
    public function getPasswordRecoveryAccessKey(?int $expiresIn = null, ?array $additionalColumns = null) {
        /** @var CmfDbRecord|ResetsPasswordsViaAccessKey $this */
        $expiresInMinutes = $expiresIn > 0 ? $expiresIn : (int)config('auth.passwords.' . \Auth::getDefaultDriver() . '.expire', 60);
        $data = [
            'account_id' => $this->getPrimaryKeyValue(),
            'expires_at' => (new \DateTime('now', new \DateTimeZone('UTC')))->getTimestamp() + $expiresInMinutes * 60,
        ];
        $this->reload(); //< needed to exclude situation with outdated data
        if ($additionalColumns === null) {
            $additionalColumns = $this->getAdditionalColumnsForPasswordRecoveryAccessKey();
        }
        $data['added_keys'] = $additionalColumns;
        foreach ($additionalColumns as $columnName) {
            $data[$columnName] = $this->getValue($columnName);
        }
        return \Crypt::encrypt(json_encode($data));
    }

    public function getAdditionalColumnsForPasswordRecoveryAccessKey() {
        /** @var CmfDbRecord|ResetsPasswordsViaAccessKey $this */
        $columns = [];
        if ($this::hasColumn('updated_at')) {
            $columns[] = 'updated_at';
        } else if ($this::hasColumn('password')) {
            $columns[] = 'password';
        }
        return $columns;
    }

    /**
     * Vlidate access key and find user
     * @param string $accessKey
     * @return CmfDbRecord|bool - false = failed to parse access key, validate data or load user
     * @throws \UnexpectedValueException
     * @throws \PeskyORM\Exception\OrmException
     * @throws \PDOException
     * @throws \InvalidArgumentException
     * @throws \BadMethodCallException
     */
    static public function loadFromPasswordRecoveryAccessKey(string $accessKey) {
        try {
            $data = \Crypt::decrypt($accessKey);
        } catch (DecryptException $exc) {
            return false;
        }
        if (empty($data)) {
            return false;
        }
        $data = json_decode($data, true);
        $now = new \DateTime('now', new \DateTimeZone('UTC'));
        if (
            empty($data)
            || !is_array($data)
            || !isset($data['added_keys'])
            || !is_array($data['added_keys'])
            || empty($data['account_id'])
            || empty($data['expires_at'])
            || $data['expires_at'] < $now->getTimestamp()
        ) {
            return false;
        }
        /** @var ResetsPasswordsViaAccessKey|CmfDbRecord $user */
        $user = static::newEmptyRecord();
        $conditions = [
            $user::getPrimaryKeyColumnName() => $data['account_id'],
        ];
        $additionalColumns = $data['added_keys'];
        foreach ($additionalColumns as $columnName) {
            if (!array_key_exists($columnName, $data)) {
                return false;
            }
            $fieldType = $user::getColumn($columnName)->getType();
            switch ($fieldType) {
                case Column::TYPE_DATE:
                    $conditions[$columnName . '::date'] = DbExpr::create("``$data[$columnName]``::date");
                    break;
                case Column::TYPE_TIME:
                    $conditions[$columnName . '::time'] = DbExpr::create("``$data[$columnName]``::time");
                    break;
                case Column::TYPE_TIMESTAMP:
                    $conditions[] = DbExpr::create("`{$columnName}`::timestamp(0) = ``{$data[$columnName]}``::timestamp(0)");
                    break;
                default:
                    $conditions[$columnName] = $data[$columnName];
            }
        }
        if (!$user->fromDb($conditions)->existsInDb()) {
            return false;
        }
        return $user;
    }
}


<?php

namespace SnooPHP\Http;

/**
 * Route manager
 *
 * When called, analyzes the incoming request
 * and execute first matching route, if any
 *
 * @author sneppy
 */
class Router
{
	/**
	 * @var string $base base path to prepend
	 */
	protected $base;

	/**
	 * @var Route[] $routes list of configured routes
	 */
	protected $routes = [];

	/**
	 * @var Callable $errorAction action executed on 404
	 */
	protected $errorAction = null;

	/**
	 * @var array $defaultHeaders headers to append to all response
	 */
	protected $defaultHeaders = [];

	/**
	 * Create a new router
	 *
	 * Note that base only affects routes creation, not routes matching
	 *
	 * @param string $base base path
	 */
	public function __construct($base = "")
	{
		$this->base = $base;
	}

	/**
	 * Get or set router base
	 *
	 * @param string|null $base new base for router
	 *
	 * @return String
	 */
	public function base($base = null)
	{
		return $this->base;
	}

	/**
	 * Handle request
	 *
	 * Also appends default headers if ignoreDefaultHeaders is not true
	 *
	 * @param Request $request request to handle
	 *
	 * @return Response|null|bool
	 */
	public function handle(Request $request)
	{
		$res = $this->match($request);
		if ($res && !$res->ignoreDefaultHeaders) $res->header($this->defaultHeaders);
		return $res;
	}

	/**
	 * Add a GET route
	 *
	 * @param string	$url	route url
	 * @param Callable	$action	action to perform on match
	 *
	 * @return Route
	 */
	public function get($url, Callable $action = null)
	{
		return $this->add($url, "GET", $action);
	}

	/**
	 * Add a POST route
	 *
	 * @param string	$url	route url
	 * @param Callable	$action	action to perform on match
	 *
	 * @return Route
	 */
	public function post($url, Callable $action = null)
	{
		return $this->add($url, "POST", $action);
	}

	/**
	 * Add a PUT route
	 *
	 * @param string	$url	route url
	 * @param Callable	$action	action to perform on match
	 *
	 * @return Route
	 */
	public function put($url, Callable $action = null)
	{
		return $this->add($url, "PUT", $action);
	}

	/**
	 * Add a DELETE route
	 *
	 * @param string	$url	route url
	 * @param Callable	$action	action to perform on match
	 *
	 * @return Route
	 */
	public function delete($url, Callable $action = null)
	{
		return $this->add($url, "DELETE", $action);
	}

	/**
	 * add an OPTIONS route
	 *
	 * OPTIONS route are usually required to allow CORS requests
	 *
	 * @param string	$url	router url
	 * @param Callable	$action	action to perform on match
	 *
	 * @return Route
	 */
	public function options($url, Callable $action = null)
	{
		return $this->add($url, "OPTIONS", $action);
	}

	/**
	 * Get or set error action
	 *
	 * @param Callable|null $action action to be executed
	 *
	 * @return Callable
	 */
	public function error(Callable $action = null)
	{
		if ($action) $this->errorAction = $action;
		return $this->errorAction;
	}

	/**
	 * Error response
	 *
	 * @param Request|null $request incoming request
	 *
	 * @return Response
	 */
	public function onError(Request $request = null)
	{
		$request = $request ?: Request::current();
		$res = $this->error()($request);
		if ($res && !$res->ignoreDefaultHeaders) $res->header($this->defaultHeaders);
		return $res;
	}

	/**
	 * Alias for error action
	 *
	 * @deprecated v2.2
	 * @see Router::error
	 */
	public function errorAction(Callable $action = null)
	{
		return $this->error($action);
	}

	/**
	 * Add one or more default headers
	 *
	 * also returns current default headers
	 *
	 * @param array $headers header(s) to be added
	 *
	 * @return array list of current default headers
	 */
	public function defaultHeader(array $headers = [])
	{
		$this->defaultHeaders = array_merge($this->defaultHeaders, $headers);

		return $this->defaultHeaders;
	}

	/**
	 * Remove one or more default headers
	 *
	 * laso returns current default headers
	 *
	 * @param string|array $headers header(s) to be removed
	 *
	 * @return array list of current default headers
	 */
	public function removeDefaultHeader($headers = "")
	{
		foreach ($headers as $header)
			foreach ($this->defaultHeaders as $h => $v)
				if ($header === $h) unset($this->defaultHeaders[$h]);

		return $this->defaultHeaders;
	}

	/**
	 * Return response of first route that matches, false otherwise
	 *
	 * If a route action returns false, following routes have a chance at matching
	 * After the first match the function returns. In case of possible multiple matches
	 * the route declared first is taken as valid (too expensive to check match length).
	 * Pay attention to "greedy" routes (with + or *) can shadow other routes.
	 *
	 * @param Request $request request to test
	 *
	 * @return Response|bool
	 */
	protected function match(Request $request)
	{
		foreach ($this->routes as $route)
		{
			if ($route->method() === $request->method() && $route->match($request->url()))
			{
				if ($action = $route->action())
				{
					try
					{
						$response = $action($request, $route->args());
						if ($response !== false) return $response;
					}
					catch (AbortRouteException $abort)
					{
						return $abort->response();
					}
				}
				else
					// Match found but no action to perform
					return null;
			}
		}

		// No match found
		return false;
	}

	/**
	 * Add a generic route
	 *
	 * Use dedicated methods:
	 * @see Router::get
	 * @see Router::post
	 * @see Router::put
	 * @see Router::delete
	 *
	 * @param string	$url	route url
	 * @param string	$method	route method
	 * @param Callable	$action	route action
	 *
	 * @return Route
	 */
	protected function add($url, $method, Callable $action)
	{
		$url = $this->base !== "" && $url === "/" ? $this->base : $this->base.$url;
		$route = new Route($url, $method, $action);
		$this->routes[] = $route;

		return $route;
	}
}


<?php

namespace Ekyna\Component\Commerce\Order\Export;

use Ekyna\Component\Commerce\Common\Util\FormatterAwareTrait;
use Ekyna\Component\Commerce\Common\Util\FormatterFactory;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Order\Model\OrderInterface;
use Ekyna\Component\Commerce\Order\Repository\OrderRepositoryInterface;

/**
 * Class OrderExporter
 * @package Ekyna\Component\Commerce\Order\Export
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class OrderExporter
{
    use FormatterAwareTrait;

    /**
     * @var OrderRepositoryInterface
     */
    protected $repository;

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

    /**
     * Export the due orders.
     *
     * @return string The export file path.
     */
    public function exportDueOrders()
    {
        return $this->buildFile($this->repository->findDueOrders(), 'due');
    }

    /**
     * Export all the due orders (archive with all CSV files).
     *
     * @return string The export file path.
     */
    public function exportAllDueOrders()
    {
        $path = tempnam(sys_get_temp_dir(), 'acc');

        $zip = new \ZipArchive();

        if (false === $zip->open($path)) {
            throw new RuntimeException("Failed to open '$path' for writing.");
        }

        $zip->addFile($this->exportRegularDueOrders(), 'regular-due-orders.csv');
        $zip->addFile($this->exportOutstandingExpiredDueOrders(), 'outstanding-expired-due-orders.csv');
        $zip->addFile($this->exportOutstandingFallDueOrders(), 'outstanding-fall-due-orders.csv');
        $zip->addFile($this->exportOutstandingPendingDueOrders(), 'outstanding-pending-due-orders.csv');

        $zip->close();

        return $path;
    }

    /**
     * Export the regular (payment term less) due orders.
     *
     * @return string The export file path.
     */
    public function exportRegularDueOrders()
    {
        return $this->buildFile($this->repository->getRegularDueOrders(), 'regular_due');
    }

    /**
     * Export the outstanding expired due orders.
     *
     * @return string The export file path.
     */
    public function exportOutstandingExpiredDueOrders()
    {
        return $this->buildFile($this->repository->getOutstandingExpiredDueOrders(), 'outstanding_expired_due');
    }

    /**
     * Export the outstanding fall due orders.
     *
     * @return string The export file path.
     */
    public function exportOutstandingFallDueOrders()
    {
        return $this->buildFile($this->repository->getOutstandingFallDueOrders(), 'outstanding_fall_due');
    }

    /**
     * Export the outstanding pending due orders.
     *
     * @return string The export file path.
     */
    public function exportOutstandingPendingDueOrders()
    {
        return $this->buildFile($this->repository->getOutstandingPendingDueOrders(), 'outstanding_pending_due');
    }

    /**
     * Builds the orders export CSV file.
     *
     * @param OrderInterface[] $orders
     * @param string           $name
     *
     * @return string
     */
    protected function buildFile(array $orders, string $name)
    {
        if (false === $path = tempnam(sys_get_temp_dir(), $name)) {
            throw new RuntimeException("Failed to create temporary file.");
        }

        if (false === $handle = fopen($path, "w")) {
            throw new RuntimeException("Failed to open '$path' for writing.");
        }

        if (!empty($headers = $this->buildHeaders())) {
            fputcsv($handle, $headers, ';', '"');
        }

        $total = 0;
        $expired = 0;

        // Order rows
        foreach ($orders as $order) {
            if (!empty($row = $this->buildRow($order))) {
                fputcsv($handle, $row, ';', '"');

                $total += $row['due_amount'];
                $expired += $row['outstanding_expired'];
            }
        }

        // Total row
        fputcsv($handle, [
            'id'                  => '',
            'number'              => '',
            'company'             => '',
            'payment_state'       => '',
            'shipment_state'      => '',
            'invoice_state'       => '',
            'payment_term'        => '',
            'due_amount'          => $total,
            'outstanding_expired' => $expired,
            'outstanding_date'    => '',
            'created_at'          => '',
        ], ';', '"');

        fclose($handle);

        return $path;
    }

    /**
     * Returns the headers.
     *
     * @return array
     */
    protected function buildHeaders()
    {
        return [
            'id',
            'number',
            'company',
            'payment_state',
            'shipment_state',
            'invoice_state',
            'payment_term',
            'due_amount',
            'outstanding_expired',
            'outstanding_date',
            'created_at',
        ];
    }

    /**
     * Builds the order row.
     *
     * @param OrderInterface $order
     *
     * @return array
     */
    protected function buildRow(OrderInterface $order)
    {
        $date = null;
        $term = null;

        $formatter = $this->getFormatter();

        if (null !== $date = $order->getOutstandingDate()) {
            $date = $formatter->date($date);
        }
        if (null !== $term = $order->getPaymentTerm()) {
            $term = $term->getName();
        }

        return [
            'id'                  => $order->getId(),
            'number'              => $order->getNumber(),
            'company'             => $order->getCompany(),
            'payment_state'       => $order->getPaymentState(),
            'shipment_state'      => $order->getShipmentState(),
            'invoice_state'       => $order->getInvoiceState(),
            'payment_term'        => $term,
            'due_amount'          => $order->getGrandTotal() - $order->getPaidTotal(),
            'outstanding_expired' => $order->getOutstandingExpired(),
            'outstanding_date'    => $date,
            'created_at'          => $formatter->date($order->getCreatedAt()),
        ];
    }
}


<?php

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

use Ekyna\Component\Commerce\Document\Model\DocumentLineTypes;
use Ekyna\Component\Commerce\Exception\ValidationFailedException;
use Ekyna\Component\Commerce\Invoice\Model\InvoiceInterface;
use Ekyna\Component\Commerce\Invoice\Model\InvoiceTypes;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

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

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

        // If credit, must have a payment method
        if (InvoiceTypes::isCredit($invoice) && null === $invoice->getPaymentMethod() && $invoice->getSale()->isPaid()) {
            $this
                ->context
                ->buildViolation($constraint->null_credit_method)
                ->atPath('paymentMethod')
                ->addViolation();

            return;
        }

        // Can't have no good lines
        if (empty($invoice->getLinesByType(DocumentLineTypes::TYPE_GOOD))) {
            $this
                ->context
                ->buildViolation($constraint->empty_good_lines)
                ->atPath('lines')
                ->addViolation();

            return;
        }

        try {
            $this->checkHierarchyIntegrity($invoice);
        } catch (ValidationFailedException $e) {
            $this
                ->context
                ->buildViolation($constraint->hierarchy_integrity)
                ->atPath('shipment')
                ->addViolation();
        }
    }

    /**
     * Check the hierarchy integrity.
     *
     * @param InvoiceInterface $invoice
     *
     * @throws ValidationFailedException
     */
    private function checkHierarchyIntegrity(InvoiceInterface $invoice)
    {
        // [ Invoice <-> Sale <-> Shipment ] integrity
        if (null !== $shipment = $invoice->getShipment()) {
            if ($invoice->getSale() !== $shipment->getSale()) {
                throw new ValidationFailedException();
            }

            // Credit <-> Return
            if (InvoiceTypes::isCredit($invoice) && !$shipment->isReturn()) {
                throw new ValidationFailedException();
            }

            // Invoice <-> Shipment
            if (InvoiceTypes::isInvoice($invoice) && $shipment->isReturn()) {
                throw new ValidationFailedException();
            }
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\EventListener;

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

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

    /**
     * @var AdjustmentBuilderInterface
     */
    protected $adjustmentBuilder;

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

    /**
     * Sets the adjustment builder.
     *
     * @param AdjustmentBuilderInterface $adjustmentBuilder
     */
    public function setAdjustmentBuilder(AdjustmentBuilderInterface $adjustmentBuilder)
    {
        $this->adjustmentBuilder = $adjustmentBuilder;
    }

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

        $change = $this->updateTaxation($item);

        $change |= $this->updateDiscount($item);

        if ($change) {
            $this->persistenceHelper->persistAndRecompute($item);
        }

        $this->scheduleSaleContentChangeEvent($item->getSale());
    }

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

        $change = false;

        // Handle taxation update
        if ($this->persistenceHelper->isChanged($item, ['taxGroup'])) {
            $change = $this->updateTaxation($item);
        }

        // Handle discount update
        $discountFields = [
            'subjectIdentity.provider', 'subjectIdentity.identifier',
            'netPrice', 'quantity', 'compound', 'private'
        ];
        if ($this->persistenceHelper->isChanged($item, $discountFields)) {
            $this->updateDiscount($item);
            $change = true;
        }

        if ($change) {
            $this->persistenceHelper->persistAndRecompute($item);

            $this->scheduleSaleContentChangeEvent($item->getSale());
        }
    }

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

        // Sale may be null here: if it is schedule for delete,
        // the persistence tracker won't be able to retrieve it.
        $sale = $this->getSaleFromItem($item);

        if (null !== $parent = $item->getParent()) {
            $parent->removeChild($item);
        } elseif (null !== $sale) {
            $sale->removeItem($item);
        }

        if (null !== $sale) {
            $this->scheduleSaleContentChangeEvent($sale);
        }
    }

    /**
     * Pre update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onPreUpdate(ResourceEventInterface $event)
    {
        // TODO Disabled to allow admins to change weight and net price
        // $this->throwIllegalOperationIfItemIsImmutable($event);
    }

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

        $item = $this->getSaleItemFromEvent($event);

        if (null === $sale = $item->getSale()) {
            return;
        }

        $items = $sale->getItems()->toArray();
        foreach ($items as $item) {
            $this->loadItem($item);
        }
    }

    /**
     * Loads the item's children and adjustments recursively.
     *
     * @param Model\SaleItemInterface $item
     */
    private function loadItem(Model\SaleItemInterface $item)
    {
        $item->getAdjustments()->toArray();

        $children = $item->getChildren()->toArray();

        foreach ($children as $child) {
            $this->loadItem($child);
        }
    }

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

        $item = $this->getSaleItemFromEvent($event);

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

    /**
     * Updates the item's discount adjustments.
     *
     * @param Model\SaleItemInterface $item
     *
     * @return bool Whether the adjustments has been changed or not.
     */
    protected function updateDiscount(Model\SaleItemInterface $item)
    {
        return $this->adjustmentBuilder->buildDiscountAdjustmentsForSaleItem($item, true);
    }

    /**
     * Updates the item's taxation adjustments.
     *
     * @param Model\SaleItemInterface $item
     *
     * @return bool Whether the adjustments has been changed or not.
     */
    protected function updateTaxation(Model\SaleItemInterface $item)
    {
        return $this->adjustmentBuilder->buildTaxationAdjustmentsForSaleItem($item, true);
    }

    /**
     * Returns the item's sale.
     *
     * @param Model\SaleItemInterface $item
     *
     * @return Model\SaleInterface
     */
    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 item property path.
     *
     * @return string
     */
    abstract protected function getSalePropertyPath();

    /**
     * Schedules the sale content change event.
     *
     * @param Model\SaleInterface $sale
     */
    abstract protected function scheduleSaleContentChangeEvent(Model\SaleInterface $sale);

    /**
     * Returns the sale item from the resource event.
     *
     * @param ResourceEventInterface $event
     *
     * @return Model\SaleItemInterface
     */
    abstract protected function getSaleItemFromEvent(ResourceEventInterface $event);
}