<?php

namespace SilverShop\Cart;

use ErrorException;
use Exception;
use Monolog\Logger;
use SilverShop\Model\Order;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\ORM\DB;

/**
 * Handles the calculation of order totals.
 *
 * Creates (if necessary) and calculates values for each modifier,
 * and subsequently the total of the order.
 * Caches to prevent recalculation, unless dirty.
 */
class OrderTotalCalculator
{
    use Injectable;

    private static $dependencies = [
        'logger' => '%$SilverShop\Logger',
    ];

    /**
     * @var Logger
     */
    public $logger;

    /**
     * @var Order
     */
    protected $order;

    function __construct(Order $order)
    {
        $this->order = $order;
    }

    /**
     * @return float
     * @throws Exception
     * @throws \Psr\Container\NotFoundExceptionInterface
     */
    function calculate()
    {
        $runningtotal = $this->order->SubTotal();
        $sort = 1;
        $existingmodifiers = $this->order->Modifiers();
        $modifierclasses = Order::config()->modifiers;

        //check if modifiers are even in use
        if (!is_array($modifierclasses) || empty($modifierclasses)) {
            return $runningtotal;
        }

        if (DB::get_conn()->supportsTransactions()) {
            DB::get_conn()->transactionStart();
        }

        set_error_handler(
            function ($severity, $message, $file, $line) {
                throw new ErrorException($message, 0, $severity, $file, $line);
            },
            E_ALL & ~(E_STRICT | E_NOTICE)
        );

        try {
            foreach ($modifierclasses as $ClassName) {
                if ($modifier = $this->getModifier($ClassName)) {
                    $modifier->Sort = $sort;
                    $runningtotal = $modifier->modify($runningtotal);
                    if ($modifier->isChanged()) {
                        $modifier->write();
                    }
                }
                $sort++;
            }
            //clear old modifiers out
            if ($existingmodifiers) {
                foreach ($existingmodifiers as $modifier) {
                    if (!in_array($modifier->ClassName, $modifierclasses)) {
                        $modifier->delete();
                        $modifier->destroy();
                    }
                }
            }
        } catch (Exception $ex) {
            // Rollback the transaction if an error occurred
            if (DB::get_conn()->supportsTransactions()) {
                DB::get_conn()->transactionRollback();
            }
            // throw the exception after rollback
            throw $ex;
        } finally {
            // restore the error handler, no matter what
            restore_error_handler();
        }

        // Everything went through fine, complete the transaction
        if (DB::get_conn()->supportsTransactions()) {
            DB::get_conn()->transactionEnd();
        }

        //prevent negative sales from ever occurring
        if ($runningtotal < 0) {
            $this->logger->error(
                "Order (ID = {$this->order->ID}) was calculated to equal $runningtotal.\n
                Order totals should never be negative!\n
                The order total was set to $0"
            );

            $runningtotal = 0;
        }

        return $runningtotal;
    }

    /**
     * Retrieve a modifier of a given class for the order.
     * Modifier will be retrieved from database if it already exists,
     * or created if it is always required.
     *
     * @param string  $className
     * @param boolean $forcecreate - force the modifier to be created.
     */
    public function getModifier($className, $forcecreate = false)
    {
        if (!ClassInfo::exists($className)) {
            user_error("Modifier class \"$className\" does not exist.");
        }
        //search for existing
        $modifier = $className::get()
            ->filter("OrderID", $this->order->ID)
            ->first();
        if ($modifier) {
            //remove if no longer valid
            if (!$modifier->valid()) {
                //TODO: need to provide feedback message - why modifier was removed
                $modifier->delete();
                $modifier->destroy();
                return null;
            }
            return $modifier;
        }
        $modifier = new $className();
        if ($modifier->required() || $forcecreate) { //create any modifiers that are required for every order
            $modifier->OrderID = $this->order->ID;
            $modifier->write();
            $this->order->Modifiers()->add($modifier);

            return $modifier;
        }

        return null;
    }
}


<?php

namespace SilverShop\Model;

use SilverShop\Cart\ShoppingCartController;
use SilverShop\Forms\ShopQuantityField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\FieldType\DBCurrency;

/**
 * An order item is a product which has been added to an order,
 * ready for purchase. An order item is typically a product itself,
 * but also can include references to other information such as
 * product attributes like colour, size, or type.
 *
 * @property int $Quantity
 * @property DBCurrency $UnitPrice
 */
class OrderItem extends OrderAttribute
{
    private static $db = [
        'Quantity' => 'Int',
        'UnitPrice' => 'Currency',
    ];

    private static $casting = [
        'UnitPrice' => 'Currency',
        'Total' => 'Currency',
    ];

    private static $searchable_fields = [
        'OrderID' => [
            'title' => 'Order ID',
            'field' => TextField::class,
        ],
        'Title' => 'PartialMatchFilter',
        'TableTitle' => 'PartialMatchFilter',
        'CartTitle' => 'PartialMatchFilter',
        'UnitPrice',
        'Quantity',
        'Total',
    ];

    private static $summary_fields = [
        'Order.ID' => 'Order ID',
        'TableTitle' => 'Title',
        'UnitPrice' => 'Unit Price',
        'Quantity' => 'Quantity',
        'Total' => 'Total Price',
    ];

    private static $required_fields = array();

    /**
     * The ORM relationship to the buyable item
     *
     * @config
     * @var    string
     */
    private static $buyable_relationship = 'Product';

    private static $singular_name = 'Item';

    private static $plural_name = 'Items';

    private static $default_sort = '"Created" DESC';

    private static $table_name = 'SilverShop_OrderItem';

    /**
     * Get the buyable object related to this item.
     */
    public function Buyable()
    {
        return $this->{self::config()->buyable_relationship}();
    }

    /**
     * Get unit price for this item.
     * Fetches from db, or Buyable, based on order status.
     */
    public function UnitPrice()
    {
        if ($this->Order()->IsCart()) {
            $buyable = $this->Buyable();
            $unitprice = ($buyable) ? $buyable->sellingPrice() : $this->UnitPrice;
            $this->extend('updateUnitPrice', $unitprice);
            return $this->UnitPrice = $unitprice;
        }
        return $this->UnitPrice;
    }

    /**
     * Prevent unit price ever being below 0
     */
    public function setUnitPrice($val)
    {
        if ($val < 0) {
            $val = 0;
        }
        $this->setField('UnitPrice', $val);
    }

    /**
     * Prevent quantity being below 1.
     * 0 quantity means it should instead be deleted.
     *
     * @param int $val new quantity to set
     */
    public function setQuantity($val)
    {
        $val = $val < 1 ? 1 : $val;
        $this->setField('Quantity', $val);
    }

    /**
     * Get calculated total, or stored total
     * depending on whether the order is in cart
     */
    public function Total()
    {
        if ($this->Order()->IsCart()) { //always calculate total if order is in cart
            return $this->calculatetotal();
        }
        return $this->CalculatedTotal; //otherwise get value from database
    }

    /**
     * Calculates the total for this item.
     * Generally called by onBeforeWrite
     */
    protected function calculatetotal()
    {
        $total = $this->UnitPrice() * $this->Quantity;
        $this->extend('updateTotal', $total);
        $this->CalculatedTotal = $total;
        return $total;
    }

    /**
     * Intersects this item's required_fields with the data record.
     * This is used for uniquely adding items to the cart.
     */
    public function uniquedata()
    {
        $required = self::config()->required_fields; //TODO: also combine with all ancestors of this->class
        $unique = [];
        $hasOnes = $this->hasOne();
        //reduce record to only required fields
        if ($required) {
            foreach ($required as $field) {
                if ($hasOnes === $field || isset($hasOnes[$field])) {
                    $field = $field . 'ID'; //add ID to hasones
                }
                $unique[$field] = $this->$field;
            }
        }
        return $unique;
    }

    /**
     * Recalculate total before saving to database.
     */
    public function onBeforeWrite()
    {
        parent::onBeforeWrite();
        if ($this->OrderID && $this->Order() && $this->Order()->isCart()) {
            $this->calculatetotal();
        }
    }

    /*
     * Event handler called when an order is fully paid for.
     */
    public function onPayment()
    {
        $this->extend('onPayment');
    }

    /**
     * Event handlier called for last time saving/processing,
     * before item permanently stored in database.
     * This should only be called when order is transformed from
     * Cart to Order, aka being 'placed'.
     */
    public function onPlacement()
    {
        $this->extend('onPlacement');
    }

    /**
     * Get the buyable image.
     * Also serves as a standardised placeholder for overriding in subclasses.
     */
    public function Image()
    {
        if ($this->Buyable()) {
            return $this->Buyable()->Image();
        }
    }

    /**
     * @return ShopQuantityField
     */
    public function QuantityField()
    {
        return ShopQuantityField::create($this);
    }

    /**
     * @return string
     */
    public function addLink()
    {
        $buyable = $this->Buyable();
        return $buyable ? ShoppingCartController::add_item_link($buyable, $this->uniquedata()) : '';
    }

    /**
     * @return string
     */
    public function removeLink()
    {
        $buyable = $this->Buyable();
        return $buyable ? ShoppingCartController::remove_item_link($buyable, $this->uniquedata()) : '';
    }

    /**
     * @return string
     */
    public function removeallLink()
    {
        $buyable = $this->Buyable();
        return $buyable ? ShoppingCartController::remove_all_item_link($buyable, $this->uniquedata()) : '';
    }

    /**
     * @return string
     */
    public function setquantityLink()
    {
        $buyable = $this->Buyable();
        return $buyable ? ShoppingCartController::set_quantity_item_link($buyable, $this->uniquedata()) : '';
    }
}


<?php

namespace SilverShop\Model\Variation;

use SilverStripe\Versioned\Versioned;

/**
 * Product Variation - Order Item
 * Connects a variation to an order, as a line in the order specifying the particular variation.
 *
 * @property int $ProductVariationVersion
 * @property int $ProductVariationID
 */
class OrderItem extends \SilverShop\Model\Product\OrderItem
{
    private static $db = [
        'ProductVariationVersion' => 'Int',
    ];

    private static $has_one = [
        'ProductVariation' => Variation::class
    ];

    private static $buyable_relationship = 'ProductVariation';

    private static $table_name = 'SilverShop_Variation_OrderItem';

