<?php

namespace SilverShop;

use Psr\Container\NotFoundExceptionInterface;
use SilverShop\Extension\ShopConfigExtension;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Session;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\i18n\i18n;
use SilverStripe\ORM\FieldType\DBMoney;

/**
 * Globally useful tools
 */
class ShopTools
{

    /**
     * Convert a numeric price to the shop currency
     *
     * @param  mixed $price the price to convert
     * @return DBMoney the price wrapped in a Money DBField to be used for templates or similar
     */
    public static function price_for_display($price)
    {
        $currency = ShopConfigExtension::get_site_currency();
        $field = DBMoney::create_field(DBMoney::class, 0, 'Price');
        $field->setAmount($price);
        $field->setCurrency($currency);
        return $field;
    }

    /**
     * Get the current locale.
     * Tries to get the locale from Fluent or the default i18n (depending on what is installed)
     *
     * @return string the locale in use
     */
    public static function get_current_locale()
    {
        if (class_exists('TractorCow\Fluent\State\FluentState')) {
            return singleton('TractorCow\Fluent\State\FluentState')->getLocale();
        }

        return i18n::get_locale();
    }

    /**
     * Set/Install the given locale.
     * This does set the i18n locale as well as the Fluent locale (if this module is installed)
     *
     * @param string $locale the locale to install
     */
    public static function install_locale($locale)
    {
        // If the locale isn't given, silently fail (there might be carts that still have locale set to null)
        if (empty($locale)) {
            return;
        }

        if (class_exists('TractorCow\Fluent\State\FluentState')) {
            singleton('TractorCow\Fluent\State\FluentState')->setLocale($locale);
        }

        // Do something like Fluent does to install the locale
        i18n::set_locale($locale);

        // LC_NUMERIC causes SQL errors for some locales (comma as decimal indicator) so skip
        foreach ([LC_COLLATE, LC_CTYPE, LC_MONETARY, LC_TIME] as $category) {
            setlocale($category, "{$locale}.UTF-8", $locale);
        }
    }

    /**
     * Get the current section (first looking at controller, then at a request instance and lastly return a fresh session)
     *
     * @param HTTPRequest $request the incoming request (optional)
     * @return Session
     */
    public static function getSession(HTTPRequest $request = null)
    {
        if ($request && ($session = $request->getSession())) {
            return $session;
        }

        if (Controller::has_curr() && ($request = Controller::curr()->getRequest())) {
            return $request->getSession();
        }

        try {
            if ($session = Injector::inst()->get(HTTPRequest::class)->getSession()) {
                return $session;
            }
        } catch (NotFoundExceptionInterface $e) {
            // No-Op
        }

        return new Session([]);
    }

    /**
     * Sanitise a model class' name for inclusion in a link
     *
     * @param  string $class
     * @return string
     */
    public static function sanitiseClassName($class)
    {
        return str_replace('\\', '-', $class);
    }

    /**
     * Unsanitise a model class' name from a URL param
     *
     * @param  string $class
     * @return string
     */
    public static function unsanitiseClassName($class)
    {
        return str_replace('-', '\\', $class);
    }
}


<?php

namespace SilverShop\Forms;

use SilverShop\Cart\ShoppingCart;
use SilverShop\Extension\ShopConfigExtension;
use SilverShop\ShopTools;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Config\Config;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\NumericField;

/**
 * Renders the cart inside a form, so that it is editable.
 *
 * @package shop
 */
class CartForm extends Form
{
    protected $cart;

    public function __construct($controller, $name = 'CartForm', $cart = null, $template = 'SilverShop\Cart\Cart')
    {
        $this->cart = $cart;
        $fields = FieldList::create(
            CartEditField::create('Items', '', $this->cart)
                ->setTemplate($template)
        );
        $actions = FieldList::create(
            FormAction::create('updatecart', _t(__CLASS__ . '.UpdateCart', 'Update Cart'))
                ->setUseButtonTag(Config::inst()->get(ShopConfigExtension::class, 'forms_use_button_tag'))
        );

        parent::__construct($controller, $name, $fields, $actions);
    }

    /**
     * Update the cart using data collected
     */
    public function updatecart($data, $form)
    {
        $items = $this->cart->Items();
        $updatecount = $removecount = 0;

        $request = $this->getRequest();
        $order = ShoppingCart::curr();
        if ($request && $request->isAjax() && $order) {
            ShopTools::install_locale($order->Locale);
        }

        $numericConverter = NumericField::create('_temp');

        $messages = [];
        $badMessages = [];
        if (isset($data['Items']) && is_array($data['Items'])) {
            foreach ($data['Items'] as $itemid => $fields) {
                $item = $items->byID($itemid);
                if (!$item) {
                    continue;
                }
                //delete lines
                if (isset($fields['Remove']) || (isset($fields['Quantity']) && (int)$fields['Quantity'] <= 0)) {
                    if (ShoppingCart::singleton()->removeOrderItem($item)) {
                        $removecount++;
                    } else {
                        $badMessages[] = ShoppingCart::singleton()->getMessage();
                    }

                    continue;
                }
                //update quantities
                if (isset($fields['Quantity']) && $quantity = Convert::raw2sql($fields['Quantity'])) {
                    $numericConverter->setValue($quantity);
                    if (!ShoppingCart::singleton()->updateOrderItemQuantity($item, $numericConverter->dataValue())) {
                        $badMessages[] = ShoppingCart::singleton()->getMessage();
                    }
                }
                //update variations
                if (isset($fields['ProductVariationID']) && $id = Convert::raw2sql($fields['ProductVariationID'])) {
                    if ($item->ProductVariationID != $id) {
                        $item->ProductVariationID = $id;
                    }
                }
                //TODO: make updates through ShoppingCart class
                //TODO: combine with items that now match exactly
                //TODO: validate changes
                if ($item->isChanged()) {
                    $item->write();
                    $updatecount++;
                }
            }
        }
        if ($removecount) {
            $messages['remove'] = _t(
                __CLASS__ . '.REMOVED_ITEMS',
                'Removed {count} items.',
                'count is the amount that was removed',
                array('count' => $removecount)
            );
        }
        if ($updatecount) {
            $messages['updatecount'] = _t(
                __CLASS__ . '.UPDATED_ITEMS',
                'Updated {count} items.',
                'count is the amount that was updated',
                array('count' => $updatecount)
            );
        }
        if (count($messages)) {
            $form->sessionMessage(implode(' ', $messages), 'good');
        }

        if (count($badMessages)) {
            $form->sessionMessage(implode(' ', $badMessages), 'bad');
        }

        $this->extend('updateCartFormResponse', $request, $response, $form);

        return $response ? $response : $this->controller->redirectBack();
    }
}


<?php

namespace SilverShop\Admin;

use SilverShop\Model\Variation\AttributeType;
use SilverShop\Model\Variation\Variation;
use SilverShop\Page\ProductCategory;
use SilverStripe\Assets\Image;
use SilverStripe\Core\Convert;
use SilverStripe\Dev\CsvBulkLoader;
use SilverStripe\ORM\ArrayList;

/**
 * ProductBulkLoader - allows loading products via CSV file.
 *
 * Images should be uploaded before import, where the Photo/Image field
 * corresponds to the filename of a file that was uploaded.
 *
 * Variations can be specified in a "Variation" column this format:
 * Type:value,value,value
 * eg: Color: red, green, blue , yellow
 * up to 6 other variation columns can be specified by adding a number to the end, eg Variation2,$Variation3
 *
 * @package    shop
 * @subpackage cms
 */
class ProductBulkLoader extends CsvBulkLoader
{
    /**
     * You can force every product to be in a certain category, as long as you know its ID.
     *
     * @config
     * @var    null
     */
    private static $parent_page_id = null;

    /**
     * Set this if you want categories to be created if they don't exist.
     *
     * @config
     * @var    bool
     */
    protected static $create_new_product_groups = false;

    protected $foundParentId = null;

    // NB do NOT use functional indirection on any fields where they
    // will be used in $duplicateChecks as well - they simply don't work.
    public $columnMap = [
        'Price' => 'BasePrice',

        'Category' => '->setParent',
        'ProductGroup' => '->setParent',
        'ProductCategory' => '->setParent',

        'Product ID' => 'InternalItemID',
        'ProductID' => 'InternalItemID',
        'SKU' => 'InternalItemID',

        'Description' => '->setContent',
        'Long Description' => '->setContent',
        'Short Description' => 'MetaDescription',

        'Short Title' => 'MenuTitle',

        'Title' => 'Title',
        'Page name' => 'Title',
        'Page Name' => 'Title',

        'Variation' => '->processVariation',
        'Variation1' => '->processVariation1',
        'Variation2' => '->processVariation2',
        'Variation3' => '->processVariation3',
        'Variation4' => '->processVariation4',
        'Variation5' => '->processVariation5',
        'Variation6' => '->processVariation6',

        'VariationID' => '->variationRow',
        'Variation ID' => '->variationRow',
        'SubID' => '->variationRow',
        'Sub ID' => '->variationRow',
    ];

    public $duplicateChecks = [
        'InternalItemID' => 'InternalItemID',
        'SKU' => 'InternalItemID',
        'Product ID' => 'InternalItemID',
        'ProductID' => 'InternalItemID',
        'Title' => 'Title',
        'Page Title' => 'Title',
        'PageTitle' => 'Title',
    ];

    public $relationCallbacks = [
        'Image' => [
            'relationname' => 'Image', // relation accessor name
            'callback' => 'imageByFilename',
        ],
        'Photo' => [
            'relationname' => 'Image', // relation accessor name
            'callback' => 'imageByFilename',
        ],
    ];