    /**
     * Overloaded relationship, for getting versioned variations
     *
     * @param  boolean $current
     * @return Variation
     */
    public function ProductVariation($forcecurrent = false)
    {
        if ($this->ProductVariationID && $this->ProductVariationVersion && !$forcecurrent) {
            return Versioned::get_version(
                Variation::class,
                $this->ProductVariationID,
                $this->ProductVariationVersion
            );
        } elseif ($this->ProductVariationID
            && $product = Variation::get()->byID($this->ProductVariationID)
        ) {
            return $product;
        }
        return null;
    }

    public function SubTitle()
    {
        if ($this->ProductVariation()) {
            return $this->ProductVariation()->getTitle();
        }
        return false;
    }

    public function Image()
    {
        if (($variation = $this->ProductVariation()) && $variation->Image()->exists()) {
            return $variation->Image();
        }
        return $this->Product()->Image();
    }

    public function Width()
    {
        if (($variation = $this->ProductVariation()) && $variation->Width) {
            return $variation->Width;
        }
        return $this->Product()->Width;
    }

    public function Height()
    {
        if (($variation = $this->ProductVariation()) && $variation->Height) {
            return $variation->Height;
        }
        return $this->Product()->Height;
    }

    public function Depth()
    {
        if (($variation = $this->ProductVariation()) && $variation->Depth) {
            return $variation->Depth;
        }
        return $this->Product()->Depth;
    }

    public function Weight()
    {
        if (($variation = $this->ProductVariation()) && $variation->Weight) {
            return $variation->Weight;
        }
        return $this->Product()->Weight;
    }
}


<?php

namespace SilverShop\Checkout\Component;

use SilverShop\Model\Order;
use SilverShop\ShopUserInfo;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Security;
use SilverStripe\SiteConfig\SiteConfig;

abstract class Address extends CheckoutComponent
{
    protected $formfielddescriptions = true;

    protected $addresstype;

    protected $addtoaddressbook = false;

    public function getFormFields(Order $order)
    {
        return $this->getAddress($order)->getFrontEndFields(
            [
            'addfielddescriptions' => $this->formfielddescriptions,
            ]
        );
    }

    public function getRequiredFields(Order $order)
    {
        return $this->getAddress($order)->getRequiredFields();
    }

    public function validateData(Order $order, array $data)
    {
    }

    public function getData(Order $order)
    {
        $data = $this->getAddress($order)->toMap();

        //merge data from multiple sources
        $data = array_merge(
            ShopUserInfo::singleton()->getLocation(),
            $data,
            [$this->addresstype . 'AddressID' => $order->{$this->addresstype . 'AddressID'}]
        );

        //merge in default address if an address isn't available
        $member = Security::getCurrentUser();
        if (!$order->{$this->addresstype . 'AddressID'}) {
            $data = array_merge(
                ShopUserInfo::singleton()->getLocation(),
                $member ? $member->{'Default' . $this->addresstype . 'Address'}()->toMap() : array(),
                [$this->addresstype . 'AddressID' => $order->{$this->addresstype . 'AddressID'}]
            );
        }

        unset($data['ID']);
        unset($data['ClassName']);
        unset($data['RecordClassName']);

        //ensure country is restricted if there is only one allowed country
        if ($country = SiteConfig::current_site_config()->getSingleCountry()) {
            $data['Country'] = $country;
        }

        return $data;
    }

    /**
     * Create a new address if the existing address has changed, or is not yet
     * created.
     *
     * @param  Order $order order to get addresses from
     * @param  array $data  data to set
     * @throws \SilverStripe\ORM\ValidationException
     */
    public function setData(Order $order, array $data)
    {
        $address = $this->getAddress($order);
        //if the value matches the current address then unset
        //this is to fix issues with blank fields & the readonly Country field
        $addressFields = DataObject::getSchema()->databaseFields(\SilverShop\Model\Address::class);
        foreach ($data as $key => $value) {
            if (!isset($addressFields[$key]) || (!$value && !$address->{$key})) {
                unset($data[$key]);
            }
        }
        $address->update($data);
        //if only one country is available, then set it
        if ($country = SiteConfig::current_site_config()->getSingleCountry()) {
            $address->Country = $country;
        }
        //write new address, or duplicate if changed
        if (!$address->isInDB()) {
            $address->write();
        } elseif ($address->isChanged()) {
            $address = $address->duplicate();
        }
        //set billing address, if not already set
        $order->{$this->addresstype . 'AddressID'} = $address->ID;
        if (!$order->BillingAddressID) {
            $order->BillingAddressID = $address->ID;
        }
        $order->write();

        if ($this->addresstype === 'Shipping') {
            ShopUserInfo::singleton()->setAddress($address);

            $order->extend('onSetShippingAddress', $address);
        }

        //associate member to address
        if ($member = Security::getCurrentUser()) {
            $default = $member->{'Default' . $this->addresstype . 'Address'}();
            //set default address
            if (!$default->exists()) {
                $member->{'Default' . $this->addresstype . 'AddressID'} = $address->ID;
                $member->write();
            }
            if ($this->addtoaddressbook) {
                $member->AddressBook()->add($address);
            }
        }
        //extension hooks
        $order->extend('onSet' . $this->addresstype . 'Address', $address);
    }

    /**
     * Enable adding form field descriptions
     */
    public function setShowFormFieldDescriptions($show = true)
    {
        $this->formfielddescriptions = $show;
    }

    /**
     * Add new addresses to the address book.
     */
    public function setAddToAddressBook($add = true)
    {
        $this->addtoaddressbook = $add;
    }

    /**
     * @param Order $order
     * @return \SilverShop\Model\Address
     */
    public function getAddress(Order $order)
    {
        return $order->{$this->addresstype . 'Address'}();
    }
}


<?php

namespace SilverShop\Model;

use SilverShop\ORM\FieldType\ShopCountry;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\HasManyList;
use SilverStripe\Security\Member;
use SilverStripe\SiteConfig\SiteConfig;

/**
 * Address model using a generic format for storing international addresses.
 *
 * Typical Address Hierarcy:
 *    Continent
 *    Country
 *    State / Province / Territory (Island?)
 *    District / Suburb / County / City
 *        Code / Zip (may cross over the above)
 *    Street / Road - name + type: eg Gandalf Cresent
 *    (Premises/Building/Unit/Suite)
 *        (Floor/Level/Side/Wing)
 *    Number / Entrance / Room
 *    Person(s), Company, Department
 *
 * Collection of international address formats:
 *
 * @see http://bitboost.com/ref/international-address-formats.html
 *      xAL address standard:
 * @see https://www.oasis-open.org/committees/ciq/ciq.html#6
 *      Universal Postal Union addressing standards:
 * @see http://www.upu.int/nc/en/activities/addressing/standards.html
 *
 * @property ShopCountry $Country
 * @property string $State
 * @property string $City
 * @property string $PostalCode
 * @property string $Address
 * @property string $AddressLine2
 * @property string $Company
 * @property string $FirstName
 * @property string $Surname
 * @property string $Phone
 * @method   Member Member()
 * @method   Order[]|HasManyList ShippingAddressOrders()
 * @method   Order[]|HasManyList BillingAddressOrders()
 */
class Address extends DataObject
{
    private static $db = [
        'Country' => 'ShopCountry',
        //level1: Country = ISO 2-character country code
        'State' => 'Varchar(100)',
        //level2: Locality, Administrative Area, State, Province, Region, Territory, Island
        'City' => 'Varchar(100)',
        //level3: Dependent Locality, City, Suburb, County, District
        'PostalCode' => 'Varchar(20)',
        //code: ZipCode, PostCode (could cross above levels within a country)

        'Address' => 'Varchar(255)',
        //Number + type of thoroughfare/street. P.O. box
        'AddressLine2' => 'Varchar(255)',
        //Premises, Apartment, Building. Suite, Unit, Floor, Level, Side, Wing.

        'Company' => 'Varchar(100)',
        //Business, Organisation, Group, Institution.

        'FirstName' => 'Varchar(100)',
        //Individual, Person, Contact, Attention
        'Surname' => 'Varchar(100)',
        'Phone' => 'Varchar(100)',
    ];

    private static $has_one = [
        'Member' => Member::class,
    ];

    private static $has_many = [
        'ShippingAddressOrders' => Order::class . '.ShippingAddress',
        'BillingAddressOrders' => Order::class . '.BillingAddress'
    ];

    private static $casting = [
        'Country' => ShopCountry::class,
    ];

    private static $required_fields = [
        'Country',
        'State',
        'City',
        'Address',
    ];

    private static $summary_fields = [
        'toString' => 'Address',
    ];

    private static $table_name = 'SilverShop_Address';

    public function getCMSFields()
    {
        $self = $this;

        $this->beforeUpdateCMSFields(
            function (FieldList $fields) use ($self) {
                $fields->addFieldToTab(
                    'Root.Main',
                    $self->getCountryField(),
                    'State'
                );

                $fields->removeByName('MemberID');
            }
        );

        return parent::getCMSFields();
    }

    public function getFrontEndFields($params = null)
    {
        $fields = new FieldList(
            $this->getCountryField(),
            $addressfield = TextField::create('Address', $this->fieldLabel('Address')),
            $address2field =
                TextField::create('AddressLine2', $this->fieldLabel('AddressLine2')),
            $cityfield = TextField::create('City', $this->fieldLabel('City')),
            $statefield = TextField::create('State', $this->fieldLabel('State')),
            $postcodefield = TextField::create('PostalCode', $this->fieldLabel('PostalCode')),
            $phonefield = TextField::create('Phone', $this->fieldLabel('Phone'))
        );
        if (!empty($params['addfielddescriptions'])) {
            $addressfield->setDescription(
                _t(__CLASS__ . '.AddressHint', 'street / thoroughfare number, name, and type or P.O. Box')
            );
            $address2field->setDescription(
                _t(__CLASS__ . '.AddressLine2Hint', 'premises, building, apartment, unit, floor')
            );
            $cityfield->setDescription(_t(__CLASS__ . '.CityHint', 'or suburb, county, district'));
            $statefield->setDescription(_t(__CLASS__ . '.StateHint', 'or province, territory, island'));
        }

        $this->extend('updateFormFields', $fields);
        return $fields;
    }

    public function getCountryField()
    {
        $countries = SiteConfig::current_site_config()->getCountriesList();
        if (count($countries) == 1) {
            //field name is Country_readonly so it's value doesn't get updated
            return ReadonlyField::create(
                'Country_readonly',
                $this->fieldLabel('Country'),
                array_pop($countries)
            );
        }
        $field = DropdownField::create(
            'Country',
            $this->fieldLabel('Country'),
            $countries
        )->setHasEmptyDefault(true);

        $this->extend('updateCountryField', $field);

        return $field;
    }

    /**
     * Get an array of data fields that must be populated for model to be valid.
     * Required fields can be customised via self::$required_fields
     */
    public function getRequiredFields()
    {
        $fields = self::config()->required_fields;
        //hack to allow overriding arrays in ss config
        if (self::$required_fields != $fields) {
            foreach (self::$required_fields as $requirement) {
                if (($key = array_search($requirement, $fields)) !== false) {
                    unset($fields[$key]);
                }
            }
        }
        //set nicer keys for easier processing
        $fields = array_combine($fields, $fields);
        $this->extend('updateRequiredFields', $fields);
        //don't require country if shop config only specifies a single country
        if (isset($fields['Country']) && SiteConfig::current_site_config()->getSingleCountry()) {
            unset($fields['Country']);
        }

        return $fields;
    }

    /**
     * Get full name associated with this Address
     */
    public function getName()
    {
        return implode(
            ' ',
            array_filter(
                [
                    $this->FirstName,
                    $this->Surname,
                ]
            )
        );
    }

    /**
     * Convert address to a single string.
     */
    public function toString($separator = ', ')
    {
        $fields = array(
            $this->Company,
            $this->getName(),
            $this->Address,
            $this->AddressLine2,
            $this->City,
            $this->State,
            $this->PostalCode,
            $this->Country
        );
        $this->extend('updateToString', $fields);
        return implode($separator, array_filter($fields));
    }

    public function getTitle()
    {
        return $this->toString();
    }

    public function forTemplate()
    {
        return $this->renderWith(__CLASS__);
    }

    /**
     * Add alias setters for fields which are synonymous
     *
     * @param  string $val
     * @return $this
     */
    public function setProvince($val)
    {
        $this->State = $val;
        return $this;
    }

    public function setTerritory($val)
    {
        $this->State = $val;
        return $this;
    }

    public function setIsland($val)
    {
        $this->State = $val;
        return $this;
    }

    public function setPostCode($val)
    {
        $this->PostalCode = $val;
        return $this;
    }

    public function setZipCode($val)
    {
        $this->PostalCode = $val;
        return $this;
    }

    public function setStreet($val)
    {
        $this->Address = $val;
        return $this;
    }

    public function setStreet2($val)
    {
        $this->AddressLine2 = $val;
        return $this;
    }

    public function setAddress2($val)
    {
        $this->AddressLine2 = $val;
        return $this;
    }

    public function setInstitution($val)
    {
        $this->Company = $val;
        return $this;
    }

    public function setBusiness($val)
    {
        $this->Company = $val;
        return $this;
    }

    public function setOrganisation($val)
    {
        $this->Company = $val;
        return $this;
    }

    public function setOrganization($val)
    {
        $this->Company = $val;
        return $this;
    }

    function validate()
    {
        $result = parent::validate();

        foreach ($this->getRequiredFields() as $requirement) {
            if (empty($this->$requirement)) {
                $result->addError("Address Model validate function - missing required field: $requirement");
            }
        }

        return $result;
    }
}


<?php

namespace SilverShop;

use SilverShop\Model\Address;
use SilverStripe\Core\Injector\Injectable;

/**
 * Collects and stores data about the user. Keep this data in session.
 */
class ShopUserInfo
{
    use Injectable;

    /**
     * Get an array of location data
     *
     * @return array location data
     */
    public function getLocation()
    {
        return $this->getLocationData();
    }

    public function setLocation(array $location)
    {
        $this->setLocationData($location);

        return $this;
    }

    /**
     * Get location of user
     *
     * @param  Address $address location
     * @return null|Address
     */
    public function getAddress()
    {
        $address = null;
        if ($data = $this->getLocationData()) {
            $address = Address::create();
            $address->update($data);
            $address->ID = 0; //ensure not in db
        }

        return $address;
    }

    /**
     * Set location of user
     *
     * @param  Address $address location
     * @return $this
     */
    public function setAddress(Address $address)
    {
        $this->setLocationData($address->toMap());

        return $this;
    }

    protected function getLocationData()
    {
        $data = ShopTools::getSession()->get('UserInfo.Location');
        return is_array($data) ? $data : [];
    }

    protected function setLocationData(array $data)
    {
        ShopTools::getSession()->set('UserInfo.Location', $data);
    }
}


<?php

namespace SilverShop\Page;

use PageController;
use SilverShop\Extension\ViewableCartExtension;
use SilverShop\Forms\CartForm;

/**
 * Class CartPageController
 *
 * @mixin ViewableCartExtension
 */
class CartPageController extends PageController
{
    private static $url_segment = 'cart';

    private static $allowed_actions = [
        'CartForm',
        'updatecart',
    ];

    /**
     * Display a title if there is no model, or no title.
     */
    public function Title()
    {
        if ($this->getFailover()->Title) {
            return $this->getFailover()->Title;
        }
        return _t('SilverShop\Page\CartPage.DefaultTitle', 'Shopping Cart');
    }

    /**
     * A form for updating cart items
     */
    public function CartForm()
    {
        $cart = $this->Cart();
        if (!$cart) {
            return false;
        }
        $form = CartForm::create($this, 'CartForm', $cart);

        $this->extend('updateCartForm', $form);

        return $form;
    }
}


<?php

namespace SilverShop\Model\Modifiers\Shipping;

/**
 * Calculates the shipping cost of an order, by taking the products
 * and calculating the shipping weight, based on an array set in _config
 *
 * ASSUMPTION: The total order weight can be at maximum the last item
 * in the $shippingCosts array.
 */
class Weight extends Base
{
    /**
     * Weight to price mapping.
     * Should be an associative array, with the weight as key (KG) and the corresponding price as value.
     * Can be set via Config API, eg.
     * <code>
     * WeightShippingModifier:
     *   weight_cost:
     *     '0.5': 12
     *     '1.0': 15
     *     '2.0': 20
     * </code>
     *
     * @config
     * @var    array
     */
    private static $weight_cost = [];

    protected $weight = 0;

    /**
     * Calculates shipping cost based on Product Weight.
     */
    public function value($subtotal = 0)
    {
        $totalWeight = $this->Weight();
        if (!$totalWeight) {
            return $this->Amount = 0;
        }
        $amount = 0;

        $table = $this->config()->weight_cost;
        if (!empty($table) && is_array($table)) {
            // ensure table is sorted
            ksort($table, SORT_NUMERIC);
            // set the amount to the highest value. In case the weight is higher than the max value in
            // the table, use the highest shipping cost and not 0!
            $amount = end($table);
            reset($table);
            foreach ($table as $weight => $cost) {
                if ($totalWeight <= $weight) {
                    $amount = $cost;
                    break;
                }
            }
        }
        return $this->Amount = $amount;
    }

    public function TableTitle()
    {
        return _t(
            __CLASS__ . '.TableTitle',
            'Shipping ({Kilograms} kg)',
            '',
            ['Kilograms' => $this->Weight()]
        );
    }

    /**
     * Calculate the total weight of the order
     *
     * @return number
     */
    public function Weight()
    {
        if ($this->weight) {
            return $this->weight;
        }
        $weight = 0;
        $order = $this->Order();
        if ($order && $orderItems = $order->Items()) {
            foreach ($orderItems as $orderItem) {
                if ($product = $orderItem->Product()) {
                    $weight = $weight + ($product->Weight * $orderItem->Quantity);
                }
            }
        }
        return $this->weight = $weight;
    }
}


<?php

namespace SilverShop\Forms;

use Exception;
use SilverShop\Cart\ShoppingCart;
use SilverShop\Cart\ShoppingCartController;
use SilverShop\Page\Product;
use SilverShop\ShopTools;
use SilverStripe\Core\Convert;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\HiddenField;
use SilverStripe\ORM\Queries\SQLSelect;

/**
 * @package shop
 */
class VariationForm extends AddProductForm
{
    /**
     * @config
     * @var bool
     */
    private static $include_json = true;

    protected $requiredFields = ['Quantity'];

    public function __construct($controller, $name = 'VariationForm')
    {
        parent::__construct($controller, $name);
        $this->extend('updateVariationForm');
    }

    /**
     * Adds a given product to the cart. If a hidden field is passed
     * (ValidateVariant) then simply a validation of the user including that
     * product is done and the users cart isn't actually changed.
     *
     * @param  array $data
     * @param  Form  $form
     * @return mixed
     */
    public function addtocart($data, $form)
    {
        if ($variation = $this->getBuyable($data)) {
            $quantity = (isset($data['Quantity']) && is_numeric($data['Quantity'])) ? (int)$data['Quantity'] : 1;
            $cart = ShoppingCart::singleton();
            $request = $this->getRequest();

            $order = $cart->current();
            if ($request && $request->isAjax() && $order) {
                ShopTools::install_locale($order->Locale);
            }

            // if we are in just doing a validation step then check
            if ($this->getRequest()->requestVar('ValidateVariant')) {
                $message = '';
                $success = false;

                try {
                    $success = $variation->canPurchase(null, $data['Quantity']);
                } catch (Exception $e) {
                    $message = get_class($e);
                    // added hook to update message
                    $this->extend('updateVariationAddToCartMessage', $e, $message, $variation);
                }

                $ret = [
                    'Message' => $message,
                    'Success' => $success,
                    'Price' => $variation->dbObject('Price')->TrimCents(),
                ];

                $this->extend('updateVariationAddToCartAjax', $ret, $variation, $form);

                return json_encode($ret);
            }

            $saveabledata = (!empty($this->saveablefields)) ? Convert::raw2sql(
                array_intersect_key($data, array_combine($this->saveablefields, $this->saveablefields))
            ) : $data;

            if ($cart->add($variation, $quantity, $saveabledata)) {
                $form->sessionMessage(
                    _t('SilverShop\Cart\ShoppingCart.ItemAdded', 'Item has been added successfully.'),
                    'good'
                );
            } else {
                $form->sessionMessage($cart->getMessage(), $cart->getMessageType());
            }
        } else {
            $variation = null;
            $form->sessionMessage(
                _t(__CLASS__ . '.VariationNotAvailable', 'That variation is not available, sorry.'),
                'bad'
            ); //validation fail
        }

        $this->extend('updateVariationAddToCart', $form, $variation);

        $this->extend('updateVariationFormResponse', $request, $response, $variation, $quantity, $form);
        return $response ? $response : ShoppingCartController::direct();
    }

    public function getBuyable($data = null)
    {
        if (isset($data['ProductAttributes'])
            && $variation = $this->getController()->getVariationByAttributes($data['ProductAttributes'])
        ) {
            return $variation;
        }

        return null;
    }