    protected function processAll($filepath, $preview = false)
    {
        $this->extend('updateColumnMap', $this->columnMap);

        $results = parent::processAll($filepath, $preview);
        //After results have been processed, publish all created & updated products
        $objects = ArrayList::create();
        $objects->merge($results->Created());
        $objects->merge($results->Updated());
        $parentPageID = $this->config()->parent_page_id;
        foreach ($objects as $object) {
            if (!$object->ParentID) {
                //set parent page
                if (is_numeric($parentPageID) && ProductCategory::get()->byID($parentPageID)) { //cached option
                    $object->ParentID = $parentPageID;
                } elseif ($parentPage = ProductCategory::get()->filter('Title', 'Products')->sort('Created', 'DESC')->first()) { //page called 'Products'
                    $object->ParentID = $parentPageID = $parentPage->ID;
                } elseif ($parentpage = ProductCategory::get()->filter('ParentID', 0)->sort('Created', 'DESC')->first()) { //root page
                    $object->ParentID = $parentPageID = $parentpage->ID;
                } elseif ($parentpage = ProductCategory::get()->sort('Created', 'DESC')->first()) { //any product page
                    $object->ParentID = $parentPageID = $parentpage->ID;
                } else {
                    $object->ParentID = $parentPageID = 0;
                }
            }
            $this->foundParentId = $parentPageID;
            $object->extend('updateImport'); //could be used for setting other attributes, such as stock level
            $object->writeToStage('Stage');
            $object->publishSingle();
        }
        return $results;
    }

    public function processRecord($record, $columnMap, &$results, $preview = false)
    {
        if (!$record || !isset($record['Title']) || $record['Title'] == '') { //TODO: make required fields customisable
            return null;
        }
        return parent::processRecord($record, $columnMap, $results, $preview);
    }

    // set image, based on filename
    public function imageByFilename(&$obj, $val, $record)
    {
        $filename = trim(strtolower(Convert::raw2sql($val)));
        $filenamedashes = str_replace(' ', '-', $filename);
        if ($filename
            && $image = Image::get()->whereAny(
                [
                    "LOWER(\"FileFilename\") LIKE '%$filename%'",
                    "LOWER(\"FileFilename\") LIKE '%$filenamedashes%'"
                ]
            )->first()
        ) { //ignore case
            if ($image instanceof Image && $image->exists()) {
                return $image;
            }
        }
        return null;
    }

    // find product group parent (ie Cateogry)
    public function setParent(&$obj, $val, $record)
    {
        $title = strtolower(Convert::raw2sql($val));
        if ($title) {
            // find or create parent category, if provided
            if ($parentPage = ProductCategory::get()->where(['LOWER("Title") = ?' => $title])->sort('Created', 'DESC')->first()) {
                $obj->ParentID = $parentPage->ID;
                $obj->write();
                $obj->writeToStage('Stage');
                $obj->publishSingle();
                //TODO: otherwise assign it to the first product group found
            } elseif ($this->config()->create_new_product_groups) {
                //create parent product group
                $pg = ProductCategory::create();
                $pg->setTitle($title);
                $pg->ParentID = ($this->foundParentId) ? $this->foundParentId : 0;
                $pg->writeToStage('Stage');
                $pg->publishSingle();
                $obj->ParentID = $pg->ID;
                $obj->write();
                $obj->writeToStage('Stage');
                $obj->publishSingle();
            }
        }
    }

    /**
     * Adds paragraphs to content.
     */
    public function setContent(&$obj, $val, $record)
    {
        $val = trim($val);
        if ($val) {
            $paragraphs = explode("\n", $val);
            $obj->Content = '
' . implode('

', $paragraphs) . '
';
        }
    }

    public function processVariation(&$obj, $val, $record)
    {
        if (isset($record['->variationRow'])) {
            return;
        } //don't use this technique for variation rows
        $parts = explode(':', $val);
        if (count($parts) == 2) {
            $attributetype = trim($parts[0]);
            $attributevalues = explode(',', $parts[1]);
            //get rid of empty values
            foreach ($attributevalues as $key => $value) {
                if (!$value || trim($value) == '') {
                    unset($attributevalues[$key]);
                }
            }
            if (count($attributevalues) >= 1) {
                $attributetype = AttributeType::find_or_make($attributetype);
                foreach ($attributevalues as $key => $value) {
                    $val = trim($value);
                    if ($val != '' && $val != null) {
                        $attributevalues[$key] = $val; //remove outside spaces from values
                    }
                }
                $attributetype->addValues($attributevalues);
                $obj->VariationAttributeTypes()->add($attributetype);
                //only generate variations if none exist yet
                if (!$obj->Variations()->exists() || $obj->WeAreBuildingVariations) {
                    //either start new variations, or multiply existing ones by new variations
                    $obj->generateVariationsFromAttributes($attributetype, $attributevalues);
                    $obj->WeAreBuildingVariations = true;
                }
            }
        }
    }