    protected function getFormFields($controller = null)
    {
        $fields = parent::getFormFields($controller);

        if (!$controller) {
            return $fields;
        }
        $product = $controller->data();
        $attributes = $product->VariationAttributeTypes();

        foreach ($attributes as $attribute) {
            $attributeDropdown = $attribute->getDropDownField(
                _t(
                    __CLASS__ . '.ChooseAttribute',
                    'Choose {attribute} …',
                    '',
                    ['attribute' => $attribute->Label]
                ),
                $product->possibleValuesForAttributeType($attribute)
            );

            if ($attributeDropdown) {
                $fields->push($attributeDropdown);
                $this->requiredFields[] = "ProductAttributes[$attribute->ID]";
            }
        }

        if ($this->config()->include_json) {
            $vararray = [];

            $query = $query2 = new SQLSelect();

            $query->setSelect('ID')
                ->setFrom('SilverShop_Variation')
                ->addWhere(['"ProductID" = ?' => $product->ID]);

            if (!Product::config()->allow_zero_price) {
                $query->addWhere('"Price" > 0');
            }

            foreach ($query->execute()->column('ID') as $variationID) {
                $query2->setSelect('SilverShop_AttributeValueID')
                    ->setFrom('SilverShop_Variation_AttributeValues')
                    ->setWhere(array('SilverShop_VariationID' => $variationID));
                $vararray[$variationID] = $query2->execute()->keyedColumn();
            }

            $fields->push(
                HiddenField::create(
                    'VariationOptions',
                    'VariationOptions',
                    json_encode($vararray)
                )
            );
        }

        return $fields;
    }

    protected function getFormValidator()
    {
        return VariationFormValidator::create(
            array_unique($this->requiredFields)
        );
    }
}


<?php

namespace SilverShop\Checkout\Component;

use SilverShop\Model\Order;

/**
 * Proxy class to handle namespacing field names for checkout components
 */
class CheckoutComponentNamespaced extends CheckoutComponent
{
    /**
     * @var CheckoutComponent
     */
    protected $proxy;

    public function __construct(CheckoutComponent $component)
    {
        $this->proxy = $component;
    }

    /**
     * @return CheckoutComponent
     */
    public function Proxy()
    {
        return $this->proxy;
    }

    public function getFormFields(Order $order)
    {
        $fields = $this->proxy->getFormFields($order);
        $allFields = $fields->dataFields();
        if ($allFields) {
            foreach ($allFields as $field) {
                $field->setName($this->namespaceFieldName($field->getName()));
            }
        }

        return $fields;
    }

    public function validateData(Order $order, array $data)
    {
        return $this->proxy->validateData($order, $this->unnamespaceData($data));
    }

    public function getData(Order $order)
    {
        return $this->namespaceData($this->proxy->getData($order));
    }

    public function setData(Order $order, array $data)
    {
        return $this->proxy->setData($order, $this->unnamespaceData($data));
    }

    public function getRequiredFields(Order $order)
    {
        $fields = $this->proxy->getRequiredFields($order);
        $namespaced = array();
        foreach ($fields as $field) {
            $namespaced[] = $this->namespaceFieldName($field);
        }
        return $namespaced;
    }

    public function dependsOn()
    {
        return $this->proxy->dependsOn();
    }

    public function name()
    {
        return $this->proxy->name();
    }

    public function providesPaymentData()
    {
        return $this->proxy->providesPaymentData();
    }

    //namespacing functions

    public function namespaceData(array $data)
    {
        $newdata = array();
        foreach ($data as $key => $value) {
            $newdata[$this->namespaceFieldName($key)] = $value;
        }
        return $newdata;
    }

    public function unnamespaceData(array $data)
    {
        $newdata = array();
        foreach ($data as $key => $value) {
            if (strpos($key, $this->name()) === 0) {
                $newdata[$this->unnamespaceFieldName($key)] = $value;
            }
        }
        return $newdata;
    }

    public function namespaceFieldName($name)
    {
        return $this->name() . "_" . $name;
    }

    public function unnamespaceFieldName($name)
    {
        return substr($name, strlen($this->name() . "_"));
    }
}


<?php

namespace SilverShop\ORM;

use SilverStripe\ORM\HasManyList;

/**
 * Additional functions for Item lists.
 */
class OrderItemList extends HasManyList
{
    public function Quantity()
    {
        return $this->Sum('Quantity');
    }

    public function Plural()
    {
        return $this->Quantity() > 1;
    }

    /**
     * Sums up all of desired field for items, and multiply by quantity.
     * Optionally sum product field instead.
     *
     * @param string  $field     - field to sum
     * @param boolean $onproduct - sum from product or not
     *
     * @return number - sum total of field
     */
    public function Sum($field, $onproduct = false)
    {
        $total = 0;
        foreach ($this->getIterator() as $item) {
            $quantity = ($field === 'Quantity') ? 1 : $item->Quantity;
            if (!$onproduct) {
                $total += $item->$field * $quantity;
            } elseif ($item->hasMethod($field)) {
                $total += $item->$field() * $quantity;
            } elseif ($product = $item->Product()) {
                $total += $product->$field * $quantity;
            }
        }
        return $total;
    }

    /**
     * Add up the totals of all the order items in this list.
     */
    public function SubTotal()
    {
        $result = 0;
        foreach ($this->getIterator() as $item) {
            $result += $item->Total();
        }

        return $result;
    }
}


<?php

namespace SilverShop\Model\Variation;

use SilverShop\Cart\ShoppingCart;
use SilverShop\Model\Buyable;
use SilverShop\Model\Order;
use SilverShop\Page\Product;
use SilverStripe\AssetAdmin\Forms\UploadField;
use SilverStripe\Assets\Image;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBCurrency;
use SilverStripe\ORM\FieldType\DBDecimal;
use SilverStripe\ORM\ManyManyList;
use SilverStripe\Versioned\Versioned;

/**
 * Product Variation
 *
 * Provides a means for specifying many variations on a product.
 * Used in combination with ProductAttributes, such as color, size.
 * A variation will specify one particular combination, such as red, and large.
 *
 * @property string $InternalItemID
 * @property DBCurrency $Price
 * @property DBDecimal $Weight
 * @property DBDecimal $Height
 * @property DBDecimal $Width
 * @property DBDecimal $Depth
 * @property int $ProductID
 * @property int $ImageID
 * @method   Product Product()
 * @method   Image Image()
 * @method   AttributeValue[]|ManyManyList AttributeValues()
 */
class Variation extends DataObject implements Buyable
{
    private static $db = [
        'InternalItemID' => 'Varchar(30)',
        'Price' => 'Currency(19,4)',

        //physical properties
        //TODO: Move to an extension
        'Weight' => 'Decimal(12,5)',
        'Height' => 'Decimal(12,5)',
        'Width' => 'Decimal(12,5)',
        'Depth' => 'Decimal(12,5)'
    ];

    private static $has_one = [
        'Product' => Product::class,
        'Image' => Image::class
    ];

    private static $owns = [
        'Image'
    ];

    private static $many_many = [
        'AttributeValues' => AttributeValue::class
    ];

    private static $casting = [
        'Title' => 'Text',
        'Price' => 'Currency'
    ];

    private static $versioning = [
        'Live'
    ];

    private static $extensions = [
        Versioned::class . '.versioned'
    ];

    private static $summary_fields = [
        'InternalItemID' => 'Product Code',
        //'Product.Title' => 'Product',
        'Title' => 'Variation',
        'Price' => 'Price'
    ];

    private static $searchable_fields = [
        'Product.Title',
        'InternalItemID'
    ];

    private static $indexes = [
        'InternalItemID' => true,
        'LastEdited' => true
    ];

    private static $singular_name = 'Variation';

    private static $plural_name = 'Variations';

    private static $default_sort = 'InternalItemID';

    private static $order_item = OrderItem::class;

    private static $table_name = 'SilverShop_Variation';

    /**
     * @config
     * @var bool
     */
    private static $title_has_label = true;

    /**
     * @config
     * @var string
     */
    private static $title_separator = ':';

    /**
     * @config
     * @var string
     */
    private static $title_glue = ', ';

    public function getCMSFields()
    {
        $fields = FieldList::create(
            TextField::create('InternalItemID', _t('SilverShop\Page\Product.Code', 'Product Code')),
            TextField::create('Price', _t('SilverShop\Page\Product.db_BasePrice', 'Price'))
        );
        //add attributes dropdowns
        $attributes = $this->Product()->VariationAttributeTypes();
        if ($attributes->exists()) {
            foreach ($attributes as $attribute) {
                if ($field = $attribute->getDropDownField()) {
                    if ($value = $this->AttributeValues()->find('TypeID', $attribute->ID)) {
                        $field->setValue($value->ID);
                    }
                    $fields->push($field);
                } else {
                    $fields->push(
                        LiteralField::create(
                            'novalues' . $attribute->Name,
                            '
' .
                            _t(
                                __CLASS__ . '.NoAttributeValuesMessage',
                                '{attribute} has no values to choose from. You can create them in the "Products" > "Product Attribute Type" section of the CMS.',
                                'Warning that will be shown if an attribute doesn\'t have any values',
                                ['attribute' => $attribute->Name]
                            ) .
                            '
'
                        )
                    );
                }
                //TODO: allow setting custom values here, rather than visiting the products section
            }
        } else {
            $fields->push(
                LiteralField::create(
                    'savefirst',
                    '
' .
                    _t(
                        __CLASS__ . '.MustSaveFirstMessage',
                        'You can choose variation attributes after saving for the first time, if they exist.'
                    ) .
                    '
'
                )
            );
        }
        $fields->push(
            UploadField::create('Image', _t('SilverShop\Page\Product.Image', 'Product Image'))
        );

        //physical measurement units
        $fieldSubstitutes = [
            'LengthUnit' => Product::config()->length_unit
        ];

        //physical measurements
        $fields->push(
            TextField::create(
                'Weight',
                _t(
                    'SilverShop\Page\Product.WeightWithUnit',
                    'Weight ({WeightUnit})',
                    '',
                    [
                        'WeightUnit' => Product::config()->weight_unit
                    ]
                ),
                '',
                12
            )
        );

        $fields->push(
            TextField::create(
                'Height',
                _t('SilverShop\Page\Product.HeightWithUnit', 'Height ({LengthUnit})', '', $fieldSubstitutes),
                '',
                12
            )
        );

        $fields->push(
            TextField::create(
                'Width',
                _t('SilverShop\Page\Product.WidthWithUnit', 'Width ({LengthUnit})', '', $fieldSubstitutes),
                '',
                12
            )
        );

        $fields->push(
            TextField::create(
                'Depth',
                _t('SilverShop\Page\Product.DepthWithUnit', 'Depth ({LengthUnit})', '', $fieldSubstitutes),
                '',
                12
            )
        );

        $this->extend('updateCMSFields', $fields);

        return $fields;
    }

    /**
     * Save selected attributes - somewhat of a hack.
     */
    public function onBeforeWrite()
    {
        parent::onBeforeWrite();

        if (isset($_POST['ProductAttributes']) && is_array($_POST['ProductAttributes'])) {
            $this->AttributeValues()->setByIDList(array_values($_POST['ProductAttributes']));
        }

        $img = $this->Image();

        if ($img && $img->exists()) {
            $img->doPublish();
        }
    }

    public function getTitle()
    {
        $values = $this->AttributeValues();
        if ($values->exists()) {
            $labelvalues = array();
            foreach ($values as $value) {
                if (self::config()->title_has_label) {
                    $labelvalues[] = $value->Type()->Label . self::config()->title_separator . $value->Value;
                } else {
                    $labelvalues[] = $value->Value;
                }
            }

            $title = implode(self::config()->title_glue, $labelvalues);
        }
        $this->extend('updateTitle', $title);

        return $title;
    }

    public function getCategoryIDs()
    {
        return $this->Product() ? $this->Product()->getCategoryIDs() : array();
    }

    public function getCategories()
    {
        return $this->Product() ? $this->Product()->getCategories() : ArrayList::create();
    }

    public function canPurchase($member = null, $quantity = 1)
    {
        $allowpurchase = false;
        if ($product = $this->Product()) {
            $allowpurchase =
                ($this->sellingPrice() > 0 || Product::config()->allow_zero_price) && $product->AllowPurchase;
        }

        $permissions = $this->extend('canPurchase', $member, $quantity);
        $permissions[] = $allowpurchase;
        return min($permissions);
    }

    /*
     * Returns if the product variation is already in the shopping cart.
     * @return boolean
     */
    public function IsInCart()
    {
        return $this->Item() && $this->Item()->Quantity > 0;
    }

    /*
     * Returns the order item which contains the product variation
     * @return  OrderItem
     */
    public function Item()
    {
        $filter = array();
        $this->extend('updateItemFilter', $filter);
        $item = ShoppingCart::singleton()->get($this, $filter);
        if (!$item) {
            //return dummy item so that we can still make use of Item
            $item = $this->createItem(0);
        }
        $this->extend('updateDummyItem', $item);
        return $item;
    }

    public function addLink()
    {
        return $this->Item()->addLink($this->ProductID, $this->ID);
    }

    public function createItem($quantity = 1, $filter = array())
    {
        $orderitem = self::config()->order_item;
        $item = new $orderitem();
        $item->ProductID = $this->ProductID;
        $item->ProductVariationID = $this->ID;
        //$item->ProductVariationVersion = $this->Version;
        if ($filter) {
            //TODO: make this a bit safer, perhaps intersect with allowed fields
            $item->update($filter);
        }
        $item->Quantity = $quantity;
        return $item;
    }

    public function sellingPrice()
    {
        $price = $this->Price;
        $this->extend('updateSellingPrice', $price);

        //prevent negative values
        $price = $price < 0 ? 0 : $price;

        // NOTE: Ideally, this would be dependent on the locale but as of
        // now the Silverstripe Currency field type has 2 hardcoded all over
        // the place. In the mean time there is an issue where the displayed
        // unit price can not exactly equal the multiplied price on an order
        // (i.e. if the calculated price is 3.145 it will display as 3.15.
        // so if I put 10 of them in my cart I will expect the price to be
        // 31.50 not 31.45).
        return round($price, Order::config()->rounding_precision);
    }
}


<?php

namespace SilverShop\Page;

use PageController;
use SilverShop\Cart\ShoppingCart;
use SilverShop\Checkout\CheckoutComponentConfig;
use SilverShop\Checkout\Component\OnsitePayment;
use SilverShop\Checkout\SinglePageCheckoutComponentConfig;
use SilverShop\Checkout\Step\Address;
use SilverShop\Checkout\Step\AddressBook;
use SilverShop\Checkout\Step\ContactDetails;
use SilverShop\Checkout\Step\Membership;
use SilverShop\Checkout\Step\PaymentMethod;
use SilverShop\Checkout\Step\Summary;
use SilverShop\Extension\SteppedCheckoutExtension;
use SilverShop\Extension\ViewableCartExtension;
use SilverShop\Forms\PaymentForm;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\FormAction;
use SilverStripe\Omnipay\Model\Message\GatewayErrorMessage;

/**
 * @package shop
 * @mixin CheckoutPage
 * @mixin SteppedCheckoutExtension
 * @mixin Address
 * @mixin AddressBook
 * @mixin ContactDetails
 * @mixin Membership
 * @mixin PaymentMethod
 * @mixin Summary
 * @mixin ViewableCartExtension
 */
class CheckoutPageController extends PageController
{
    private static $url_segment     = 'checkout';

    private static $allowed_actions = array(
        'OrderForm',
        'payment',
        'PaymentForm',
    );

    public function Title()
    {
        if ($this->failover && $this->failover->Title) {
            return $this->failover->Title;
        }

        return _t('SilverShop\Page\CheckoutPage.DefaultTitle', "Checkout");
    }

    public function OrderForm()
    {
        if (!(bool)$this->Cart()) {
            return false;
        }

        /**
         * @var CheckoutComponentConfig $config
         */
        $config = SinglePageCheckoutComponentConfig::create(ShoppingCart::curr());
        $form = PaymentForm::create($this, 'OrderForm', $config);

        // Normally, the payment is on a second page, either offsite or through /checkout/payment
        // If the site has customised the checkout component config to include an onsite payment
        // component, we should honor that and change the button label. PaymentForm::checkoutSubmit
        // will also check this and process payment if needed.
        if ($config->hasComponentWithPaymentData()) {
            $form->setActions(
                FieldList::create(
                    FormAction::create('checkoutSubmit', _t('SilverShop\Page\CheckoutPage.SubmitPayment', 'Submit Payment'))
                )
            );
        }

        $form->Cart = $this->Cart();
        $this->extend('updateOrderForm', $form);

        return $form;
    }

    /**
     * Action for making on-site payments
     */
    public function payment()
    {
        if (!$this->Cart()) {
            return $this->redirect($this->Link());
        }

        return array(
            'Title'     => 'Make Payment',
            'OrderForm' => $this->PaymentForm(),
        );
    }

    public function PaymentForm()
    {
        if (!(bool)$this->Cart()) {
            return false;
        }

        $config = CheckoutComponentConfig::create(ShoppingCart::curr(), false);
        $config->addComponent(OnsitePayment::create());

        $form = PaymentForm::create($this, "PaymentForm", $config);

        $form->setActions(
            FieldList::create(
                FormAction::create("submitpayment", _t('SilverShop\Page\CheckoutPage.SubmitPayment', "Submit Payment"))
            )
        );

        $form->setFailureLink($this->Link());
        $this->extend('updatePaymentForm', $form);

        return $form;
    }

    /**
     * Retrieves error messages for the latest payment (if existing).
     * This can originate e.g. from an earlier offsite gateway API response.
     *
     * @return string
     */
    public function PaymentErrorMessage()
    {
        $order = $this->Cart();
        if (!$order) {
            return false;
        }

        $lastPayment = $order->Payments()->sort('Created', 'DESC')->first();
        if (!$lastPayment) {
            return false;
        }

        $errorMessages = $lastPayment->Messages()->exclude('Message', '')->sort('Created', 'DESC');
        $lastErrorMessage = null;
        foreach ($errorMessages as $errorMessage) {
            if ($errorMessage instanceof GatewayErrorMessage) {
                $lastErrorMessage = $errorMessage;
                break;
            }
        }
        if (!$lastErrorMessage) {
            return false;
        }

        return $lastErrorMessage->Message;
    }

    /**
     * Override viewer to get correct template for first step
     *
     * {@inheritDoc}
     * @see \SilverStripe\CMS\Controllers\ContentController::getViewer()
     */
    public function getViewer($action)
    {
        if (CheckoutPage::config()->first_step && $action == 'index') {
            $action = CheckoutPage::config()->first_step;
        }
        return parent::getViewer($action);
    }
}


<?php

namespace SilverShop\Model\Product;

use SilverShop\Page\Product;
use SilverStripe\Versioned\Versioned;

/**
 * Product - Order Item
 * Connects a product to an orde.
 *
 * @property int $ProductVersion
 * @property int $ProductID
 */
class OrderItem extends \SilverShop\Model\OrderItem
{
    private static $db = [
        'ProductVersion' => 'Int',
    ];

    private static $has_one = [
        'Product' => Product::class,
    ];

    private static $table_name = 'SilverShop_Product_OrderItem';

    /**
     * the has_one join field to identify the buyable
     */
    private static $buyable_relationship = 'Product';

    /**
     * Get related product
     *  - live version if in cart, or
     *  - historical version if order is placed
     *
     * @param boolean $forcecurrent - force getting latest version of the product.
     *
     * @return Product
     */
    public function Product($forcecurrent = false)
    {
        //TODO: this might need some unit testing to make sure it compliles with comment description
        //ie use live if in cart (however I see no logic for checking cart status)
        if ($this->ProductID && $this->ProductVersion && !$forcecurrent) {
            return Versioned::get_version(Product::class, $this->ProductID, $this->ProductVersion);
        } elseif ($this->ProductID
            && $product = Versioned::get_one_by_stage(
                Product::class,
                'Live',
                '"SilverShop_Product"."ID"  = ' . $this->ProductID
            )
        ) {
            return $product;
        }
        return null;
    }

    public function onPlacement()
    {
        parent::onPlacement();
        if ($product = $this->Product(true)) {
            $this->ProductVersion = $product->Version;
        }
    }

    public function TableTitle()
    {
        $product = $this->Product();
        $tabletitle = ($product) ? $product->Title : $this->i18n_singular_name();
        $this->extend('updateTableTitle', $tabletitle);
        return $tabletitle;
    }

    public function Link()
    {
        if ($product = $this->Product()) {
            return $product->Link();
        }
    }
}


<?php

namespace SilverShop\Extension;