    //work around until I can figure out how to allow calling processVariation multiple times
    public function processVariation1(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function processVariation2(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function processVariation3(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function processVariation4(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function processVariation5(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function processVariation6(&$obj, $val, $record)
    {
        $this->processVariation($obj, $val, $record);
    }

    public function variationRow(&$obj, $val, $record)
    {

        $obj->write(); //make sure product is in DB
        //TODO: or find existing variation
        $variation = Variation::get()->filter('InternalItemID', $val)->first();
        if (!$variation) {
            $variation = Variation::create();
            $variation->InternalItemID = $val;
            $variation->ProductID = $obj->ID; //link to product
            $variation->write();
        }
        $varcols = array(
            '->processVariation',
            '->processVariation1',
            '->processVariation2',
            '->processVariation3',
            '->processVariation4',
            '->processVariation5',
            '->processVariation6',
        );
        foreach ($varcols as $col) {
            if (isset($record[$col])) {
                $parts = explode(':', $record[$col]);
                if (count($parts) == 2) {
                    $attributetype = trim($parts[0]);
                    $attributevalues = explode(',', $parts[1]);
                    //get rid of empty values
                    foreach ($attributevalues as $key => $value) {
                        if (!$value || trim($value) == '') {
                            unset($attributevalues[$key]);
                        }
                    }
                    if (count($attributevalues) == 1) {
                        $attributetype = AttributeType::find_or_make($attributetype);
                        foreach ($attributevalues as $key => $value) {
                            $val = trim($value);
                            if ($val != '' && $val != null) {
                                $attributevalues[$key] = $val; //remove outside spaces from values
                            }
                        }
                        $attributetype->addValues($attributevalues); //create and add values to attribute type
                        $obj->VariationAttributeTypes()->add($attributetype); //add variation attribute type to product
                        //TODO: if existing variation, then remove current values
                        //record vairation attribute values (variation1, 2 etc)
                        foreach ($attributetype->convertArrayToValues($attributevalues) as $value) {
                            $variation->AttributeValues()->add($value);
                            break;
                        }
                    }
                }
            }
        }
        //copy db values into variation (InternalItemID, Price, Stock, etc) ...there will be unknowns from extensions.
        $dbfields = $variation->getSchema()->fieldSpecs(Variation::class);
        foreach ($record as $field => $value) {
            if (isset($dbfields[$field])) {
                $variation->$field = $value;
            }
        }
        $variation->write();
    }
}


<?php

namespace SilverShop\Extension;

use SilverStripe\AssetAdmin\Forms\UploadField;
use SilverStripe\Assets\Image;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Forms\CheckboxSetField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Tab;
use SilverStripe\Forms\TabSet;
use SilverStripe\Forms\TreeDropdownField;
use SilverStripe\ORM\DataExtension;
use SilverStripe\Security\Group;
use SilverStripe\SiteConfig\SiteConfig;

/**
 * @property string $AllowedCountries
 * @property int $TermsPageID
 * @property int $CustomerGroupID
 * @property int $DefaultProductImageID
 *
 * @method SiteTree TermsPage()
 * @method Group CustomerGroup()
 * @method Image DefaultProductImage()
 */
class ShopConfigExtension extends DataExtension
{
    use Configurable;

    private static $db = [
        'AllowedCountries' => 'Text',
    ];

    private static $has_one = [
        'TermsPage' => SiteTree::class,
        'CustomerGroup' => Group::class,
        'DefaultProductImage' => Image::class,
    ];

    /**
     * Email address where shop emails should be sent from
     *
     * @config
     * @var
     */
    private static $email_from;

    /**
     * The shop base currency
     *
     * @config
     * @var
     */
    private static $base_currency = 'NZD';

    private static $forms_use_button_tag = false;

    public static function current()
    {
        return SiteConfig::current_site_config();
    }

    public static function get_site_currency()
    {
        return self::config()->base_currency;
    }

    public function updateCMSFields(FieldList $fields)
    {
        $fields->insertBefore('Access', $shoptab = Tab::create('Shop', 'Shop'));
        $fields->addFieldToTab(
            'Root.Shop',
            TabSet::create(
                'ShopTabs',
                $maintab = Tab::create(
                    'Main',
                    TreeDropdownField::create(
                        'TermsPageID',
                        _t(__CLASS__ . '.TermsPage', 'Terms and Conditions Page'),
                        SiteTree::class
                    ),
                    TreeDropdownField::create(
                        'CustomerGroupID',
                        _t(__CLASS__ . '.CustomerGroup', 'Group to add new customers to'),
                        Group::class
                    ),
                    UploadField::create('DefaultProductImage', _t(__CLASS__ . '.DefaultImage', 'Default Product Image'))
                ),
                $countriesTab = Tab::create(
                    'Countries',
                    CheckboxSetField::create(
                        'AllowedCountries',
                        _t(__CLASS__ . '.AllowedCountries', 'Allowed Ordering and Shipping Countries'),
                        self::config()->iso_3166_country_codes
                    )
                )
            )
        );
        $fields->removeByName('CreateTopLevelGroups');
        $countriesTab->setTitle(_t(__CLASS__ . '.AllowedCountriesTabTitle', 'Allowed Countries'));
    }

    /**
     * Get list of allowed countries
     *
     * @param boolean $prefixisocode - prefix the country code
     *
     * @return array
     */
    public function getCountriesList($prefixisocode = false)
    {
        $countries = self::config()->iso_3166_country_codes;
        asort($countries);
        if ($allowed = $this->owner->AllowedCountries) {
            $allowed = json_decode($allowed);
            if (!empty($allowed)) {
                $countries = array_intersect_key($countries, array_flip($allowed));
            }
        }
        if ($prefixisocode) {
            foreach ($countries as $key => $value) {
                $countries[$key] = "$key - $value";
            }
        }
        return $countries;
    }

    /**
     * For shops that only sell to a single country,
     * this will return the country code, otherwise null.
     *
     * @param string fullname get long form name of country
     *
     * @return string country code
     */
    public function getSingleCountry($fullname = false)
    {
        $countries = $this->getCountriesList();
        if (count($countries) == 1) {
            if ($fullname) {
                return array_pop($countries);
            } else {
                reset($countries);
                return key($countries);
            }
        }
        return null;
    }

    /**
     * Convert iso country code to English country name
     *
     * @return string - name of country
     */
    public static function countryCode2name($code)
    {
        $codes = self::config()->iso_3166_country_codes;
        if (isset($codes[$code])) {
            return $codes[$code];
        }
        return $code;
    }
}


<?php

namespace SilverShop\Extension;

use SilverShop\Cart\ShoppingCart;
use SilverShop\Page\CartPage;
use SilverShop\Page\CheckoutPage;
use SilverShop\Page\ProductCategory;
use SilverStripe\Control\Director;
use SilverStripe\Core\Extension;

/**
 * ViewableData extension that provides access to the cart from anywhere.
 * Also handles last-minute recalculation, if required.
 * All order updates: quantities, modifiers etc should be done before
 * this function is called.
 *
 * @package shop
 */
class ViewableCartExtension extends Extension
{
    /**
     * Get the cart, and do last minute calculation if necessary.
     */
    public function Cart()
    {
        $order = ShoppingCart::curr();
        if (!$order || !$order->Items() || !$order->Items()->exists()) {
            return false;
        }

        return $order;
    }

    public function getContinueLink()
    {
        if ($cartPage = CartPage::get()->first()) {
            if ($cartPage->ContinuePageID) {
                return $cartPage->ContinuePage()->Link();
            }
        }

        $maincategory = ProductCategory::get()
            ->sort(
                [
                    'ParentID' => 'ASC',
                    'ID' => 'ASC',
                ]
            )->first();
        if ($maincategory) {
            return $maincategory->Link();
        }

        return Director::baseURL();
    }

    public function getCartLink()
    {
        return CartPage::find_link();
    }

    public function getCheckoutLink()
    {
        return CheckoutPage::find_link();
    }
}


<?php

namespace SilverShop\Model\Variation;

use SilverShop\Page\Product;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\HasManyList;
use SilverStripe\ORM\ManyManyList;

/**
 * Producte Attribute Type
 * Types of product attributes.
 * eg: color, size, length
 *
 * @property string $Name
 * @property string $Label
 * @method   AttributeValue[]|HasManyList Values()
 * @method   Product[]|ManyManyList Product()
 */
class AttributeType extends DataObject
{
    private static $db = [
        'Name' => 'Varchar', //for back-end use
        'Label' => 'Varchar' //for front-end use
    ];

    private static $has_many = [
        'Values' => AttributeValue::class,
    ];

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

    private static $summary_fields = [
        'Name' => 'Name',
        'Label' => 'Label',
    ];

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

    private static $default_sort = 'ID ASC';

    private static $singular_name = 'Attribute';

    private static $plural_name = 'Attributes';

    private static $table_name = 'SilverShop_AttributeType';

    public static function find_or_make($name)
    {
        if ($type = AttributeType::get()->filter('Name:nocase', $name)->first()
        ) {
            return $type;
        }
        $type = AttributeType::create();
        $type->Name = $name;
        $type->Label = $name;
        $type->write();

        return $type;
    }

    public function getCMSFields()
    {
        $fields = FieldList::create(
            TextField::create('Name', $this->fieldLabel('Name')),
            TextField::create('Label', $this->fieldLabel('Label'))
        );
        if ($this->isInDB()) {
            $fields->push(
                GridField::create(
                    'Values',
                    $this->fieldLabel('Values'),
                    $this->Values(),
                    GridFieldConfig_RecordEditor::create()
                )
            );
        } else {
            $fields->push(
                LiteralField::create(
                    'Values',
                    '
' .
                    _t(__CLASS__ . '.SaveFirstInfo', 'Save first, then you can add values.') .
                    '
'
                )
            );
        }

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

        return $fields;
    }

    public function addValues(array $values)
    {
        $avalues = $this->convertArrayToValues($values);
        $this->Values()->addMany($avalues);
    }

    /**
     * Finds or creates values for this type.
     *
     * @param array $values
     *
     * @return ArrayList
     */
    public function convertArrayToValues(array $values)
    {
        $set = ArrayList::create();
        foreach ($values as $value) {
            $val = $this->Values()->find('Value', $value);
            if (!$val) {  //TODO: ignore case, if possible
                $val = AttributeValue::create();
                $val->Value = $value;
                $val->TypeID = $this->ID;
                $val->write();
            }
            $set->push($val);
        }

        return $set;
    }

    /**
     * Returns a dropdown field for the user to select a variant.
     *
     * @param string    $emptyString
     * @param ArrayList $values
     *
     * @return DropdownField
     */
    public function getDropDownField($emptystring = null, $values = null)
    {
        $values = ($values) ? $values : $this->Values()->sort(['Sort' => 'ASC', 'Value' => 'ASC']);

        if ($values->exists()) {
            $field = DropdownField::create(
                'ProductAttributes[' . $this->ID . ']',
                $this->Name,
                $values->map('ID', 'Value')
            );

            if ($emptystring) {
                $field->setEmptyString($emptystring);
            }

            return $field;
        }

        return null;
    }

    public function onBeforeWrite()
    {
        parent::onBeforeWrite();
        if ($this->Name && !$this->Label) {
            $this->Label = $this->Name;
        } elseif ($this->Label && !$this->Name) {
            $this->Name = $this->Label;
        }
    }

    public function canDelete($member = null)
    {
        //TODO: prevent deleting if has been used
        return true;
    }
}


<?php

namespace SilverShop\Page;

use Page;
use SilverShop\Extension\ProductVariationsExtension;
use SilverStripe\i18n\i18nEntityProvider;
use SilverStripe\ORM\DataList;

/**
 * Product Category provides a way to hierartically categorise products.
 *
 * It contains functions for versioning child products
 *
 * @package shop
 */
class ProductCategory extends Page implements i18nEntityProvider
{
    private static $belongs_many_many = [
        'Products' => Product::class,
    ];

    private static $singular_name = 'Category';

    private static $plural_name = 'Categories';

    private static $icon = 'silvershop/core: client/dist/images/icons/folder.gif';

    private static $table_name = 'SilverShop_ProductCategory';

    private static $default_child = 'Product';

    private static $include_child_groups = true;

    private static $page_length = 12;

    private static $must_have_price = true;

    private static $sort_options = [
        'Alphabetical' => 'URLSegment',
        'Price' => 'BasePrice',
    ];

    /**
     * Retrieve a set of products, based on the given parameters. Checks get query for sorting and pagination.
     *
     * @param bool $recursive include sub-categories
     *
     * @return DataList
     */
    public function ProductsShowable($recursive = true)
    {
        // Figure out the categories to check
        $groupids = array($this->ID);
        if (!empty($recursive) && self::config()->include_child_groups) {
            $groupids += $this->AllChildCategoryIDs();
        }
        $products = Product::get()->filterAny(
            [
            'ParentID' => $groupids,
            'ProductCategories.ID' => $groupids
            ]
        );
        if (self::config()->must_have_price) {
            if (Product::has_extension(ProductVariationsExtension::class)) {
                $products = $products->filterAny(
                    [
                    'BasePrice:GreaterThan' => 0,
                    'Variations.Price:GreaterThan' => 0
                    ]
                );
            } else {
                $products = $products->filter('BasePrice:GreaterThan', 0);
            }
        }

        $this->extend('updateProductsShowable', $products);

        return $products;
    }

    /**
     * Loop down each level of children to get all ids.
     */
    public function AllChildCategoryIDs()
    {
        $ids = [$this->ID];
        $allids = [];
        do {
            $ids = ProductCategory::get()
                ->filter('ParentID', $ids)
                ->getIDList();
            $allids += $ids;
        } while (!empty($ids));

        return $allids;
    }

    /**
     * Return children ProductCategory pages of this category.
     *
     * @param bool $recursive
     *
     * @return DataList
     */
    public function ChildCategories($recursive = false)
    {
        $ids = array($this->ID);
        if ($recursive) {
            $ids += $this->AllChildCategoryIDs();
        }

        return ProductCategory::get()->filter('ParentID', $ids);
    }

    /**
     * Recursively generate a product menu, starting from the topmost category.
     *
     * @return DataList
     */
    public function GroupsMenu()
    {
        if ($this->Parent() instanceof ProductCategory) {
            return $this->Parent()->GroupsMenu();
        }
        return ProductCategory::get()
            ->filter('ParentID', $this->ID);
    }

    /**
     * Override the nested title defaults, to show deeper nesting in the CMS.
     *
     * @param integer $level     nesting level
     * @param string  $separator seperate nesting with this string
     */
    public function NestedTitle($level = 10, $separator = ' > ', $field = 'MenuTitle')
    {
        $item = $this;
        while ($item && $level > 0) {
            $parts[] = $item->{$field};
            $item = $item->Parent;
            $level--;
        }
        return implode($separator, array_reverse($parts));
    }

    public function provideI18nEntities()
    {
        $entities = parent::provideI18nEntities();

        // add the sort option keys
        foreach ($this->config()->sort_options as $key => $value) {
            $entities[__CLASS__ . '.' . $key] = array(
                $key,
                "Sort by the '$value' field",
            );
        }

        return $entities;
    }
}


<?php

namespace SilverShop\Forms\GridField;

use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
use SilverStripe\Forms\LiteralField;
use SilverStripe\SiteConfig\SiteConfig;
use SilverStripe\View\Requirements;

class OrderGridFieldDetailForm_ItemRequest extends GridFieldDetailForm_ItemRequest
{
    private static $allowed_actions = [
        'edit',
        'view',
        'ItemEditForm',
        'printorder',
    ];

    /**
     * Add print button to order detail form
     */
    public function ItemEditForm()
    {
        $form = parent::ItemEditForm();
        $printlink = $this->Link('printorder') . '?print=1';
        $printwindowjs = <<<JS
            window.open('$printlink', 'print_order', 'toolbar=0,scrollbars=1,location=1,statusbar=0,menubar=0,resizable=1,width=800,height=600,left = 50,top = 50');return false;
JS;
        $form->Actions()->push(
            LiteralField::create(
                'PrintOrder',
                "<button class=\"no-ajax grid-print-button btn action btn-primary font-icon-print\" onclick=\"javascript:$printwindowjs\">"
                . _t('SilverShop\Model\Order.Print', 'Print') . '</button>'
            )
        );

        return $form;
    }

    /**
     * Render order for printing
     */
    public function printorder()
    {
        Requirements::clear();
        //include print javascript, if print argument is provided
        if (isset($_REQUEST['print']) && $_REQUEST['print']) {
            Requirements::customScript('if(document.location.href.indexOf(\'print=1\') > 0) {window.print();}');
        }
        $title = _t('SilverShop\Model\Order.Invoice', 'Invoice');
        if ($id = $this->popupController->getRequest()->param('ID')) {
            $title .= " #$id";
        }

        return $this->record->customise(
            [
                'SiteConfig' => SiteConfig::current_site_config(),
                'Title'      => $title,
            ]
        )->renderWith('SilverShop\Admin\OrderAdmin_Printable');
    }
}


<?php

namespace SilverShop\Checkout\Step;

use SilverShop\Extension\SteppedCheckoutExtension;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extension;

/**
 * Base class for building steps for checkout processing
 */
class CheckoutStep extends Extension
{
    use Configurable;

    /**
     * Get the next step action
     *
     * @return string|NULL
     */
    private function nextstep()
    {
        $steps = $this->owner->getSteps();
        $found = false;
        foreach ($steps as $step => $class) {
            //determine if this is the current step
            if (method_exists($this, $step)) {
                $found = true;
            } elseif ($found) {
                return $step;
            }
        }
        return null;
    }

    public function NextStepLink($nextstep = null)
    {
        if (!$nextstep) {
            $nextstep = $this->nextstep();
        }
        $anchor = Config::inst()->get(SteppedCheckoutExtension::class, 'continue_anchor');
        $anchor = $anchor ? '#' . $anchor : '';
        return $this->owner->Link($nextstep) . $anchor;
    }
}


<?php

namespace SilverShop\Admin;

use SilverShop\Forms\GridField\OrderGridFieldDetailForm_ItemRequest;
use SilverShop\Model\Order;
use SilverShop\Model\OrderStatusLog;
use SilverStripe\Admin\ModelAdmin;
use SilverStripe\Forms\GridField\GridFieldAddNewButton;
use SilverStripe\Forms\GridField\GridFieldDetailForm;
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
use SilverStripe\ORM\DataObject;

/**
 * Order administration interface, based on ModelAdmin
 *
 * @package SilverShop\Admin
 */
class OrdersAdmin extends ModelAdmin
{
    private static $url_segment = 'orders';

    private static $menu_title = 'Orders';

    private static $menu_priority = 1;

    private static $menu_icon_class = 'silvershop-icon-cart';

    private static $managed_models = [
        Order::class,
        OrderStatusLog::class
    ];

    private static $model_importers = array();

    /**
     * Restrict list to non-hidden statuses
     */
    public function getList()
    {
        $list = parent::getList();

        if ($this->modelClass == Order::class) {
            // Exclude hidden statuses
            $list = $list->exclude('Status', Order::config()->hidden_status);
            $this->extend('updateList', $list);
        }

        return $list;
    }

    /**
     * Replace gridfield detail form to include print functionality
     */
    public function getEditForm($id = null, $fields = null)
    {
        $form = parent::getEditForm($id, $fields);
        if ($this->modelClass == Order::class) {
            /** @var GridFieldConfig $config */
            $config = $form
                ->Fields()
                ->fieldByName($this->sanitiseClassName($this->modelClass))
                ->getConfig();

            $config
                ->getComponentByType(GridFieldSortableHeader::class)
                ->setFieldSorting([ 'StatusI18N' => 'Status' ]);

            $config
                ->getComponentByType(GridFieldDetailForm::class)
                ->setItemRequestClass(OrderGridFieldDetailForm_ItemRequest::class); //see below
        }

        if ($this->modelClass == OrderStatusLog::class) {
            /** @var GridFieldConfig $config */
            $config = $form
                ->Fields()
                ->fieldByName($this->sanitiseClassName($this->modelClass))
                ->getConfig();

            // Remove add new button
            $config->removeComponentsByType($config->getComponentByType(GridFieldAddNewButton::class));
        }

        return $form;
    }
}


<?php

namespace SilverShop\Checkout;

use SilverShop\Model\Address;
use SilverShop\Page\CheckoutPage;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\ConfirmedPasswordField;
use SilverStripe\Forms\EmailField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\HeaderField;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\OptionsetField;
use SilverStripe\Forms\PasswordField;
use SilverStripe\Forms\TextareaField;
use SilverStripe\Forms\TextField;
use SilverStripe\Omnipay\GatewayInfo;
use SilverStripe\Security\Member;
use SilverStripe\SiteConfig\SiteConfig;

/**
 * Factory for generating checkout fields
 *
 * @todo
 */
class CheckoutFieldFactory
{
    private static $inst;

    public static function singleton()
    {
        if (!self::$inst) {
            self::$inst = new CheckoutFieldFactory();
        }
        return self::$inst;
    }

    //prevent instantiation
    private function __construct()
    {
    }

    public function getContactFields($subset = array())
    {
        return $this->getSubset(
            FieldList::create(
                TextField::create('FirstName', _t('SilverShop\Model\Order.db_FirstName', 'First Name')),
                TextField::create('Surname', _t('SilverShop\Model\Order.db_Surname', 'Surname')),
                EmailField::create('Email', _t('SilverShop\Model\Order.db_Email', 'Email'))
            ),
            $subset
        );
    }

    public function getAddressFields($type = "shipping", $subset = array())
    {
        $address = singleton(Address::class);
        $fields = $address->getFormFields($type);
        return $this->getSubset($fields, $subset);
    }

    public function getMembershipFields()
    {
        $fields = $this->getContactFields();
        $idfield = Member::config()->unique_identifier_field;
        if (!$fields->fieldByName($idfield)) {
            $fields->push(TextField::create($idfield, $idfield)); //TODO: scaffold the correct id field
        }
        $fields->push($this->getPasswordField());
        return $fields;
    }

    public function getPasswordFields()
    {
        $loginlink = "Security/login?BackURL=" . CheckoutPage::find_link(true);
        $fields = FieldList::create(
            HeaderField::create(_t('SilverShop\Checkout\CheckoutField.MembershipDetails', 'Membership Details'), 3),
            LiteralField::create(
                'MemberInfo',
                '
' .
                _t(
                    'SilverShop\Checkout\CheckoutField.MemberLoginInfo',
                    'If you are already a member please log in',
                    '',
                    array('LoginUrl' => $loginlink)
                ) .
                '
'
            ),
            LiteralField::create(
                'AccountInfo',
                '
' . _t(
                    'SilverShop\Checkout\CheckoutField.AccountInfo',
                    'Please choose a password, so you can login and check your order history in the future'
                ) . '
'
            ),
            $pwf = $this->getPasswordField()
        );
        if (!Checkout::membership_required()) {
            $pwf->setCanBeEmpty(true);
        }
        return $fields;
    }

    public function getPaymentMethodFields()
    {
        //TODO: only get one field if there is no option
        return OptionsetField::create(
            'PaymentMethod',
            _t('SilverShop\Checkout\CheckoutField.PaymentType', "Payment Type"),
            GatewayInfo::getSupportedGateways(),
            array_keys(GatewayInfo::getSupportedGateways())
        );
    }

    public function getPasswordField($confirmed = true)
    {
        if ($confirmed) {
            return ConfirmedPasswordField::create('Password', _t('SilverShop\Checkout\CheckoutField.Password', 'Password'));
        }
        return PasswordField::create('Password', _t('SilverShop\Checkout\CheckoutField.Password', 'Password'));
    }

    public function getNotesField()
    {
        return TextareaField::create("Notes", _t("SilverShop\Model\Order.db_Notes", "Message"));
    }

    public function getTermsConditionsField()
    {
        $field = null;

        if (SiteConfig::current_site_config()->TermsPage()->exists()) {
            $termsPage = SiteConfig::current_site_config()->TermsPage();

            $field = CheckboxField::create(
                'ReadTermsAndConditions',
                _t(
                    'SilverShop\Checkout\Checkout.TermsAndConditionsLink',
                    'I agree to the terms and conditions stated on the {TermsPageTitle} page',
                    '',
                    array('TermsPageLink' => $termsPage->Link(), 'TermsPageTitle' => $termsPage->Title)
                )
            );
        }

        return $field;
    }

    /**
     * Helper function for reducing a field set to a given subset,
     * in the given order.
     *
     * @param FieldList $fields form fields to take a subset from.
     * @param array     $subset list of field names to return as subset
     *
     * @return FieldList subset of form fields
     */
    private function getSubset(FieldList $fields, $subset = array())
    {
        if (empty($subset)) {
            return $fields;
        }
        $subfieldlist = FieldList::create();
        foreach ($subset as $field) {
            if ($field = $fields->fieldByName($field)) {
                $subfieldlist->push($field);
            }
        }
        return $subfieldlist;
    }
}


<?php

namespace SilverShop\Model\Modifiers;

use SilverShop\Model\Order;
use SilverShop\Model\OrderAttribute;
use SilverStripe\ORM\FieldType\DBCurrency;
use SilverStripe\ORM\FieldType\DBEnum;

/**
 * The OrderModifier class is a databound object for
 * handling the additional charges or deductions of
 * an order.
 *
 * @property DBCurrency $Amount
 * @property DBEnum $Type
 * @property int $Sort
 */
class OrderModifier extends OrderAttribute
{
    private static $db = [
        'Amount' => 'Currency',
        'Type' => "Enum('Chargable,Deductable,Ignored','Chargable')",
        'Sort' => 'Int',
    ];

    private static $defaults = [
        'Type' => 'Chargable',
    ];

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

    private static $searchable_fields = [
        'OrderID' => array(
            'title' => 'Order ID',
            'field' => 'TextField',
        ),
        'Title' => 'PartialMatchFilter',
        'TableTitle' => 'PartialMatchFilter',
        'CartTitle' => 'PartialMatchFilter',
        'Amount',
        'Type',
    ];

    private static $summary_fields = [
        'Order.ID' => 'Order ID',
        'TableTitle' => 'Table Title',
        'ClassName' => 'Type',
        'Amount' => 'Amount',
        'Type' => 'Type',
    ];

    private static $singular_name = 'Modifier';

    private static $plural_name = 'Modifiers';

    private static $default_sort = '"Sort" ASC, "Created" ASC';

    private static $table_name = 'SilverShop_OrderModifier';

    /**
     * Specifies whether this modifier is always required in an order.
     */
    public function required()
    {
        return true;
    }

    /**
     * Modifies the incoming value by adding,
     * subtracting or ignoring the value this modifier calculates.
     *
     * Sets $this->Amount to the calculated value;
     *
     * @param $subtotal - running total to be modified
     * @param $forcecalculation - force calculating the value, if order isn't in cart
     *
     * @return $subtotal - updated subtotal
     */
    public function modify($subtotal, $forcecalculation = false)
    {
        $order = $this->Order();
        $value = ($order->IsCart() || $forcecalculation) ? $this->value($subtotal) : $this->Amount;
        switch ($this->Type) {
            case 'Chargable':
                $subtotal += $value;
                break;
            case 'Deductable':
                $subtotal -= $value;
                break;
            case 'Ignored':
                break;
        }
        $value = round($value, Order::config()->rounding_precision);
        $this->Amount = $value;
        return $subtotal;
    }

    /**
     * Calculates value to store, based on incoming running total.
     *
     * @param float $incoming the incoming running total.
     */
    public function value($incoming)
    {
        return 0;
    }

    /**
     * Check if the modifier should be in the cart.
     */
    public function valid()
    {
        $order = $this->Order();
        if (!$order) {
            return false;
        }
        return true;
    }

    /**
     * This function is always called to determine the
     * amount this modifier needs to charge or deduct.
     *
     * If the modifier exists in the DB, in which case it
     * already exists for a given order, we just return
     * the Amount data field from the DB. This is for
     * existing orders.
     *
     * If this is a new order, and the modifier doesn't
     * exist in the DB ($this->ID is 0), so we return
     * the amount from $this->LiveAmount() which is a
     * calculation based on the order and it's items.
     */
    public function Amount()
    {
        return $this->Amount;
    }

    /**
     * Monetary to use in templates.
     */
    public function TableValue()
    {
        return $this->Total();
    }

    /**
     * Provides a modifier total that is positive or negative, depending on whether the modifier is chargable or not.
     *
     * @return boolean
     */
    public function Total()
    {
        if ($this->Type == 'Deductable') {
            return $this->Amount * -1;
        }
        return $this->Amount;
    }

    /**
     * Checks if this modifier has type = Chargable
     *
     * @return boolean
     */
    public function IsChargable()
    {
        return $this->Type == 'Chargable';
    }

    /**
     * Checks if the modifier can be removed.
     *
     * @return boolean
     */
    public function canRemove()
    {
        return false;
    }
}


<?php

namespace SilverShop\Extension;

use SilverShop\Cart\ShoppingCart;
use SilverShop\Checkout\Step\Address;
use SilverShop\Checkout\Step\ContactDetails;
use SilverShop\Checkout\Step\Membership;
use SilverShop\Checkout\Step\PaymentMethod;
use SilverShop\Checkout\Step\Summary;
use SilverShop\Page\CheckoutPage;
use SilverShop\Page\CheckoutPageController;
use SilverStripe\Control\Controller;
use SilverStripe\Core\Extension;

/**
 * Stepped checkout provides multiple forms and actions for placing an order
 *
 * @package    shop
 * @subpackage steppedcheckout
 */
class SteppedCheckoutExtension extends Extension
{
    /**
     * Anchor string to add to continue links
     *
     * @config
     * @var    string
     */
    private static $continue_anchor;

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

    /**
     * Set up CheckoutPage_Controller decorators for managing steps
     */
    public static function setupSteps($steps = null)
    {
        if (!is_array($steps)) {
            //default steps
            $steps =[
                'membership'      => Membership::class,
                'contactdetails'  => ContactDetails::class,
                'shippingaddress' => Address::class,
                'billingaddress'  => Address::class,
                'paymentmethod'   => PaymentMethod::class,
                'summary'         => Summary::class,
            ];
        }

        CheckoutPage::config()->steps = $steps;

        if (!CheckoutPage::config()->first_step) {
            reset($steps);
            CheckoutPage::config()->first_step = key($steps);
        }
        //initiate extensions
        CheckoutPageController::add_extension(SteppedCheckoutExtension::class);
        foreach ($steps as $action => $classname) {
            CheckoutPageController::add_extension($classname);
        }
    }

    public function getSteps()
    {
        return CheckoutPage::config()->steps;
    }

    /**
     * Redirect back to start of checkout if no cart started
     */
    public function onAfterInit()
    {
        $action = $this->owner->getRequest()->param('Action');
        $steps = $this->getSteps();
        if (!ShoppingCart::curr() && !empty($action) && isset($steps[$action])) {
            Controller::curr()->redirect($this->owner->Link());
            return;
        }
    }

    /**
     * Check if passed action is the same as the current step
     */
    public function IsCurrentStep($name)
    {
        if ($this->owner->getAction() === $name) {
            return true;
        } elseif (!$this->owner->getAction() || $this->owner->getAction() === 'index') {
            return $this->actionPos($name) === 0;
        }
        return false;
    }

    public function StepExists($name)
    {
        $steps = $this->getSteps();
        return isset($steps[$name]);
    }

    /**
     * Check if passed action is for a step before current
     */
    public function IsPastStep($name)
    {
        return $this->compareActions($name, $this->owner->getAction()) < 0;
    }

    /**
     * Check if passed action is for a step after current
     */
    public function IsFutureStep($name)
    {
        return $this->compareActions($name, $this->owner->getAction()) > 0;
    }

    /**
     * Get first step from stored steps
     */
    public function index()
    {
        if (CheckoutPage::config()->first_step) {
            return $this->owner->{CheckoutPage::config()->first_step}();
        }
        return array();
    }

    /**
     * Check if one step comes before or after the another
     */
    private function compareActions($action1, $action2)
    {
        return $this->actionPos($action1) - $this->actionPos($action2);
    }

    /**
     * Get the numerical position of a step
     */
    private function actionPos($incoming)
    {
        $count = 0;
        foreach ($this->getSteps() as $action => $step) {
            if ($action == $incoming) {
                return $count;
            }
            $count++;
        }
    }
}


<?php

namespace SilverShop\Page;

use Page;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Control\Controller;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\TreeDropdownField;
use SilverStripe\ORM\DB;

/**
 * View and edit the cart in a full page.
 * Visitor can continue shopping, or proceed to checkout.
 */
class CartPage extends Page
{
    private static $has_one = [
        'CheckoutPage' => CheckoutPage::class,
        'ContinuePage' => SiteTree::class,
    ];

    private static $icon = 'silvershop/core: client/dist/images/icons/cart.gif';

    private static $table_name = 'SilverShop_CartPage';

    /**
     * Returns the link to the checkout page on this site
     *
     * @param boolean $urlSegment If set to TRUE, only returns the URLSegment field
     *
     * @return string Link to checkout page
     */
    public static function find_link($urlSegment = false, $action = false, $id = false)
    {
        $base = CartPageController::config()->url_segment;
        if ($page = self::get()->first()) {
            $base = $page->Link();
        }
        return Controller::join_links($base, $action, $id);
    }

    public function getCMSFields()
    {
        $this->beforeUpdateCMSFields(
            function (FieldList $fields) {
                if ($checkouts = CheckoutPage::get()) {
                    $fields->addFieldToTab(
                        'Root.Links',
                        DropdownField::create(
                            'CheckoutPageID',
                            $this->fieldLabel('CheckoutPage'),
                            $checkouts->map("ID", "Title")
                        )
                    );
                }

                $fields->addFieldToTab(
                    'Root.Links',
                    TreeDropdownField::create(
                        'ContinuePageID',
                        $this->fieldLabel('ContinuePage'),
                        SiteTree::class
                    )
                );
            }
        );

        return parent::getCMSFields();
    }

    /**
     * This module always requires a page model.
     */
    public function requireDefaultRecords()
    {
        parent::requireDefaultRecords();
        if (!self::get()->exists() && $this->config()->create_default_pages) {
            $page = self::create()->update(
                [
                    'Title' => 'Shopping Cart',
                    'URLSegment' => CartPageController::config()->url_segment,
                    'ShowInMenus' => 0,
                ]
            );
            $page->write();
            $page->publishSingle();
            $page->flushCache();
            DB::alteration_message('Cart page created', 'created');
        }
    }
}


<?php

namespace SilverShop\Page;

use PageController;
use SilverShop\ListSorter\ListSorter;
use SilverStripe\ORM\PaginatedList;

class ProductCategoryController extends PageController
{
    /**
     * Return the products for this group.
     */
    public function Products($recursive = true)
    {
        $products = $this->ProductsShowable($recursive);

        //sort the products
        $products = $this->getSorter()->sortList($products);

        //paginate the products, if necessary
        $pagelength = ProductCategory::config()->page_length;
        if ($pagelength > 0) {
            $products = PaginatedList::create($products, $this->request);
            $products->setPageLength($pagelength);
            $products->TotalCount = $products->getTotalItems();
        }

        return $products;
    }

    /**
     * Return products that are featured, that is products that have "FeaturedProduct = 1"
     */
    public function FeaturedProducts($recursive = true)
    {
        return $this->ProductsShowable($recursive)
            ->filter('Featured', true);
    }

    /**
     * Return products that are not featured, that is products that have "FeaturedProduct = 0"
     */
    public function NonFeaturedProducts($recursive = true)
    {
        return $this->ProductsShowable($recursive)
            ->filter('Featured', false);
    }

    /**
     * Sorting controls
     *
     * @return ListSorter sorter
     */
    public function getSorter()
    {
        $options = array();
        foreach (ProductCategory::config()->sort_options as $k => $v) {
            // make the label translatable
            $k = _t(ProductCategory::class . '.' . $k, $k);
            $options[$k] = $v;
        }

        $sorter = ListSorter::create($this->request, $options);
        $this->extend('updateSorter', $sorter);

        return $sorter;
    }
}


<?php

namespace SilverShop\ORM\FieldType;

use SilverStripe\ORM\FieldType\DBCurrency;

/**
 * Improvements to Currency for presenting in templates.
 *
 * @package shop
 */
class ShopCurrency extends DBCurrency
{
    /**
     * The shop currency decimal delimiter
     *
     * @config
     * @var    string
     */
    private static $decimal_delimiter = '.';

    /**
     * The shop currency thousands delimiter
     *
     * @config
     * @var    string
     */
    private static $thousand_delimiter = ',';

    /**
     * Whether or not to append the currency symbol to
     *
     * @config
     * @var    string
     */
    private static $append_symbol = false;

    /**
     * HTML to use for negative numbers
     *
     * @config
     * @var    string
     */
    private static $negative_value_format = '(%s)';

    private static $casting = [
        'forTemplate' => 'HTMLFragment',
        'Nice' => 'HTMLFragment',
        'NiceOrEmpty' => 'HTMLFragment',
    ];

    public function Nice()
    {
        $symbol = $this->config()->currency_symbol;
        $val = number_format(
            abs($this->value),
            2,
            self::config()->decimal_delimiter,
            self::config()->thousand_delimiter
        );
        if ($this->config()->append_symbol) {
            $val = $val . ' ' . $symbol;
        } else {
            $val = $symbol . $val;
        }
        if ($this->value < 0) {
            return sprintf(self::config()->negative_value_format, $val);
        }

        return $val;
    }

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

    /**
     * If no cents on the price, trim those off.
     *
     * @return string
     */
    public function TrimCents()
    {
        $val = $this->value;

        if (floor($val) == $val) {
            return floor($val);
        }

        return $val;
    }

    public function NiceOrEmpty()
    {
        if ($this->value != 0) {
            return $this->Nice();
        }
        return '';
    }
}


<?php

namespace SilverShop\Page;

use Page;
use SilverShop\Cart\ShoppingCart;
use SilverShop\Cart\ShoppingCartController;
use SilverShop\Extension\ProductVariationsExtension;
use SilverShop\Forms\AddProductForm;
use SilverShop\Model\Buyable;
use SilverShop\Model\Order;
use SilverShop\Model\Product\OrderItem;
use SilverShop\Model\Variation\Variation;
use SilverStripe\AssetAdmin\Forms\UploadField;
use SilverStripe\Assets\Image;
use SilverStripe\Control\Director;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\ListboxField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\FieldType\DBBoolean;
use SilverStripe\ORM\FieldType\DBCurrency;
use SilverStripe\ORM\FieldType\DBDecimal;
use SilverStripe\ORM\ManyManyList;
use SilverStripe\Security\Member;
use SilverStripe\SiteConfig\SiteConfig;

/**
 * This is a standard Product page-type with fields like
 * Price, Weight, Model and basic management of
 * groups.
 *
 * It also has an associated Product_OrderItem class,
 * an extension of OrderItem, which is the mechanism
 * that links this page type class to the rest of the
 * eCommerce platform. This means you can add an instance
 * of this page type to the shopping cart.
 *
 * @mixin ProductVariationsExtension
 *
 * @property string $InternalItemID
 * @property string $Model
 * @property DBCurrency $BasePrice
 * @property DBDecimal $Weight
 * @property DBDecimal $Height
 * @property DBDecimal $Width
 * @property DBDecimal $Depth
 * @property bool $Featured
 * @property bool $AllowPurchase
 * @property float $Popularity
 * @property int $ImageID
 *
 * @method ProductCategory[]|ManyManyList ProductCategories()
 */
class Product extends Page implements Buyable
{
    private static $db = [
        'InternalItemID' => 'Varchar(30)', //ie SKU, ProductID etc (internal / existing recognition of product)
        'Model' => 'Varchar(30)',

        'BasePrice' => 'Currency(19,4)', // Base retail price the item is marked at.

        //physical properties
        // TODO: Move these to an extension (used in Variations as well)
        'Weight' => 'Decimal(12,5)',
        'Height' => 'Decimal(12,5)',
        'Width' => 'Decimal(12,5)',
        'Depth' => 'Decimal(12,5)',

        'Featured' => 'Boolean',
        'AllowPurchase' => 'Boolean',

        'Popularity' => 'Float' //storage for CalculateProductPopularity task
    ];

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

    private static $owns = [
        'Image'
    ];

    private static $many_many = [
        'ProductCategories' => ProductCategory::class,
    ];

    private static $defaults = [
        'AllowPurchase' => true,
        'ShowInMenus' => false,
    ];

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

    private static $summary_fields = [
        'InternalItemID',
        'Title',
        'BasePrice.NiceOrEmpty',
        'IsPurchaseable.Nice',
    ];

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

    private static $table_name = 'SilverShop_Product';

    private static $singular_name = 'Product';

    private static $plural_name = 'Products';

    private static $icon = 'silvershop/core: client/dist/images/icons/package.gif';

    private static $default_parent = ProductCategory::class;

    private static $default_sort = '"Title" ASC';

    private static $global_allow_purchase = true;

    private static $allow_zero_price = false;

    private static $order_item = OrderItem::class;

    // Physical Measurement
    private static $weight_unit = 'kg';

    private static $length_unit = 'cm';

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

    /**
     * Add product fields to CMS
     *
     * @return FieldList updated field list
     */
    public function getCMSFields()
    {
        $self = $this;

        $this->beforeUpdateCMSFields(
            function (FieldList $fields) use ($self) {
                $fields->fieldByName('Root.Main.Title')
                    ->setTitle(_t(__CLASS__ . '.PageTitle', 'Product Title'));

                $fields->addFieldsToTab('Root.Main', [
                    TextField::create('InternalItemID', _t(__CLASS__ . '.InternalItemID', 'Product Code/SKU'), '', 30),
                    DropdownField::create('ParentID', _t(__CLASS__ . '.Category', 'Category'), $self->getCategoryOptions())
                        ->setDescription(_t(__CLASS__ . '.CategoryDescription', 'This is the parent page or default category.')),
                    ListboxField::create(
                        'ProductCategories',
                        _t(__CLASS__ . '.AdditionalCategories', 'Additional Categories'),
                        $self->getCategoryOptionsNoParent()
                    ),
                    TextField::create('Model', _t(__CLASS__ . '.Model', 'Model'), '', 30),
                    CheckboxField::create('Featured', _t(__CLASS__ . '.Featured', 'Featured Product')),
                    CheckboxField::create('AllowPurchase', _t(__CLASS__ . '.AllowPurchase', 'Allow product to be purchased'), 1),
                ], 'Content');

                $fields->addFieldsToTab(
                    'Root.Pricing',
                    [
                        TextField::create('BasePrice', $this->fieldLabel('BasePrice'))
                        ->setDescription(_t(__CLASS__ . '.PriceDesc', 'Base price to sell this product at.'))
                        ->setMaxLength(12),
                    ]
                );

                $fieldSubstitutes = [
                    'LengthUnit' => $self::config()->length_unit
                ];

                $fields->addFieldsToTab(
                    'Root.Shipping',
                    [
                    TextField::create(
                        'Weight',
                        _t(
                            __CLASS__ . '.WeightWithUnit',
                            'Weight ({WeightUnit})',
                            '',
                            [
                            'WeightUnit' => self::config()->weight_unit
                            ]
                        ),
                        '',
                        12
                    ),
                    TextField::create(
                        'Height',
                        _t(__CLASS__ . '.HeightWithUnit', 'Height ({LengthUnit})', '', $fieldSubstitutes),
                        '',
                        12
                    ),
                    TextField::create(
                        'Width',
                        _t(__CLASS__ . '.WidthWithUnit', 'Width ({LengthUnit})', '', $fieldSubstitutes),
                        '',
                        12
                    ),
                    TextField::create(
                        'Depth',
                        _t(__CLASS__ . '.DepthWithUnit', 'Depth ({LengthUnit})', '', $fieldSubstitutes),
                        '',
                        12
                    ),
                    ]
                );

                if (!$fields->dataFieldByName('Image')) {
                    $fields->addFieldToTab(
                        'Root.Images',
                        UploadField::create('Image', _t(__CLASS__ . '.Image', 'Product Image'))
                    );
                }
            }
        );

        return parent::getCMSFields();
    }

    /**
     * Add missing translations to the fieldLabels
     */
    public function fieldLabels($includerelations = true)
    {
        $labels = parent::fieldLabels($includerelations);

        $labels['Title'] = _t(__CLASS__ . '.PageTitle', 'Product Title');
        $labels['IsPurchaseable'] = $labels['IsPurchaseable.Nice'] = _t(__CLASS__ . '.IsPurchaseable', 'Is Purchaseable');
        $labels['BasePrice.NiceOrEmpty'] = _t(__CLASS__ . '.db_BasePrice', 'Price');

        return $labels;
    }

    /**
     * Helper function for generating list of categories to select from.
     *
     * @return array categories
     */
    private function getCategoryOptions()
    {
        $categories = ProductCategory::get()->map('ID', 'NestedTitle')->toArray();
        $categories = [
            0 => _t('SilverStripe\CMS\Model\SiteTree.PARENTTYPE_ROOT', 'Top-level page'),
        ] + $categories;
        if ($this->ParentID && !($this->Parent() instanceof ProductCategory)) {
            $categories = [
                $this->ParentID => $this->Parent()->Title . ' (' . $this->Parent()->i18n_singular_name() . ')',
            ] + $categories;
        }

        return $categories;
    }

    /**
     * Helper function for generating a list of additional categories excluding the main parent.
     *
     * @return array categories
     */
    private function getCategoryOptionsNoParent()
    {
        $ancestors = $this->getAncestors()->column('ID');
        $categories = ProductCategory::get();
        if (!empty($ancestors)) {
            $categories = $categories->exclude('ID', $ancestors);
        }
        return $categories->map('ID', 'NestedTitle')->toArray();
    }

    /**
     * Get ids of all categories that this product appears in.
     *
     * @return array ids list
     */
    public function getCategoryIDs()
    {
        $ids = array();
        //ancestors
        foreach ($this->getAncestors() as $ancestor) {
            $ids[$ancestor->ID] = $ancestor->ID;
        }
        //additional categories
        $ids += $this->ProductCategories()->getIDList();

        return $ids;
    }

    /**
     * Get all categories that this product appears in.
     *
     * @return DataList category data list
     */
    public function getCategories()
    {
        return ProductCategory::get()->byIDs($this->getCategoryIDs());
    }

    /**
     * Conditions for whether a product can be purchased:
     *  - global allow purchase is enabled
     *  - product AllowPurchase field is true
     *  - if variations, then one of them needs to be purchasable
     *  - if not variations, selling price must be above 0
     *
     * Other conditions may be added by decorating with the canPurchase function
     *
     * @param Member $member
     * @param int    $quantity
     *
     * @return boolean
     */
    public function canPurchase($member = null, $quantity = 1)
    {
        $global = self::config()->global_allow_purchase;
        if (!$global || !$this->AllowPurchase) {
            return false;
        }
        $allowpurchase = false;
        $extension = self::has_extension(ProductVariationsExtension::class);
        if ($extension && Variation::get()->filter('ProductID', $this->ID)->first()) {
            foreach ($this->Variations() as $variation) {
                if ($variation->canPurchase($member, $quantity)) {
                    $allowpurchase = true;
                    break;
                }
            }
        } else {
            $allowpurchase = ($this->sellingPrice() > 0 || self::config()->allow_zero_price);
        }

        // Standard mechanism for accepting permission changes from decorators
        $permissions = $this->extend('canPurchase', $member, $quantity);
        $permissions[] = $allowpurchase;
        return min($permissions);
    }

    /**
     * Returns the purchaseable flag as `DBBoolean`. Useful for templates or summaries.
     * @return DBBoolean
     */
    public function IsPurchaseable()
    {
        return DBBoolean::create_field(DBBoolean::class, $this->canPurchase());
    }

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

    /**
     * Returns the order item which contains the product
     *
     * @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();
        }
        $this->extend('updateDummyItem', $item);
        return $item;
    }

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

    /**
     * The raw retail price the visitor will get when they
     * add to cart. Can include discounts or markups on the base price.
     */
    public function sellingPrice()
    {
        $price = $this->BasePrice;
        //TODO: this is not ideal, because prices manipulations will not happen in a known order
        $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);
    }

    /**
     * This value is cased to Currency in temlates.
     */
    public function getPrice()
    {
        return $this->sellingPrice();
    }

    public function setPrice($price)
    {
        $price = $price < 0 ? 0 : $price;
        $this->setField('BasePrice', $price);
    }

    /**
     * Allow orphaned products to be viewed.
     */
    public function isOrphaned()
    {
        return false;
    }

    public function Link($action = null)
    {
        $link = parent::Link($action);
        $this->extend('updateLink', $link);
        return $link;
    }

    /**
     * If the product does not have an image, and a default image
     * is defined in SiteConfig, return that instead.
     *
     * @return Image
     * @throws \Exception
     */
    public function Image()
    {
        $image = $this->getComponent('Image');
        $this->extend('updateImage', $image);

        if ($image && $image->exists()) {
            return $image;
        }
        $image = SiteConfig::current_site_config()->DefaultProductImage();
        if ($image && $image->exists()) {
            return $image;
        }
        return null;
    }

    /**
     * Link to add this product to cart.
     *
     * @return string link
     */
    public function addLink()
    {
        return ShoppingCartController::add_item_link($this);
    }

    /**
     * Link to remove one of this product from cart.
     *
     * @return string link
     */
    public function removeLink()
    {
        return ShoppingCartController::remove_item_link($this);
    }

    /**
     * Link to remove all of this product from cart.
     *
     * @return string link
     */
    public function removeallLink()
    {
        return ShoppingCartController::remove_all_item_link($this);
    }

    /**
     * Get the form class to use to edit this product in the frontend
     * @return string FQCN
     */
    public function getFormClass()
    {
        $formClass = AddProductForm::class;
        $this->extend('updateFormClass', $formClass);
        return $formClass;
    }
}


<?php

namespace SilverShop\Forms;

use SilverShop\Checkout\Checkout;
use SilverShop\Checkout\CheckoutComponentConfig;
use SilverShop\Checkout\Component\CheckoutComponentNamespaced;
use SilverShop\Checkout\Component\OnsitePayment;
use SilverShop\Checkout\OrderProcessor;
use SilverShop\Model\Order;
use SilverStripe\Omnipay\GatewayFieldsFactory;
use SilverStripe\Omnipay\GatewayInfo;

class PaymentForm extends CheckoutForm
{
    /**
     * @var string URL to redirect the user to on payment success.
     * Not the same as the "confirm" action in {@link PaymentGatewayController}.
     */
    protected $successlink;

    /**
     * @var string URL to redirect the user to on payment failure.
     * Not the same as the "cancel" action in {@link PaymentGatewayController}.
     */
    protected $failurelink;

    /**
     * @var OrderProcessor
     */
    protected $orderProcessor;

    public function __construct($controller, $name, CheckoutComponentConfig $config)
    {
        parent::__construct($controller, $name, $config);

        $this->orderProcessor = OrderProcessor::create($config->getOrder());
    }

    public function setSuccessLink($link)
    {
        $this->successlink = $link;
    }

    public function getSuccessLink()
    {
        return $this->successlink;
    }

    public function setFailureLink($link)
    {
        $this->failurelink = $link;
    }

    public function getFailureLink()
    {
        return $this->failurelink;
    }

    public function checkoutSubmit($data, $form)
    {
        // form validation has passed by this point, so we can save data
        $this->config->setData($form->getData());
        $order = $this->config->getOrder();
        $gateway = Checkout::get($order)->getSelectedPaymentMethod(false);
        if (GatewayInfo::isOffsite($gateway)
            || GatewayInfo::isManual($gateway)
            || $this->config->hasComponentWithPaymentData()
        ) {
            return $this->submitpayment($data, $form);
        }

        return $this->controller->redirect(
            $this->controller->Link('payment') //assumes CheckoutPage
        );
    }

    /**
     * Behaviour can be overwritten by creating a processPaymentResponse method
     * on the controller owning this form. It takes a Symfony\Component\HttpFoundation\Response argument,
     * and expects an SS_HTTPResponse in return.
     */
    public function submitpayment($data, $form)
    {
        $data = $form->getData();

        $cancelUrl = $this->getFailureLink() ? $this->getFailureLink() : $this->controller->Link();

        $order = $this->config->getOrder();

        // final recalculation, before making payment
        $order->calculate();

        // handle cases where order total is 0. Note that the order will appear
        // as "paid", but without a Payment record attached.
        if ($order->GrandTotal() == 0 && Order::config()->allow_zero_order_total) {
            if (!$this->orderProcessor->placeOrder()) {
                $form->sessionMessage($this->orderProcessor->getError());
                return $this->controller->redirectBack();
            }
            return $this->controller->redirect($this->getSuccessLink());
        }

        // try to place order before payment, if configured
        if (Order::config()->place_before_payment) {
            if (!$this->orderProcessor->placeOrder()) {
                $form->sessionMessage($this->orderProcessor->getError());
                return $this->controller->redirectBack();
            }
            $cancelUrl = $this->orderProcessor->getReturnUrl();
        }

        // if we got here from checkoutSubmit and there's a namespaced Component that provides payment data,
        // we need to strip the inputs down to only the checkout component.
        $components = $this->config->getComponents();
        if ($components->first() instanceof CheckoutComponentNamespaced) {
            foreach ($components as $component) {
                if ($component->Proxy()->providesPaymentData()) {
                    $data = array_merge($data, $component->unnamespaceData($data));
                }
            }
        }

        $gateway = Checkout::get($order)->getSelectedPaymentMethod(false);
        $fieldFactory = new GatewayFieldsFactory($gateway);

        // This is where the payment is actually attempted
        $paymentResponse = $this->orderProcessor->makePayment(
            $gateway,
            $fieldFactory->normalizeFormData($data),
            $this->getSuccessLink(),
            $cancelUrl
        );

        $response = null;
        if ($this->controller->hasMethod('processPaymentResponse')) {
            $response = $this->controller->processPaymentResponse($paymentResponse, $form);
        } elseif ($paymentResponse && !$paymentResponse->isError()) {
            $response = $paymentResponse->redirectOrRespond();
        } else {
            $form->sessionMessage($this->orderProcessor->getError(), 'bad');
            $response = $this->controller->redirectBack();
        }

        return $response;
    }

    /**
     * @param OrderProcessor $processor
     */
    public function setOrderProcessor(OrderProcessor $processor)
    {
        $this->orderProcessor = $processor;
    }

    /**
     * @return OrderProcessor
     */
    public function getOrderProcessor()
    {
        return $this->orderProcessor;
    }
}


<?php

namespace SilverShop\Forms;

use SilverShop\Checkout\OrderEmailNotifier;
use SilverShop\Checkout\OrderProcessor;
use SilverShop\Extension\ShopConfigExtension;
use SilverShop\Model\Order;
use SilverStripe\Core\Config\Config;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Forms\CompositeField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\HeaderField;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\OptionsetField;
use SilverStripe\Omnipay\GatewayFieldsFactory;
use SilverStripe\Omnipay\GatewayInfo;
use SilverStripe\ORM\FieldType\DBCurrency;
use SilverStripe\Security\Security;
use SilverStripe\View\Requirements;

/**
 * Perform actions on placed orders
 */
class OrderActionsForm extends Form
{
    private static $allowed_actions = [
        'docancel',
        'dopayment',
        'httpsubmission',
    ];

    private static $email_notification = false;

    private static $allow_paying = true;

    private static $allow_cancelling = true;

    private static $include_jquery = true;

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

    /**
     * OrderActionsForm constructor.
     *
     * @param  $controller
     * @param  $name
     * @param  Order      $order
     * @throws \SilverStripe\Omnipay\Exception\InvalidConfigurationException
     */
    public function __construct($controller, $name, Order $order)
    {
        $this->order = $order;
        $fields = FieldList::create(
            HiddenField::create('OrderID', '', $order->ID)
        );
        $actions = FieldList::create();
        //payment
        if (self::config()->allow_paying && $order->canPay()) {
            $gateways = GatewayInfo::getSupportedGateways();
            //remove manual gateways
            foreach ($gateways as $gateway => $gatewayname) {
                if (GatewayInfo::isManual($gateway)) {
                    unset($gateways[$gateway]);
                }
            }
            if (!empty($gateways)) {
                $fields->push(
                    HeaderField::create(
                        'MakePaymentHeader',
                        _t(__CLASS__ . '.MakePayment', 'Make Payment')
                    )
                );
                $outstandingfield = DBCurrency::create_field(DBCurrency::class, $order->TotalOutstanding(true));
                $fields->push(
                    LiteralField::create(
                        'Outstanding',
                        _t(
                            'SilverShop\Model\Order.OutstandingWithAmount',
                            'Outstanding: {Amount}',
                            '',
                            ['Amount' => $outstandingfield->Nice()]
                        )
                    )
                );
                $fields->push(
                    OptionsetField::create(
                        'PaymentMethod',
                        _t(__CLASS__ . '.PaymentMethod', 'Payment Method'),
                        $gateways,
                        key($gateways)
                    )
                );

                if ($ccFields = $this->getCCFields($gateways)) {
                    if ($this->config()->include_jquery) {
                        Requirements::javascript('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js');
                    }
                    Requirements::javascript('silvershop/core: client/dist/javascript/OrderActionsForm.js');
                    $fields->push($ccFields);
                }

                $actions->push(
                    FormAction::create(
                        'dopayment',
                        _t(__CLASS__ . '.PayOrder', 'Pay outstanding balance')
                    )->setUseButtonTag(Config::inst()->get(ShopConfigExtension::class, 'forms_use_button_tag'))
                );
            }
        }
        //cancelling
        if (self::config()->allow_cancelling && $order->canCancel()) {
            $actions->push(
                FormAction::create(
                    'docancel',
                    _t(__CLASS__ . '.CancelOrder', 'Cancel this order')
                )->setValidationExempt(true)
            );
        }

        parent::__construct(
            $controller,
            $name,
            $fields,
            $actions,
            OrderActionsFormValidator::create(
                [
                    'PaymentMethod'
                ]
            )
        );
        $this->extend('updateForm', $order);
    }

    /**
     * Make payment for a place order, where payment had previously failed.
     *
     * @param array $data
     * @param Form  $form
     *
     * @return HTTPResponse
     */
    public function dopayment($data, $form)
    {
        if (self::config()->allow_paying
            && $this->order
            && $this->order->canPay()
        ) {
            // Save payment data from form and process payment
            $data = $form->getData();
            $gateway = (!empty($data['PaymentMethod'])) ? $data['PaymentMethod'] : null;

            if (!GatewayInfo::isManual($gateway)) {
                $processor = OrderProcessor::create($this->order);
                $fieldFactory = new GatewayFieldsFactory(null);
                $response = $processor->makePayment(
                    $gateway,
                    $fieldFactory->normalizeFormData($data),
                    $processor->getReturnUrl()
                );
                if ($response && !$response->isError()) {
                    return $response->redirectOrRespond();
                } else {
                    $form->sessionMessage($processor->getError(), 'bad');
                }
            } else {
                $form->sessionMessage(_t(__CLASS__ . '.ManualNotAllowed', 'Manual payment not allowed'), 'bad');
            }

            return $this->controller->redirectBack();
        }
        $form->sessionMessage(
            _t(__CLASS__ . '.CouldNotProcessPayment', 'Payment could not be processed.'),
            'bad'
        );
        return $this->controller->redirectBack();
    }

    /**
     * Form action handler for CancelOrderForm.
     *
     * Take the order that this was to be change on,
     * and set the status that was requested from
     * the form request data.
     *
     * @param  array $data The form request data submitted
     * @param  Form  $form The {@link Form} this was submitted on
     * @throws \SilverStripe\ORM\ValidationException
     */
    public function docancel($data, $form)
    {
        if (self::config()->allow_cancelling
            && $this->order->canCancel()
        ) {
            $this->order->Status = 'MemberCancelled';
            $this->order->write();

            if (self::config()->email_notification) {
                OrderEmailNotifier::create($this->order)->sendCancelNotification();
            }

            $this->controller->sessionMessage(
                _t(__CLASS__ . '.OrderCancelled', 'Order sucessfully cancelled'),
                'warning'
            );
            if (Security::getCurrentUser() && $link = $this->order->Link()) {
                $this->controller->redirect($link);
            } else {
                $this->controller->redirectBack();
            }
        }
    }

    /**
     * Get credit card fields for the given gateways
     *
     * @param  array $gateways
     * @return CompositeField|null
     */
    protected function getCCFields(array $gateways)
    {
        $fieldFactory = new GatewayFieldsFactory(null, array('Card'));
        $onsiteGateways = array();
        $allRequired = array();
        foreach ($gateways as $gateway => $title) {
            if (!GatewayInfo::isOffsite($gateway)) {
                $required = GatewayInfo::requiredFields($gateway);
                $onsiteGateways[$gateway] = $fieldFactory->getFieldName($required);
                $allRequired += $required;
            }
        }

        $allRequired = array_unique($allRequired);
        $allRequired = $fieldFactory->getFieldName(array_combine($allRequired, $allRequired));

        if (empty($onsiteGateways)) {
            return null;
        }

        $ccFields = $fieldFactory->getCardFields();

        // Remove all the credit card fields that aren't required by any gateway
        foreach ($ccFields->dataFields() as $name => $field) {
            if ($name && !in_array($name, $allRequired)) {
                $ccFields->removeByName($name, true);
            }
        }

        $lookupField = LiteralField::create(
            '_CCLookupField',
            sprintf(
                '',
                json_encode($onsiteGateways, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)
            )
        );

        $ccFields->push($lookupField);

        return CompositeField::create($ccFields)->setTag('fieldset')->addExtraClass('credit-card');
    }
}


<?php

namespace SilverShop\Extension;

use SilverShop\Cart\ShoppingCart;
use SilverShop\Forms\OrderActionsForm;
use SilverShop\Model\Order;
use SilverShop\ShopTools;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Extension;
use SilverStripe\Forms\Form;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\PaginatedList;
use SilverStripe\Security\Security;

/**
 * Provides forms and processing to a controller for editing an
 * order that has been previously placed.
 *
 * @property Controller $owner
 */
class OrderManipulationExtension extends Extension
{
    private static $allowed_actions = array(
        'ActionsForm',
        'order',
    );

    private static $sessname = 'OrderManipulation.historicalorders';

    /**
     * Add an order to the session-stored history of orders.
     */
    public static function add_session_order(Order $order)
    {
        $history = self::get_session_order_ids();
        if (!is_array($history)) {
            $history = array();
        }
        $history[$order->ID] = $order->ID;
        ShopTools::getSession()->set(self::$sessname, $history);
    }

    /**
     * Get historical orders for current session.
     */
    public static function get_session_order_ids()
    {
        $history = ShopTools::getSession()->get(self::$sessname);
        if (!is_array($history)) {
            $history = null;
        }
        return $history;
    }

    public static function clear_session_order_ids()
    {
        ShopTools::getSession()->set(self::$sessname, null)->clear(self::$sessname);
    }

    /**
     * Get the order via url 'ID' or form submission 'OrderID'.
     * It will check for permission based on session stored ids or member id.
     *
     * @return Order order
     */
    public function orderfromid()
    {
        $request = $this->owner->getRequest();
        $id = (int)$request->param('ID');
        if (!$id) {
            $id = (int)$request->postVar('OrderID');
        }

        return $this->allorders()->byID($id);
    }

    /**
     * Get all orders for current member / session.
     *
     * @return DataList of Orders
     */
    public function allorders()
    {
        $filters = array(
            'ID' => -1 //ensures no results are returned
        );
        if ($sessids = self::get_session_order_ids()) {
            $filters['ID'] = $sessids;
        }
        if ($member = Security::getCurrentUser()) {
            $filters['MemberID'] = $member->ID;
        }

        return Order::get()->filterAny($filters)
            ->filter('Status:not', Order::config()->hidden_status);
    }

    /**
     * Return all past orders for current member / session.
     */
    public function PastOrders($paginated = false)
    {
        $orders = $this->allorders()
            ->filter('Status', Order::config()->placed_status);
        if ($paginated) {
            $orders = PaginatedList::create($orders, $this->owner->getRequest());
        }

        return $orders;
    }

    /**
     * Return the {@link Order} details for the current
     * Order ID that we're viewing (ID parameter in URL).
     *
     * @param  HTTPRequest $request
     * @return array of template variables
     * @throws \SilverStripe\Control\HTTPResponse_Exception
     */
    public function order(HTTPRequest $request)
    {
        //move the shopping cart session id to past order ids, if it is now an order
        ShoppingCart::singleton()->archiveorderid($request->param('ID'));

        $order = $this->orderfromid();
        if (!$order) {
            return $this->owner->httpError(404, 'Order could not be found');
        }

        return array(
            'Order' => $order,
            'Form' => $this->ActionsForm() //see OrderManipulation extension
        );
    }

    /**
     * Build a form for cancelling, or retrying payment for a placed order.
     *
     * @return Form
     */
    public function ActionsForm()
    {
        if ($order = $this->orderfromid()) {
            $form = OrderActionsForm::create($this->owner, 'ActionsForm', $order);
            $form->extend('updateActionsForm', $order);
            if (!$form->Actions()->exists()) {
                return null;
            }

            return $form;
        }
        return null;
    }

    protected $sessionmessage;

    protected $sessionmessagetype = null;

    public function setSessionMessage($message = 'success', $type = 'good')
    {
        $this->owner->getRequest()->getSession()
            ->set('OrderManipulation.Message', $message)
            ->set('OrderManipulation.MessageType', $type);
    }

    public function SessionMessage()
    {
        $session = $this->owner->getRequest()->getSession();
        if ($session && ($message = $session->get('OrderManipulation.Message'))) {
            $this->sessionmessage = $message;
            $session->clear('OrderManipulation.Message');
        }

        return $this->sessionmessage;
    }

    public function SessionMessageType()
    {
        $session = $this->owner->getRequest()->getSession();
        if ($session && ($type = $session->get('OrderManipulation.MessageType'))) {
            $this->sessionmessagetype = $type;
            $session->clear('OrderManipulation.MessageType');
        }

        return $this->sessionmessagetype;
    }
}