use SilverShop\Forms\VariationForm;
use SilverShop\Model\Variation\AttributeType;
use SilverShop\Model\Variation\AttributeValue;
use SilverShop\Model\Variation\Variation;
use SilverShop\ORM\FieldType\ShopCurrency;
use SilverShop\Page\Product;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
use SilverStripe\Forms\LabelField;
use SilverStripe\Forms\ListboxField;
use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\DataList;
use SilverStripe\Versioned\Versioned;
use SilverStripe\View\ArrayData;

/**
 * Adds extra fields and relationships to Products for variations support.
 *
 * @package    silvershop
 * @subpackage variations
 */
class ProductVariationsExtension extends DataExtension
{
    private static $has_many = [
        'Variations' => Variation::class,
    ];

    private static $many_many = [
        'VariationAttributeTypes' => AttributeType::class,
    ];

    /**
     * Adds variations specific fields to the CMS.
     */
    public function updateCMSFields(FieldList $fields)
    {
        $fields->addFieldsToTab('Root.Variations', [
            ListboxField::create(
                'VariationAttributeTypes',
                _t(__CLASS__ . '.Attributes', "Attributes"),
                AttributeType::get()->map('ID', 'Title')->toArray()
            )
                ->setDescription(_t(
                    __CLASS__ . '.AttributesDescription',
                    'These are fields to indicate the way(s) each variation varies. Once selected, they can be edited on each variation.'
                )),
            GridField::create(
                'Variations',
                _t(__CLASS__ . '.Variations', 'Variations'),
                $this->owner->Variations(),
                GridFieldConfig_RecordEditor::create()
            )
        ]);
        if ($this->owner->Variations()->exists()) {
            $fields->addFieldToTab(
                'Root.Pricing',
                LabelField::create(
                    'variationspriceinstructinos',
                    _t(
                        __CLASS__ . '.VariationsInfo',
                        'Price - Because you have one or more variations, the price can be set in the "Variations" tab.'
                    )
                )
            );
            $fields->removeFieldFromTab('Root.Pricing', 'BasePrice');
            $fields->removeFieldFromTab('Root.Main', 'InternalItemID');
        }
    }

    public function PriceRange()
    {
        $variations = $this->owner->Variations();

        if (!Product::config()->allow_zero_price) {
            $variations = $variations->filter('Price:GreaterThan', 0);
        }

        if (!$variations->exists() || !$variations->Count()) {
            return null;
        }

        $prices = $variations->map('ID', 'SellingPrice')->toArray();
        $pricedata = array(
            'HasRange' => false,
            'Max' => ShopCurrency::create(),
            'Min' => ShopCurrency::create(),
            'Average' => ShopCurrency::create(),
        );
        $count = count($prices);
        $sum = array_sum($prices);
        $maxprice = max($prices);
        $minprice = min($prices);
        $pricedata['HasRange'] = ($minprice != $maxprice);
        $pricedata['Max']->setValue($maxprice);
        $pricedata['Min']->setValue($minprice);
        if ($count > 0) {
            $pricedata['Average']->setValue($sum / $count);
        }

        return ArrayData::create($pricedata);
    }

    /**
     * Pass an array of attribute ids to query for the appropriate variation.
     *
     * @param array $attributes
     *
     * @return Variation|null
     */
    public function getVariationByAttributes(array $attributes)
    {
        if (!is_array($attributes)) {
            return null;
        }

        $attrs = array_filter(array_values($attributes));
        $set = Variation::get()->filter('ProductID', $this->owner->ID);

        foreach ($attrs as $i => $valueid) {
            $alias = "A$i";
            $set = $set->innerJoin(
                'SilverShop_Variation_AttributeValues',
                "\"SilverShop_Variation\".\"ID\" = \"$alias\".\"SilverShop_VariationID\"",
                $alias
            )->where(["\"$alias\".\"SilverShop_AttributeValueID\" = ?" => $valueid]);
        }

        return $set->first();
    }

    /**
     * Generates variations based on selected attributes.
     *
     * @param  AttributeType $attributetype
     * @param  array         $values
     * @throws \SilverStripe\ORM\ValidationException
     */
    public function generateVariationsFromAttributes(AttributeType $attributetype, array $values)
    {
        //TODO: introduce transactions here, in case objects get half made etc
        //if product has variation attribute types
        if (!empty($values)) {
            //TODO: get values dataobject set
            $avalues = $attributetype->convertArrayToValues($values);
            $existingvariations = $this->owner->Variations();
            if ($existingvariations->exists()) {
                //delete old variation, and create new ones - to prevent modification of exising variations
                foreach ($existingvariations as $oldvariation) {
                    $oldvalues = $oldvariation->AttributeValues();
                    foreach ($avalues as $value) {
                        $newvariation = $oldvariation->duplicate();
                        $newvariation->InternalItemID = $this->owner->InternalItemID . '-' . $newvariation->ID;
                        $newvariation->AttributeValues()->addMany($oldvalues);
                        $newvariation->AttributeValues()->add($value);
                        $newvariation->write();
                        $existingvariations->add($newvariation);
                    }
                    $existingvariations->remove($oldvariation);
                    $oldvariation->AttributeValues()->removeAll();
                    $oldvariation->delete();
                    $oldvariation->destroy();
                    //TODO: check that old variations actually stick around, as they will be needed for past orders etc
                }
            } else {
                foreach ($avalues as $value) {
                    $variation = Variation::create();
                    $variation->ProductID = $this->owner->ID;
                    $variation->Price = $this->owner->BasePrice;
                    $variation->write();
                    $variation->InternalItemID = $this->owner->InternalItemID . '-' . $variation->ID;
                    $variation->AttributeValues()->add($value);
                    $variation->write();
                    $existingvariations->add($variation);
                }
            }
        }
    }

    /**
     * Get all the {@link ProductAttributeValue} for a given attribute type,
     * based on this product's variations.
     *
     * @return DataList
     */
    public function possibleValuesForAttributeType($type)
    {
        if (!is_numeric($type)) {
            $type = $type->ID;
        }

        if (!$type) {
            return null;
        }

        $list = AttributeValue::get()
            ->innerJoin(
                'SilverShop_Variation_AttributeValues',
                '"SilverShop_AttributeValue"."ID" = "SilverShop_Variation_AttributeValues"."SilverShop_AttributeValueID"'
            )->innerJoin(
                'SilverShop_Variation',
                '"SilverShop_Variation_AttributeValues"."SilverShop_VariationID" = "SilverShop_Variation"."ID"'
            )->where("TypeID = $type AND \"SilverShop_Variation\".\"ProductID\" = " . $this->owner->ID);

        if (!Product::config()->allow_zero_price) {
            $list = $list->where('"SilverShop_Variation"."Price" > 0');
        }
        return $list;
    }

    /**
     * Make sure variations are deleted with product.
     */
    public function onAfterDelete()
    {
        $remove = false;
        // if a record is staged or live, leave it's variations alone.
        if (!property_exists($this, 'owner')) {
            $remove = true;
        } else {
            $staged = Versioned::get_by_stage($this->owner->ClassName, 'Stage')
                ->byID($this->owner->ID);
            $live = Versioned::get_by_stage($this->owner->ClassName, 'Live')
                ->byID($this->owner->ID);
            if (!$staged && !$live) {
                $remove = true;
            }
        }
        if ($remove) {
            foreach ($this->owner->Variations() as $variation) {
                $variation->delete();
                $variation->destroy();
            }
        }
    }

    public function updateFormClass(&$formClass)
    {
        if ($this->owner->Variations()->exists()) {
            $formClass = VariationForm::class;
        }
    }
}


<?php

namespace SilverShop\Extension;

use SilverStripe\Assets\Image;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\DataExtension;

/**
 * Adds some image size functions to the Image DataObject.
 *
 * @package shop
 */
class ProductImageExtension extends DataExtension
{
    use Configurable;

    /**
     * @var Image
     */
    protected $owner;

    /**
     * @param bool $upscale [optional]
     * @return Image
     */
    public function getThumbnail($upscale = false)
    {
        $width = self::config()->thumbnail_width;
        $height = self::config()->thumbnail_height;

        return $this->getImageAt($width, $height, $upscale);
    }

    /**
     * @param bool $upscale [optional]
     * @return Image
     */
    public function getContentImage($upscale = false)
    {
        $width = self::config()->content_image_width;
        $height = self::config()->content_image_height;

        return $this->getImageAt($width, $height, $upscale);
    }

    /**
     * @param bool $upscale [optional]
     * @return Image
     */
    public function getLargeImage($upscale = false)
    {
        $width = self::config()->large_image_width;
        $height = self::config()->large_image_height;

        return $this->getImageAt($width, $height, $upscale);
    }

    /**
     * Resizes image by width or height only if the source image is bigger than the given width/height.
     * This prevents ugly upscaling.
     *
     * @param int  $width   [optional]
     * @param int  $height  [optional]
     * @param bool $upscale [optional]
     *
     * @return Image
     */
    public function getImageAt($width = null, $height = null, $upscale = false)
    {
        if (!$this->owner->exists()) {
            return $this->owner;
        }

        $realWidth = $this->owner->getWidth();
        $realHeight = $this->owner->getHeight();

        if ($width && $height) {
            return $realWidth < $width && $realHeight < $height && !$upscale
                ? $this->owner
                : $this->owner->Fit($width, $height);
        } else {
            if ($width) {
                return $realWidth < $width && !$upscale
                    ? $this->owner
                    : $this->owner->ScaleWidth($width);
            } else {
                return $realHeight < $height && !$upscale
                    ? $this->owner
                    : $this->owner->ScaleHeight($height);
            }
        }
    }

    /**
     * @return bool - is the image large enough that a "large" image makes sense?
     */
    public function HasLargeImage()
    {
        $imageWidth = intval($this->owner->getWidth());
        return $imageWidth > self::config()->content_image_width;
    }
}


<?php

namespace SilverShop\Checkout;

use SilverShop\Checkout\Component\CheckoutComponent;
use SilverShop\Checkout\Component\CheckoutComponentNamespaced;
use SilverShop\Model\Order;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Forms\FieldList;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult;

/**
 * @package shop
 */
class CheckoutComponentConfig
{
    use Injectable;

    protected $components;

    protected $order;

    protected $namespaced; //namespace fields according to their component

    public function __construct(Order $order, $namespaced = true)
    {
        $this->components = ArrayList::create();
        $this->order = $order;
        $this->namespaced = $namespaced;
    }

    public function getOrder()
    {
        return $this->order;
    }

    /**
     * @param CheckoutComponent $component
     * @param string            $insertBefore The class of the component to insert this one before
     * @return $this
     */
    public function addComponent(CheckoutComponent $component, $insertBefore = null)
    {
        if ($this->namespaced) {
            $component = CheckoutComponentNamespaced::create($component);
        }
        if ($insertBefore) {
            $existingItems = $this->getComponents();
            $this->components = ArrayList::create();
            $inserted = false;
            foreach ($existingItems as $existingItem) {
                if (!$inserted && $existingItem instanceof $insertBefore) {
                    $this->components->push($component);
                    $inserted = true;
                }
                $this->components->push($existingItem);
            }
            if (!$inserted) {
                $this->components->push($component);
            }
        } else {
            $this->getComponents()->push($component);
        }
        return $this;
    }

    /**
     * @return ArrayList Of CheckoutComponent
     */
    public function getComponents()
    {
        if (!$this->components) {
            $this->components = ArrayList::create();
        }
        return $this->components;
    }

    /**
     * Returns the first available component with the given class or interface.
     *
     * @param String ClassName
     *
     * @return CheckoutComponent
     */
    public function getComponentByType($type)
    {
        foreach ($this->components as $component) {
            if ($this->namespaced) {
                if ($component->Proxy() instanceof $type) {
                    return $component->Proxy();
                }
            } else {
                if ($component instanceof $type) {
                    return $component;
                }
            }
        }
    }

    /**
     * Whether or not this config has a component that collects payment data.
     * Should be used to determine whether or not to add an additional payment step after checkout.
     * @return bool
     */
    public function hasComponentWithPaymentData()
    {
        foreach ($this->getComponents() as $component) {
            if ($component->providesPaymentData()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get combined form fields
     *
     * @return FieldList namespaced fields
     */
    public function getFormFields()
    {
        $fields = FieldList::create();
        foreach ($this->getComponents() as $component) {
            if ($cfields = $component->getFormFields($this->order)) {
                $fields->merge($cfields);
            } else {
                user_error('getFields on  ' . get_class($component) . ' must return a FieldList');
            }
        }
        return $fields;
    }

    public function getRequiredFields()
    {
        $required = array();
        foreach ($this->getComponents() as $component) {
            $required = array_merge($required, $component->getRequiredFields($this->order));
        }
        return $required;
    }

    /**
     * Validate every component against given data.
     *
     * @param array $data data to validate
     *
     * @return boolean validation result
     * @throws ValidationException
     */
    public function validateData($data)
    {
        $result = ValidationResult::create();
        foreach ($this->getComponents() as $component) {
            try {
                $component->validateData($this->order, $this->dependantData($component, $data));
            } catch (ValidationException $e) {
                //transfer messages into a single result
                foreach ($e->getResult()->getMessages() as $code => $message) {
                    if (is_numeric($code)) {
                        $code = null;
                    }
                    if ($this->namespaced) {
                        $code = $component->namespaceFieldName($code);
                    }
                    $result->addError($message['message'], $code);
                }
            }
        }

        $this->order->extend('onValidateDataOnCheckout', $result);

        if (!$result->isValid()) {
            throw new ValidationException($result);
        }

        return true;
    }

    /**
     * Get combined data
     *
     * @return array map of field names to data values
     */
    public function getData()
    {
        $data = array();

        foreach ($this->getComponents() as $component) {
            $orderdata = $component->getData($this->order);

            if (is_array($orderdata)) {
                $data = array_merge($data, $orderdata);
            } else {
                user_error('getData on  ' . $component->name() . ' must return an array');
            }
        }
        return $data;
    }

    /**
     * Set data on all components
     *
     * @param array $data map of field names to data values
     */
    public function setData($data)
    {
        foreach ($this->getComponents() as $component) {
            $component->setData($this->order, $this->dependantData($component, $data));
        }
    }

    /**
     * Helper function for saving data from other components.
     */
    protected function dependantData($component, $data)
    {
        if (!$this->namespaced) { //no need to try and get un-namespaced dependant data
            return $data;
        }
        $dependantdata = array();
        foreach ($component->dependsOn() as $dependanttype) {
            $dependant = null;
            foreach ($this->components as $check) {
                if (get_class($check->Proxy()) == $dependanttype) {
                    $dependant = $check;
                    break;
                }
            }
            if (!$dependant) {
                user_error("Could not find a $dependanttype component, as depended by " . $component->name());
            }
            $dependantdata = array_merge(
                $dependantdata,
                $component->namespaceData($dependant->unnamespaceData($data))
            );
        }
        return array_merge($dependantdata, $data);
    }
}


<?php

namespace SilverShop\Cart;

use Exception;
use SilverShop\Extension\OrderManipulationExtension;
use SilverShop\Extension\ProductVariationsExtension;
use SilverShop\Model\Buyable;
use SilverShop\Model\Order;
use SilverShop\Model\OrderItem;
use SilverShop\ORM\Filters\MatchObjectFilter;
use SilverShop\Page\Product;
use SilverShop\ShopTools;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;

/**
 * Encapsulated manipulation of the current order using a singleton pattern.
 *
 * Ensures that an order is only started (persisted to DB) when necessary,
 * and all future changes are on the same order, until the order has is placed.
 * The requirement for starting an order is to adding an item to the cart.
 *
 * @package shop
 */
class ShoppingCart
{
    use Injectable;
    use Configurable;

    private static $cartid_session_name = 'SilverShop.shoppingcartid';

    /**
     * @var Order
     */
    private $order;

    private $calculateonce = false;

    private $message;

    private $type;

    /**
     * Shortened alias for ShoppingCart::singleton()->current()
     *
     * @return Order
     */
    public static function curr()
    {
        return self::singleton()->current();
    }

    /**
     * Get the current order, or return null if it doesn't exist.
     *
     * @return Order
     */
    public function current()
    {
        $session = ShopTools::getSession();
        //find order by id saved to session (allows logging out and retaining cart contents)
        if (!$this->order && $sessionid = $session->get(self::config()->cartid_session_name)) {
            $this->order = Order::get()->filter(
                [
                    'Status' => 'Cart',
                    'ID' => $sessionid,
                ]
            )->first();
        }
        if (!$this->calculateonce && $this->order) {
            $this->order->calculate();
            $this->calculateonce = true;
        }

        return $this->order ? $this->order : null;
    }

    /**
     * Set the current cart
     *
     * @param Order $cart the Order to use as the current cart-content
     *
     * @return ShoppingCart
     */
    public function setCurrent(Order $cart)
    {
        if (!$cart->IsCart()) {
            trigger_error('Passed Order object is not cart status', E_ERROR);
        }
        $this->order = $cart;
        $session = ShopTools::getSession();
        $session->set(self::config()->cartid_session_name, $cart->ID);

        return $this;
    }

    /**
     * Helper that only allows orders to be started internally.
     *
     * @return Order
     */
    protected function findOrMake()
    {
        if ($this->current()) {
            return $this->current();
        }
        $this->order = Order::create();
        if (Member::config()->login_joins_cart && ($member = Security::getCurrentUser())) {
            $this->order->MemberID = $member->ID;
        }
        $this->order->write();
        $this->order->extend('onStartOrder');

        $session = ShopTools::getSession();
        $session->set(self::config()->cartid_session_name, $this->order->ID);

        return $this->order;
    }

    /**
     * Adds an item to the cart
     *
     * @param Buyable $buyable
     * @param int     $quantity
     * @param array   $filter
     *
     * @return boolean|OrderItem false or the new/existing item
     */
    public function add(Buyable $buyable, $quantity = 1, $filter = [])
    {
        $order = $this->findOrMake();

        // If an extension throws an exception, error out
        try {
            $order->extend('beforeAdd', $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        if (!$buyable) {
            return $this->error(_t(__CLASS__ . '.ProductNotFound', 'Product not found.'));
        }

        $item = $this->findOrMakeItem($buyable, $quantity, $filter);
        if (!$item) {
            return false;
        }
        if (!$item->_brandnew) {
            $item->Quantity += $quantity;
        } else {
            $item->Quantity = $quantity;
        }

        // If an extension throws an exception, error out
        try {
            $order->extend('afterAdd', $item, $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        $item->write();
        $this->message(_t(__CLASS__ . '.ItemAdded', 'Item has been added successfully.'));

        return $item;
    }

    /**
     * Remove an item from the cart.
     *
     * @param Buyable $buyable
     * @param int     $quantity - number of items to remove, or leave null for all items (default)
     * @param array   $filter
     *
     * @return boolean success/failure
     */
    public function remove(Buyable $buyable, $quantity = null, $filter = [])
    {
        $order = $this->current();

        if (!$order) {
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
        }

        // If an extension throws an exception, error out
        try {
            $order->extend('beforeRemove', $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        $item = $this->get($buyable, $filter);

        if (!$item || !$this->removeOrderItem($item, $quantity)) {
            return false;
        }

        // If an extension throws an exception, error out
        // TODO: There should be a rollback
        try {
            $order->extend('afterRemove', $item, $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        $this->message(_t(__CLASS__ . '.ItemRemoved', 'Item has been successfully removed.'));

        return true;
    }

    /**
     * Remove a specific order item from cart
     *
     * @param  OrderItem $item
     * @param  int       $quantity - number of items to remove or leave `null` to remove all items (default)
     * @return boolean success/failure
     */
    public function removeOrderItem(OrderItem $item, $quantity = null)
    {
        $order = $this->current();

        if (!$order) {
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
        }

        if (!$item || $item->OrderID != $order->ID) {
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
        }

        //if $quantity will become 0, then remove all
        if (!$quantity || ($item->Quantity - $quantity) <= 0) {
            $item->delete();
            $item->destroy();
        } else {
            $item->Quantity -= $quantity;
            $item->write();
        }

        return true;
    }

    /**
     * Sets the quantity of an item in the cart.
     * Will automatically add or remove item, if necessary.
     *
     * @param Buyable $buyable
     * @param int     $quantity
     * @param array   $filter
     *
     * @return boolean|OrderItem false or the new/existing item
     */
    public function setQuantity(Buyable $buyable, $quantity = 1, $filter = [])
    {
        if ($quantity <= 0) {
            return $this->remove($buyable, $quantity, $filter);
        }

        $item = $this->findOrMakeItem($buyable, $quantity, $filter);

        if (!$item || !$this->updateOrderItemQuantity($item, $quantity, $filter)) {
            return false;
        }

        return $item;
    }

    /**
     * Update quantity of a given order item
     *
     * @param  OrderItem $item
     * @param  int       $quantity the new quantity to use
     * @param  array     $filter
     * @return boolean success/failure
     */
    public function updateOrderItemQuantity(OrderItem $item, $quantity = 1, $filter = [])
    {
        $order = $this->current();

        if (!$order) {
            return $this->error(_t(__CLASS__ . '.NoOrder', 'No current order.'));
        }

        if (!$item || $item->OrderID != $order->ID) {
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
        }

        $buyable = $item->Buyable();
        // If an extension throws an exception, error out
        try {
            $order->extend('beforeSetQuantity', $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        $item->Quantity = $quantity;

        // If an extension throws an exception, error out
        try {
            $order->extend('afterSetQuantity', $item, $buyable, $quantity, $filter);
        } catch (Exception $exception) {
            return $this->error($exception->getMessage());
        }

        $item->write();
        $this->message(_t(__CLASS__ . '.QuantitySet', 'Quantity has been set.'));

        return true;
    }

    /**
     * Finds or makes an order item for a given product + filter.
     *
     * @param Buyable $buyable  the buyable
     * @param int     $quantity quantity to add
     * @param array   $filter
     *
     * @return OrderItem the found or created item
     * @throws \SilverStripe\ORM\ValidationException
     */
    private function findOrMakeItem(Buyable $buyable, $quantity = 1, $filter = [])
    {
        $order = $this->findOrMake();

        if (!$buyable || !$order) {
            return null;
        }

        $item = $this->get($buyable, $filter);

        if (!$item) {
            $member = Security::getCurrentUser();

            $buyable = $this->getCorrectBuyable($buyable);

            if (!$buyable->canPurchase($member, $quantity)) {
                return $this->error(
                    _t(
                        __CLASS__ . '.CannotPurchase',
                        'This {Title} cannot be purchased.',
                        '',
                        ['Title' => $buyable->i18n_singular_name()]
                    )
                );
                //TODO: produce a more specific message
            }

            $item = $buyable->createItem($quantity, $filter);
            $item->OrderID = $order->ID;
            $item->write();

            $order->Items()->add($item);

            $item->_brandnew = true; // flag as being new
        }

        return $item;
    }

    /**
     * Finds an existing order item.
     *
     * @param Buyable $buyable
     * @param array   $customfilter
     *
     * @return OrderItem the item requested or null
     */
    public function get(Buyable $buyable, $customfilter = array())
    {
        $order = $this->current();
        if (!$buyable || !$order) {
            return null;
        }

        $buyable = $this->getCorrectBuyable($buyable);

        $filter = array(
            'OrderID' => $order->ID,
        );

        $itemclass = Config::inst()->get(get_class($buyable), 'order_item');
        $relationship = Config::inst()->get($itemclass, 'buyable_relationship');
        $filter[$relationship . 'ID'] = $buyable->ID;
        $required = ['OrderID', $relationship . 'ID'];
        if (is_array($itemclass::config()->required_fields)) {
            $required = array_merge($required, $itemclass::config()->required_fields);
        }
        $query = new MatchObjectFilter($itemclass, array_merge($customfilter, $filter), $required);
        $item = $itemclass::get()->where($query->getFilter())->first();
        if (!$item) {
            return $this->error(_t(__CLASS__ . '.ItemNotFound', 'Item not found.'));
        }

        return $item;
    }

    /**
     * Ensure the proper buyable will be returned for a given buyable…
     * This is being used to ensure a product with variations cannot be added to the cart…
     * a Variation has to be added instead!
     *
     * @param  Buyable $buyable
     * @return Buyable
     */
    public function getCorrectBuyable(Buyable $buyable)
    {
        if ($buyable instanceof Product
            && $buyable->hasExtension(ProductVariationsExtension::class)
            && $buyable->Variations()->count() > 0
        ) {
            foreach ($buyable->Variations() as $variation) {
                if ($variation->canPurchase()) {
                    return $variation;
                }
            }
        }

        return $buyable;
    }

    /**
     * Store old cart id in session order history
     *
     * @param int|null $requestedOrderId optional parameter that denotes the order that was requested
     */
    public function archiveorderid($requestedOrderId = null)
    {
        $session = ShopTools::getSession();
        $sessionId = $session->get(self::config()->cartid_session_name);
        $order = Order::get()
            ->filter('Status:not', 'Cart')
            ->byId($sessionId);

        if ($order && !$order->IsCart()) {
            OrderManipulationExtension::add_session_order($order);
        }
        // in case there was no order requested
        // OR there was an order requested AND it's the same one as currently in the session,
        // then clear the cart. This check is here to prevent clearing of the cart if the user just
        // wants to view an old order (via AccountPage).
        if (!$requestedOrderId || ($sessionId == $requestedOrderId)) {
            $this->clear();
        }
    }

    /**
     * Empty / abandon the entire cart.
     *
     * @param  bool $write whether or not to write the abandoned order
     * @return bool - true if successful, false if no cart found
     */
    public function clear($write = true)
    {
        $session = ShopTools::getSession();
        $session->set(self::config()->cartid_session_name, null)->clear(self::config()->cartid_session_name);
        $order = $this->current();
        $this->order = null;

        if ($write) {
            if (!$order) {
                return $this->error(_t(__CLASS__ . '.NoCartFound', 'No cart found.'));
            }
            $order->write();
        }
        $this->message(_t(__CLASS__ . '.Cleared', 'Cart was successfully cleared.'));

        return true;
    }

    /**
     * Store a new error.
     */
    protected function error($message)
    {
        $this->message($message, 'bad');

        return null;
    }

    /**
     * Store a message to be fed back to user.
     *
     * @param string $message
     * @param string $type    - good, bad, warning
     */
    protected function message($message, $type = 'good')
    {
        $this->message = $message;
        $this->type = $type;
    }

    public function getMessage()
    {
        return $this->message;
    }

    public function getMessageType()
    {
        return $this->type;
    }

    public function clearMessage()
    {
        $this->message = null;
    }

    //singleton protection
    public function __clone()
    {
        trigger_error('Clone is not allowed.', E_USER_ERROR);
    }

    public function __wakeup()
    {
        trigger_error('Unserializing is not allowed.', E_USER_ERROR);
    }
}


<?php

namespace SilverShop\ORM\Filters;

use InvalidArgumentException;
use SilverStripe\ORM\DataQuery;
use SilverStripe\ORM\Filters\PartialMatchFilter;

/**
 * This must be created manually OR the subfilters set manually
 * because of the way SS cleans up the filter definitions before
 * passing them to the filter classes.
 */
class MultiFieldPartialMatchFilter extends PartialMatchFilter
{
    /**
     * @var array $subfilters
     */
    protected $subfilters;

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

    /**
     * @param string $fullName    Determines the name of the field, as well as the searched database
     *                            column. Can contain a relation name in dot notation, which will
     *                            automatically join the necessary tables (e.g. "Comments.Name" to
     *                            join the "Comments" has-many relationship and search the "Name"
     *                            column when applying this filter to a SiteTree class).
     * @param mixed  $value       [optional]
     * @param array  $modifiers   [optional]
     * @param array  $otherFields [optional]
     */
    public function __construct($fullName, $value = false, array $modifiers = [], array $otherFields = [])
    {
        parent::__construct($fullName, $value, $modifiers);
        if ($value !== false) {
            $this->setValue($value);
        }
        $this->setSubfilters($otherFields);
    }

    /**
     * @param array $modifiers
     *
     * @throws InvalidArgumentException
     */
    public function setModifiers(array $modifiers)
    {
        $modifiers = array_map('strtolower', $modifiers);

        if (($extras = array_diff($modifiers, ['not', 'nocase', 'case', 'splitwords'])) != array()) {
            throw new InvalidArgumentException(
                get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers'
            );
        }

        $this->modifiers = $modifiers;
        $this->subfilterModifiers = array_filter(
            $modifiers,
            function ($v) {
                return $v != 'splitwords';
            }
        );

        if (!empty($this->subfilters)) {
            foreach ($this->subfilters as $f) {
                $f->setModifiers($this->subfilterModifiers);
            }
        }
    }

    /**
     * @param array $fieldNames
     */
    public function setSubfilters(array $fieldNames)
    {
        $this->subfilters = array();
        if (count($fieldNames) > 0) {
            foreach ($fieldNames as $name) {
                $this->subfilters[] = new PartialMatchFilter($name, $this->value, $this->subfilterModifiers);
            }
        }
    }

    /**
     * @param string $value
     */
    public function setValue($value)
    {
        if ($this->shouldSplitWords() && is_string($value) && preg_match('/\s+/', $value)) {
            $value = preg_split('/\s+/', trim($value));
        }

        parent::setValue($value);

        if (count($this->subfilters) > 0) {
            foreach ($this->subfilters as $f) {
                $f->setValue($value);
            }
        }
    }

    /**
     * @return bool
     */
    protected function shouldSplitWords()
    {
        $modifiers = $this->getModifiers();
        return in_array('splitwords', $modifiers);
    }

    /**
     * @param DataQuery $query
     *
     * @return $this|DataQuery
     */
    public function apply(DataQuery $query)
    {
        $orGroup = $query->disjunctiveGroup();
        $orGroup = parent::apply($orGroup);

        if (count($this->subfilters) > 0) {
            foreach ($this->subfilters as $f) {
                $orGroup = $f->apply($orGroup);
            }
        }

        // The original query will have been affected by the things added to $orGroup above
        // but returning this instead of that will cause new filters to be added as AND
        return $query;
    }
}


<?php

namespace SilverShop\Model\Modifiers\Tax;

use SilverShop\Model\Order;

/**
 * Handles calculation of sales tax on Orders.
 *
 * @package    shop
 * @subpackage modifiers
 */
class FlatTax extends Base
{
    /**
     * @config
     * @var string
     */
    private static $name            = 'GST';

    /**
     * @config
     * @var float
     */
    private static $rate            = 0.15;

    /**
     * @config
     * @var bool
     */
    private static $exclusive       = true;

    /**
     * @config
     * @var string
     */
    private static $includedmessage = '%.1f%% %s (inclusive)';

    /**
     * @config
     * @var string
     */
    private static $excludedmessage = '%.1f%% %s';

    public function __construct($record = null, $isSingleton = false, $model = null)
    {
        parent::__construct($record, $isSingleton, $model);
        $this->Type = self::config()->exclusive ? 'Chargable' : 'Ignored';
    }

    /**
     * Get the tax amount to charge on the order.
     */
    public function value($incoming)
    {
        $this->Rate = self::config()->rate;
        //inclusive tax requires a different calculation
        return self::config()->exclusive
            ?
            $incoming * $this->Rate
            :
            $incoming - round($incoming / (1 + $this->Rate), Order::config()->rounding_precision);
    }
}