Passed
Pull Request — master (#635)
by Roman
04:20
created

Order   F

Complexity

Total Complexity 105

Size/Duplication

Total Lines 867
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 867
rs 1.263
c 0
b 0
f 0
wmc 105

40 Methods

Rating   Name   Duplication   Size   Complexity  
A getAddressesDiffer() 0 3 2
A Currency() 0 3 1
B canPay() 0 14 5
A canView() 0 8 2
A calculate() 0 7 2
A IsCart() 0 3 1
A canEdit() 0 8 2
A getShippingAddress() 0 3 1
A getStatusI18N() 0 3 1
C statusTransition() 0 22 7
B debug() 0 26 6
A Total() 0 3 1
A provideI18nEntities() 0 14 2
A getBillingAddress() 0 6 3
A setTotal() 0 3 1
A IsPaid() 0 3 2
A generateReference() 0 12 2
B onBeforeWrite() 0 19 7
A getReference() 0 3 2
A TotalOutstanding() 0 5 2
A getName() 0 5 3
A Link() 0 6 2
A getCMSFields() 0 23 3
A getDefaultSearchContext() 0 53 1
A SubTotal() 0 7 2
A IsSent() 0 3 1
A get_order_status_options() 0 7 2
A GrandTotal() 0 3 1
A getModifier() 0 4 1
A getComponents() 0 10 3
A getTitle() 0 3 1
A IsProcessing() 0 3 2
A canCreate() 0 8 2
B canCancel() 0 19 7
A canDelete() 0 8 2
A onAfterWrite() 0 22 2
A onBeforeDelete() 0 19 4
A getLatestEmail() 0 6 4
B getAddress() 0 21 9
A fieldLabels() 0 9 1

How to fix   Complexity   

Complex Class

Complex classes like Order often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Order, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverShop\Model;
4
5
use SilverShop\Cart\OrderTotalCalculator;
6
use SilverShop\Checkout\OrderEmailNotifier;
7
use SilverShop\Extension\MemberExtension;
8
use SilverShop\Extension\ShopConfigExtension;
9
use SilverShop\Model\Modifiers\OrderModifier;
10
use SilverShop\ORM\Filters\MultiFieldPartialMatchFilter;
11
use SilverShop\ORM\OrderItemList;
12
use SilverShop\Page\AccountPage;
13
use SilverShop\Page\CheckoutPage;
14
use SilverShop\ShopTools;
15
use SilverStripe\Control\Controller;
16
use SilverStripe\Control\Director;
17
use SilverStripe\Dev\Debug;
18
use SilverStripe\Forms\CheckboxSetField;
19
use SilverStripe\Forms\DateField;
20
use SilverStripe\Forms\DropdownField;
21
use SilverStripe\Forms\FieldList;
22
use SilverStripe\Forms\LiteralField;
23
use SilverStripe\Forms\Tab;
24
use SilverStripe\Forms\TabSet;
25
use SilverStripe\Omnipay\Extensions\Payable;
26
use SilverStripe\ORM\DataObject;
27
use SilverStripe\ORM\FieldType\DBCurrency;
28
use SilverStripe\ORM\FieldType\DBDatetime;
29
use SilverStripe\ORM\FieldType\DBEnum;
30
use SilverStripe\ORM\Filters\GreaterThanFilter;
31
use SilverStripe\ORM\Filters\LessThanFilter;
32
use SilverStripe\ORM\HasManyList;
33
use SilverStripe\ORM\Search\SearchContext;
34
use SilverStripe\ORM\UnsavedRelationList;
35
use SilverStripe\Security\Member;
36
use SilverStripe\Security\Security;
37
38
/**
39
 * The order class is a databound object for handling Orders
40
 * within SilverStripe.
41
 *
42
 * @mixin Payable
43
 *
44
 * @property DBCurrency $Total
45
 * @property string $Reference
46
 * @property DBDatetime $Placed
47
 * @property DBDatetime $Paid
48
 * @property DBDatetime $ReceiptSent
49
 * @property DBDatetime $Printed
50
 * @property DBDatetime $Dispatched
51
 * @property DBEnum $Status
52
 * @property string $FirstName
53
 * @property string $Surname
54
 * @property string $Email
55
 * @property string $Notes
56
 * @property string $IPAddress
57
 * @property bool $SeparateBillingAddress
58
 * @property string $Locale
59
 * @property int $MemberID
60
 * @property int $ShippingAddressID
61
 * @property int $BillingAddressID
62
 * @method   Member|MemberExtension Member()
63
 * @method   Address BillingAddress()
64
 * @method   Address ShippingAddress()
65
 * @method   OrderItem[]|HasManyList Items()
66
 * @method   OrderModifier[]|HasManyList Modifiers()
67
 * @method   OrderStatusLog[]|HasManyList OrderStatusLogs()
68
 */
69
class Order extends DataObject
70
{
71
    /**
72
     * Status codes and what they mean:
73
     *
74
     * Unpaid (default): Order created but no successful payment by customer yet
75
     * Query: Order not being processed yet (customer has a query, or could be out of stock)
76
     * Paid: Order successfully paid for by customer
77
     * Processing: Order paid for, package is currently being processed before shipping to customer
78
     * Sent: Order paid for, processed for shipping, and now sent to the customer
79
     * Complete: Order completed (paid and shipped). Customer assumed to have received their goods
80
     * AdminCancelled: Order cancelled by the administrator
81
     * MemberCancelled: Order cancelled by the customer (Member)
82
     */
83
    private static $db = [
0 ignored issues
show
introduced by
The private property $db is not used, and could be removed.
Loading history...
84
        'Total' => 'Currency',
85
        'Reference' => 'Varchar', //allow for customised order numbering schemes
86
        //status
87
        'Placed' => 'Datetime', //date the order was placed (went from Cart to Order)
88
        'Paid' => 'Datetime', //no outstanding payment left
89
        'ReceiptSent' => 'Datetime', //receipt emailed to customer
90
        'Printed' => 'Datetime',
91
        'Dispatched' => 'Datetime', //products have been sent to customer
92
        'Status' => "Enum('Unpaid,Paid,Processing,Sent,Complete,AdminCancelled,MemberCancelled,Cart','Cart')",
93
        //customer (for guest orders)
94
        'FirstName' => 'Varchar',
95
        'Surname' => 'Varchar',
96
        'Email' => 'Varchar',
97
        'Notes' => 'Text',
98
        'IPAddress' => 'Varchar(15)',
99
        //separate shipping
100
        'SeparateBillingAddress' => 'Boolean',
101
        // keep track of customer locale
102
        'Locale' => 'Locale',
103
    ];
104
105
    private static $has_one = [
0 ignored issues
show
introduced by
The private property $has_one is not used, and could be removed.
Loading history...
106
        'Member' => Member::class,
107
        'ShippingAddress' => Address::class,
108
        'BillingAddress' => Address::class,
109
    ];
110
111
    private static $has_many = [
0 ignored issues
show
introduced by
The private property $has_many is not used, and could be removed.
Loading history...
112
        'Items' => OrderItem::class,
113
        'Modifiers' => OrderModifier::class,
114
        'OrderStatusLogs' => OrderStatusLog::class,
115
    ];
116
117
    private static $defaults = [
0 ignored issues
show
introduced by
The private property $defaults is not used, and could be removed.
Loading history...
118
        'Status' => 'Cart',
119
    ];
120
121
    private static $casting = [
0 ignored issues
show
introduced by
The private property $casting is not used, and could be removed.
Loading history...
122
        'FullBillingAddress' => 'Text',
123
        'FullShippingAddress' => 'Text',
124
        'Total' => 'Currency',
125
        'SubTotal' => 'Currency',
126
        'TotalPaid' => 'Currency',
127
        'Shipping' => 'Currency',
128
        'TotalOutstanding' => 'Currency',
129
    ];
130
131
    private static $summary_fields = [
0 ignored issues
show
introduced by
The private property $summary_fields is not used, and could be removed.
Loading history...
132
        'Reference',
133
        'Placed',
134
        'Name',
135
        'LatestEmail',
136
        'Total',
137
        'StatusI18N',
138
    ];
139
140
    private static $searchable_fields = [
0 ignored issues
show
introduced by
The private property $searchable_fields is not used, and could be removed.
Loading history...
141
        'Reference',
142
        'Name',
143
        'Email',
144
        'Status' => [
145
            'filter' => 'ExactMatchFilter',
146
            'field' => CheckboxSetField::class,
147
        ],
148
    ];
149
150
    private static $table_name = 'SilverShop_Order';
0 ignored issues
show
introduced by
The private property $table_name is not used, and could be removed.
Loading history...
151
152
    private static $singular_name = 'Order';
0 ignored issues
show
introduced by
The private property $singular_name is not used, and could be removed.
Loading history...
153
154
    private static $plural_name = 'Orders';
0 ignored issues
show
introduced by
The private property $plural_name is not used, and could be removed.
Loading history...
155
156
    private static $default_sort = '"Placed" DESC, "Created" DESC';
0 ignored issues
show
introduced by
The private property $default_sort is not used, and could be removed.
Loading history...
157
158
    /**
159
     * Statuses for orders that have been placed.
160
     *
161
     * @config
162
     */
163
    private static $placed_status = [
164
        'Paid',
165
        'Unpaid',
166
        'Processing',
167
        'Sent',
168
        'Complete',
169
        'MemberCancelled',
170
        'AdminCancelled',
171
    ];
172
173
    /**
174
     * Statuses for which an order can be paid for
175
     *
176
     * @config
177
     */
178
    private static $payable_status = [
179
        'Cart',
180
        'Unpaid',
181
        'Processing',
182
        'Sent',
183
    ];
184
185
    /**
186
     * Statuses that shouldn't show in user account.
187
     *
188
     * @config
189
     */
190
    private static $hidden_status = ['Cart'];
0 ignored issues
show
introduced by
The private property $hidden_status is not used, and could be removed.
Loading history...
191
192
193
    /**
194
     * Statuses that should be logged in the Order-Status-Log
195
     *
196
     * @config
197
     * @var    array
198
     */
199
    private static $log_status = [];
200
201
    /**
202
     * Whether or not an order can be cancelled before payment
203
     *
204
     * @config
205
     * @var    bool
206
     */
207
    private static $cancel_before_payment = true;
208
209
    /**
210
     * Whether or not an order can be cancelled before processing
211
     *
212
     * @config
213
     * @var    bool
214
     */
215
    private static $cancel_before_processing = false;
216
217
    /**
218
     * Whether or not an order can be cancelled before sending
219
     *
220
     * @config
221
     * @var    bool
222
     */
223
    private static $cancel_before_sending = false;
224
225
    /**
226
     * Whether or not an order can be cancelled after sending
227
     *
228
     * @config
229
     * @var    bool
230
     */
231
    private static $cancel_after_sending = false;
232
233
    /**
234
     * Place an order before payment processing begins
235
     *
236
     * @config
237
     * @var    boolean
238
     */
239
    private static $place_before_payment = false;
0 ignored issues
show
introduced by
The private property $place_before_payment is not used, and could be removed.
Loading history...
240
241
    /**
242
     * Modifiers represent the additional charges or
243
     * deductions associated to an order, such as
244
     * shipping, taxes, vouchers etc.
245
     *
246
     * @config
247
     * @var    array
248
     */
249
    private static $modifiers = [];
0 ignored issues
show
introduced by
The private property $modifiers is not used, and could be removed.
Loading history...
250
251
    /**
252
     * Rounding precision of order amounts
253
     *
254
     * @config
255
     * @var    int
256
     */
257
    private static $rounding_precision = 2;
258
259
    /**
260
     * Minimal length (number of decimals) of order reference ids
261
     *
262
     * @config
263
     * @var    int
264
     */
265
    private static $reference_id_padding = 5;
266
267
    /**
268
     * Will allow completion of orders with GrandTotal=0,
269
     * which could be the case for orders paid with loyalty points or vouchers.
270
     * Will send the "Paid" date on the order, even though no actual payment was taken.
271
     * Will trigger the payment related extension points:
272
     * Order->onPayment, OrderItem->onPayment, Order->onPaid.
273
     *
274
     * @config
275
     * @var    boolean
276
     */
277
    private static $allow_zero_order_total = false;
0 ignored issues
show
introduced by
The private property $allow_zero_order_total is not used, and could be removed.
Loading history...
278
279
    /**
280
     * A flag indicating that an order-status-log entry should be written
281
     *
282
     * @var bool
283
     */
284
    protected $flagOrderStatusWrite = false;
285
286
    public static function get_order_status_options()
287
    {
288
        $values = array();
289
        foreach (singleton(Order::class)->dbObject('Status')->enumValues(false) as $value) {
290
            $values[$value] = _t(__CLASS__ . '.STATUS_' . strtoupper($value), $value);
291
        }
292
        return $values;
293
    }
294
295
    /**
296
     * Create CMS fields for cms viewing and editing orders
297
     */
298
    public function getCMSFields()
299
    {
300
        $fields = FieldList::create(TabSet::create('Root', Tab::create('Main')));
0 ignored issues
show
Bug introduced by
'Main' of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

300
        $fields = FieldList::create(TabSet::create('Root', Tab::create(/** @scrutinizer ignore-type */ 'Main')));
Loading history...
301
        $fs = '<div class="field">';
302
        $fe = '</div>';
303
        $parts = array(
304
            DropdownField::create('Status', $this->fieldLabel('Status'), self::get_order_status_options()),
305
            LiteralField::create('Customer', $fs . $this->renderWith('SilverShop\Admin\OrderAdmin_Customer') . $fe),
306
            LiteralField::create('Addresses', $fs . $this->renderWith('SilverShop\Admin\OrderAdmin_Addresses') . $fe),
307
            LiteralField::create('Content', $fs . $this->renderWith('SilverShop\Admin\OrderAdmin_Content') . $fe),
308
        );
309
        if ($this->Notes) {
310
            $parts[] = LiteralField::create('Notes', $fs . $this->renderWith('SilverShop\Admin\OrderAdmin_Notes') . $fe);
311
        }
312
        $fields->addFieldsToTab('Root.Main', $parts);
313
        $this->extend('updateCMSFields', $fields);
314
        if ($payments = $fields->fieldByName('Root.Payments.Payments')) {
315
            $fields->removeByName('Payments');
316
            $fields->insertAfter('Content', $payments);
317
            $payments->addExtraClass('order-payments');
318
        }
319
320
        return $fields;
321
    }
322
323
    /**
324
     * Augment field labels
325
     */
326
    public function fieldLabels($includerelations = true)
327
    {
328
        $labels = parent::fieldLabels($includerelations);
329
330
        $labels['Name'] = _t('SilverShop\Generic.Customer', 'Customer');
331
        $labels['LatestEmail'] = _t(__CLASS__ . '.db_Email', 'Email');
332
        $labels['StatusI18N'] = _t(__CLASS__ . '.db_Status', 'Status');
333
334
        return $labels;
335
    }
336
337
    /**
338
     * Adjust scafolded search context
339
     *
340
     * @return SearchContext the updated search context
341
     */
342
    public function getDefaultSearchContext()
343
    {
344
        $context = parent::getDefaultSearchContext();
345
        $fields = $context->getFields();
346
347
        $validStates = self::config()->placed_status;
348
        $statusOptions = array_filter(self::get_order_status_options(), function ($k) use ($validStates) {
349
            return in_array($k, $validStates);
350
        }, ARRAY_FILTER_USE_KEY);
351
352
        $fields->push(
353
            // TODO: Allow filtering by multiple statuses
354
            DropdownField::create('Status', $this->fieldLabel('Status'))
0 ignored issues
show
Bug introduced by
'Status' of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

354
            DropdownField::create(/** @scrutinizer ignore-type */ 'Status', $this->fieldLabel('Status'))
Loading history...
355
                ->setSource($statusOptions)
356
                ->setHasEmptyDefault(true)
357
        );
358
359
        // add date range filtering
360
        $fields->insertBefore(
361
            'Status',
362
            DateField::create('DateFrom', _t(__CLASS__ . '.DateFrom', 'Date from'))
363
        );
364
365
        $fields->insertBefore(
366
            'Status',
367
            DateField::create('DateTo', _t(__CLASS__ . '.DateTo', 'Date to'))
368
        );
369
370
        // get the array, to maniplulate name, and fullname seperately
371
        $filters = $context->getFilters();
372
        $filters['DateFrom'] = GreaterThanFilter::create('Placed');
0 ignored issues
show
Bug introduced by
'Placed' of type string is incompatible with the type array expected by parameter $args of SilverStripe\ORM\Filters\SearchFilter::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

372
        $filters['DateFrom'] = GreaterThanFilter::create(/** @scrutinizer ignore-type */ 'Placed');
Loading history...
373
        $filters['DateTo'] = LessThanFilter::create('Placed');
374
375
        // filter customer need to use a bunch of different sources
376
        $filters['Name'] = MultiFieldPartialMatchFilter::create(
377
            'FirstName',
378
            false,
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type array expected by parameter $args of SilverStripe\ORM\Filters\SearchFilter::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

378
            /** @scrutinizer ignore-type */ false,
Loading history...
379
            ['SplitWords'],
380
            [
381
                'Surname',
382
                'Member.FirstName',
383
                'Member.Surname',
384
                'BillingAddress.FirstName',
385
                'BillingAddress.Surname',
386
                'ShippingAddress.FirstName',
387
                'ShippingAddress.Surname',
388
            ]
389
        );
390
391
        $context->setFilters($filters);
392
393
        $this->extend('updateDefaultSearchContext', $context);
394
        return $context;
395
    }
396
397
    /**
398
     * Hack for swapping out relation list with OrderItemList
399
     *
400
     * @inheritdoc
401
     */
402
    public function getComponents($componentName, $id = null)
403
    {
404
        $components = parent::getComponents($componentName, $id);
0 ignored issues
show
Unused Code introduced by
The call to SilverStripe\ORM\DataObject::getComponents() has too many arguments starting with $id. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

404
        /** @scrutinizer ignore-call */ 
405
        $components = parent::getComponents($componentName, $id);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
405
        if ($componentName === 'Items' && get_class($components) !== UnsavedRelationList::class) {
406
            $query = $components->dataQuery();
407
            $components = OrderItemList::create(OrderItem::class, 'OrderID');
0 ignored issues
show
Bug introduced by
SilverShop\Model\OrderItem::class of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

407
            $components = OrderItemList::create(/** @scrutinizer ignore-type */ OrderItem::class, 'OrderID');
Loading history...
408
            $components->setDataQuery($query);
409
            $components = $components->forForeignID($this->ID);
410
        }
411
        return $components;
412
    }
413
414
    /**
415
     * Returns the subtotal of the items for this order.
416
     */
417
    public function SubTotal()
418
    {
419
        if ($this->Items()->exists()) {
420
            return $this->Items()->SubTotal();
421
        }
422
423
        return 0;
424
    }
425
426
    /**
427
     * Calculate the total
428
     *
429
     * @return float the final total
430
     */
431
    public function calculate()
432
    {
433
        if (!$this->IsCart()) {
434
            return $this->Total;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->Total returns the type SilverStripe\ORM\FieldType\DBCurrency which is incompatible with the documented return type double.
Loading history...
435
        }
436
        $calculator = OrderTotalCalculator::create($this);
0 ignored issues
show
Bug introduced by
$this of type SilverShop\Model\Order is incompatible with the type array expected by parameter $args of SilverShop\Cart\OrderTotalCalculator::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

436
        $calculator = OrderTotalCalculator::create(/** @scrutinizer ignore-type */ $this);
Loading history...
437
        return $this->Total = $calculator->calculate();
438
    }
439
440
    /**
441
     * This is needed to maintain backwards compatiability with
442
     * some subsystems using modifiers. eg discounts
443
     */
444
    public function getModifier($className, $forcecreate = false)
445
    {
446
        $calculator = OrderTotalCalculator::create($this);
0 ignored issues
show
Bug introduced by
$this of type SilverShop\Model\Order is incompatible with the type array expected by parameter $args of SilverShop\Cart\OrderTotalCalculator::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

446
        $calculator = OrderTotalCalculator::create(/** @scrutinizer ignore-type */ $this);
Loading history...
447
        return $calculator->getModifier($className, $forcecreate);
448
    }
449
450
    /**
451
     * Enforce rounding precision when setting total
452
     */
453
    public function setTotal($val)
454
    {
455
        $this->setField('Total', round($val, self::$rounding_precision));
456
    }
457
458
    /**
459
     * Get final value of order.
460
     * Retrieves value from DataObject's record array.
461
     */
462
    public function Total()
463
    {
464
        return $this->getField('Total');
465
    }
466
467
    /**
468
     * Alias for Total.
469
     */
470
    public function GrandTotal()
471
    {
472
        return $this->Total();
473
    }
474
475
    /**
476
     * Calculate how much is left to be paid on the order.
477
     * Enforces rounding precision.
478
     *
479
     * Payments that have been authorized via a non-manual gateway should count towards the total paid amount.
480
     * However, it's possible to exclude these by setting the $includeAuthorized parameter to false, which is
481
     * useful to determine the status of the Order. Order status should only change to 'Paid' when all
482
     * payments are 'Captured'.
483
     *
484
     * @param  bool $includeAuthorized whether or not to include authorized payments (excluding manual payments)
485
     * @return float
486
     */
487
    public function TotalOutstanding($includeAuthorized = true)
488
    {
489
        return round(
490
            $this->GrandTotal() - ($includeAuthorized ? $this->TotalPaidOrAuthorized() : $this->TotalPaid()),
0 ignored issues
show
Bug introduced by
The method TotalPaidOrAuthorized() does not exist on SilverShop\Model\Order. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

490
            $this->GrandTotal() - ($includeAuthorized ? $this->/** @scrutinizer ignore-call */ TotalPaidOrAuthorized() : $this->TotalPaid()),
Loading history...
Bug introduced by
The method TotalPaid() does not exist on SilverShop\Model\Order. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

490
            $this->GrandTotal() - ($includeAuthorized ? $this->TotalPaidOrAuthorized() : $this->/** @scrutinizer ignore-call */ TotalPaid()),
Loading history...
491
            self::config()->rounding_precision
492
        );
493
    }
494
495
    /**
496
     * Get the order status. This will return a localized value if available.
497
     *
498
     * @return string the payment status
499
     */
500
    public function getStatusI18N()
501
    {
502
        return _t(__CLASS__ . '.STATUS_' . strtoupper($this->Status), $this->Status);
503
    }
504
505
    /**
506
     * Get the link for finishing order processing.
507
     */
508
    public function Link()
509
    {
510
        if (Security::getCurrentUser()) {
511
            return Controller::join_links(AccountPage::find_link(), 'order', $this->ID);
512
        }
513
        return CheckoutPage::find_link(false, 'order', $this->ID);
514
    }
515
516
    /**
517
     * Returns TRUE if the order can be cancelled
518
     * PRECONDITION: Order is in the DB.
519
     *
520
     * @return boolean
521
     */
522
    public function canCancel($member = null)
523
    {
524
        $extended = $this->extendedCan(__FUNCTION__, $member);
525
        if ($extended !== null) {
526
            return $extended;
527
        }
528
529
        switch ($this->Status) {
530
            case 'Unpaid' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
531
            return self::config()->cancel_before_payment;
532
            case 'Paid' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
533
            return self::config()->cancel_before_processing;
534
            case 'Processing' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
535
            return self::config()->cancel_before_sending;
536
            case 'Sent' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
537
            case 'Complete' :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
538
            return self::config()->cancel_after_sending;
539
        }
540
        return false;
541
    }
542
543
    /**
544
     * Check if an order can be paid for.
545
     *
546
     * @return boolean
547
     */
548
    public function canPay($member = null)
549
    {
550
        $extended = $this->extendedCan(__FUNCTION__, $member);
551
        if ($extended !== null) {
552
            return $extended;
553
        }
554
555
        if (!in_array($this->Status, self::config()->payable_status)) {
556
            return false;
557
        }
558
        if ($this->TotalOutstanding(true) > 0 && empty($this->Paid)) {
559
            return true;
560
        }
561
        return false;
562
    }
563
564
    /**
565
     * Prevent deleting orders.
566
     *
567
     * @return boolean
568
     */
569
    public function canDelete($member = null)
570
    {
571
        $extended = $this->extendedCan(__FUNCTION__, $member);
572
        if ($extended !== null) {
573
            return $extended;
574
        }
575
576
        return false;
577
    }
578
579
    /**
580
     * Check if an order can be viewed.
581
     *
582
     * @return boolean
583
     */
584
    public function canView($member = null)
585
    {
586
        $extended = $this->extendedCan(__FUNCTION__, $member);
587
        if ($extended !== null) {
588
            return $extended;
589
        }
590
591
        return true;
592
    }
593
594
    /**
595
     * Check if an order can be edited.
596
     *
597
     * @return boolean
598
     */
599
    public function canEdit($member = null)
600
    {
601
        $extended = $this->extendedCan(__FUNCTION__, $member);
602
        if ($extended !== null) {
603
            return $extended;
604
        }
605
606
        return true;
607
    }
608
609
    /**
610
     * Prevent standard creation of orders.
611
     *
612
     * @return boolean
613
     */
614
    public function canCreate($member = null, $context = array())
615
    {
616
        $extended = $this->extendedCan(__FUNCTION__, $member, $context);
617
        if ($extended !== null) {
618
            return $extended;
619
        }
620
621
        return false;
622
    }
623
624
    /**
625
     * Return the currency of this order.
626
     * Note: this is a fixed value across the entire site.
627
     *
628
     * @return string
629
     */
630
    public function Currency()
631
    {
632
        return ShopConfigExtension::get_site_currency();
633
    }
634
635
    /**
636
     * Get the latest email for this order.z
637
     */
638
    public function getLatestEmail()
639
    {
640
        if ($this->MemberID && ($this->Member()->LastEdited > $this->LastEdited || !$this->Email)) {
0 ignored issues
show
Bug introduced by
The property LastEdited does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
641
            return $this->Member()->Email;
0 ignored issues
show
Bug introduced by
The property Email does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
642
        }
643
        return $this->getField('Email');
644
    }
645
646
    /**
647
     * Gets the name of the customer.
648
     */
649
    public function getName()
650
    {
651
        $firstname = $this->FirstName ? $this->FirstName : $this->Member()->FirstName;
0 ignored issues
show
Bug introduced by
The property FirstName does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
652
        $surname = $this->FirstName ? $this->Surname : $this->Member()->Surname;
0 ignored issues
show
Bug introduced by
The property Surname does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
653
        return implode(' ', array_filter(array($firstname, $surname)));
654
    }
655
656
    public function getTitle()
657
    {
658
        return $this->Reference . ' - ' . $this->dbObject('Placed')->Nice();
659
    }
660
661
    /**
662
     * Get shipping address, or member default shipping address.
663
     */
664
    public function getShippingAddress()
665
    {
666
        return $this->getAddress('Shipping');
667
    }
668
669
    /**
670
     * Get billing address, if marked to use seperate address, otherwise use shipping address,
671
     * or the member default billing address.
672
     */
673
    public function getBillingAddress()
674
    {
675
        if (!$this->SeparateBillingAddress && $this->ShippingAddressID === $this->BillingAddressID) {
676
            return $this->getShippingAddress();
677
        } else {
678
            return $this->getAddress('Billing');
679
        }
680
    }
681
682
    /**
683
     * @param string $type - Billing or Shipping
684
     * @return Address
685
     * @throws \Exception
686
     */
687
    protected function getAddress($type)
688
    {
689
        $address = $this->getComponent($type . 'Address');
690
691
        if (!$address || !$address->exists() && $this->Member()) {
0 ignored issues
show
introduced by
The condition ! $address || ! $address...ts() && $this->Member() can never be false.
Loading history...
692
            $address = $this->Member()->{"Default${type}Address"}();
693
        }
694
695
        if (empty($address->Surname) && empty($address->FirstName)) {
696
            if ($member = $this->Member()) {
697
                // If there's a member object, use information from the Member.
698
                // The information from Order should have precendence if set though!
699
                $address->FirstName = $this->FirstName ?: $member->FirstName;
0 ignored issues
show
Bug introduced by
The property FirstName does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
700
                $address->Surname = $this->Surname ?: $member->Surname;
0 ignored issues
show
Bug introduced by
The property Surname does not seem to exist on SilverShop\Extension\MemberExtension.
Loading history...
701
            } else {
702
                $address->FirstName = $this->FirstName;
703
                $address->Surname = $this->Surname;
704
            }
705
        }
706
707
        return $address;
708
    }
709
710
    /**
711
     * Check if the two addresses saved differ.
712
     *
713
     * @return boolean
714
     */
715
    public function getAddressesDiffer()
716
    {
717
        return $this->SeparateBillingAddress || $this->ShippingAddressID !== $this->BillingAddressID;
718
    }
719
720
    /**
721
     * Has this order been sent to the customer?
722
     * (at "Sent" status).
723
     *
724
     * @return boolean
725
     */
726
    public function IsSent()
727
    {
728
        return $this->Status == 'Sent';
729
    }
730
731
    /**
732
     * Is this order currently being processed?
733
     * (at "Sent" OR "Processing" status).
734
     *
735
     * @return boolean
736
     */
737
    public function IsProcessing()
738
    {
739
        return $this->IsSent() || $this->Status == 'Processing';
740
    }
741
742
    /**
743
     * Return whether this Order has been paid for (Status == Paid)
744
     * or Status == Processing, where it's been paid for, but is
745
     * currently in a processing state.
746
     *
747
     * @return boolean
748
     */
749
    public function IsPaid()
750
    {
751
        return (boolean)$this->Paid || $this->Status == 'Paid';
752
    }
753
754
    public function IsCart()
755
    {
756
        return $this->Status == 'Cart';
757
    }
758
759
    /**
760
     * Create a unique reference identifier string for this order.
761
     */
762
    public function generateReference()
763
    {
764
        $reference = str_pad($this->ID, self::$reference_id_padding, '0', STR_PAD_LEFT);
765
        $this->extend('generateReference', $reference);
766
        $candidate = $reference;
767
        //prevent generating references that are the same
768
        $count = 0;
769
        while (Order::get()->filter('Reference', $candidate)->count() > 0) {
770
            $count++;
771
            $candidate = $reference . '' . $count;
772
        }
773
        $this->Reference = $candidate;
774
    }
775
776
    /**
777
     * Get the reference for this order, or fall back to order ID.
778
     */
779
    public function getReference()
780
    {
781
        return $this->getField('Reference') ? $this->getField('Reference') : $this->ID;
782
    }
783
784
    /**
785
     * Force creating an order reference
786
     */
787
    protected function onBeforeWrite()
788
    {
789
        parent::onBeforeWrite();
790
        if (!$this->getField('Reference') && in_array($this->Status, self::$placed_status)) {
791
            $this->generateReference();
792
        }
793
794
        // perform status transition
795
        if ($this->isInDB() && $this->isChanged('Status')) {
796
            $this->statusTransition(
797
                empty($this->original['Status']) ? 'Cart' : $this->original['Status'],
798
                $this->Status
799
            );
800
        }
801
802
        // While the order is unfinished/cart, always store the current locale with the order.
803
        // We do this everytime an order is saved, because the user might change locale (language-switch).
804
        if ($this->Status == 'Cart') {
0 ignored issues
show
introduced by
The condition $this->Status == 'Cart' can never be true.
Loading history...
805
            $this->Locale = ShopTools::get_current_locale();
806
        }
807
    }
808
809
    /**
810
     * Called from @see onBeforeWrite whenever status changes
811
     *
812
     * @param string $fromStatus status to transition away from
813
     * @param string $toStatus   target status
814
     */
815
    protected function statusTransition($fromStatus, $toStatus)
816
    {
817
        // Add extension hook to react to order status transitions.
818
        $this->extend('onStatusChange', $fromStatus, $toStatus);
819
820
        if ($toStatus == 'Paid' && !$this->Paid) {
821
            $this->Paid = DBDatetime::now()->Rfc2822();
0 ignored issues
show
Documentation Bug introduced by
It seems like SilverStripe\ORM\FieldTy...etime::now()->Rfc2822() of type string is incompatible with the declared type SilverStripe\ORM\FieldType\DBDatetime of property $Paid.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
822
            foreach ($this->Items() as $item) {
823
                $item->onPayment();
824
            }
825
            //all payment is settled
826
            $this->extend('onPaid');
827
828
            if (!$this->ReceiptSent) {
829
                OrderEmailNotifier::create($this)->sendReceipt();
0 ignored issues
show
Bug introduced by
$this of type SilverShop\Model\Order is incompatible with the type array expected by parameter $args of SilverShop\Checkout\OrderEmailNotifier::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

829
                OrderEmailNotifier::create(/** @scrutinizer ignore-type */ $this)->sendReceipt();
Loading history...
830
                $this->ReceiptSent = DBDatetime::now()->Rfc2822();
0 ignored issues
show
Documentation Bug introduced by
It seems like SilverStripe\ORM\FieldTy...etime::now()->Rfc2822() of type string is incompatible with the declared type SilverStripe\ORM\FieldType\DBDatetime of property $ReceiptSent.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
831
            }
832
        }
833
834
        $logStatus = $this->config()->log_status;
835
        if (!empty($logStatus) && in_array($toStatus, $logStatus)) {
836
            $this->flagOrderStatusWrite = $fromStatus != $toStatus;
837
        }
838
    }
839
840
    /**
841
     * delete attributes, statuslogs, and payments
842
     */
843
    protected function onBeforeDelete()
844
    {
845
        foreach ($this->Items() as $item) {
846
            $item->delete();
847
        }
848
849
        foreach ($this->Modifiers() as $modifier) {
850
            $modifier->delete();
851
        }
852
853
        foreach ($this->OrderStatusLogs() as $logEntry) {
854
            $logEntry->delete();
855
        }
856
857
        // just remove the payment relations…
858
        // that way payment objects still persist (they might be relevant for book-keeping?)
859
        $this->Payments()->removeAll();
0 ignored issues
show
Bug introduced by
The method Payments() does not exist on SilverShop\Model\Order. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

859
        $this->/** @scrutinizer ignore-call */ 
860
               Payments()->removeAll();
Loading history...
860
861
        parent::onBeforeDelete();
862
    }
863
864
    public function onAfterWrite()
865
    {
866
        parent::onAfterWrite();
867
868
        //create an OrderStatusLog
869
        if ($this->flagOrderStatusWrite) {
870
            $this->flagOrderStatusWrite = false;
871
            $log = OrderStatusLog::create();
872
873
            // populate OrderStatusLog
874
            $log->Title = _t(
875
                'SilverShop\ShopEmail.StatusChanged',
876
                'Status for order #{OrderNo} changed to "{OrderStatus}"',
877
                '',
878
                ['OrderNo' => $this->Reference, 'OrderStatus' => $this->getStatusI18N()]
879
            );
880
            $log->Note = _t('SilverShop\ShopEmail.StatusChange' . $this->Status . 'Note', $this->Status . 'Note');
881
            $log->OrderID = $this->ID;
882
            OrderEmailNotifier::create($this)->sendStatusChange($log->Title, $log->Note);
0 ignored issues
show
Bug introduced by
$this of type SilverShop\Model\Order is incompatible with the type array expected by parameter $args of SilverShop\Checkout\OrderEmailNotifier::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

882
            OrderEmailNotifier::create(/** @scrutinizer ignore-type */ $this)->sendStatusChange($log->Title, $log->Note);
Loading history...
883
            $log->SentToCustomer = true;
884
            $this->extend('updateOrderStatusLog', $log);
885
            $log->write();
886
        }
887
    }
888
889
    public function debug()
890
    {
891
        if (Director::is_cli()) {
892
            // Temporarily disabled.
893
            // TODO: Reactivate when the following issue got fixed: https://github.com/silverstripe/silverstripe-framework/issues/7827
894
            return '';
895
        }
896
897
        $val = "<div class='order'><h1>" . static::class . "</h1>\n<ul>\n";
898
        if ($this->record) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->record of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
899
            foreach ($this->record as $fieldName => $fieldVal) {
900
                $val .= "\t<li>$fieldName: " . Debug::text($fieldVal) . "</li>\n";
901
            }
902
        }
903
        $val .= "</ul>\n";
904
        $val .= "<div class='items'><h2>Items</h2>";
905
        if ($items = $this->Items()) {
0 ignored issues
show
Unused Code introduced by
The assignment to $items is dead and can be removed.
Loading history...
906
            $val .= $this->Items()->debug();
907
        }
908
        $val .= "</div><div class='modifiers'><h2>Modifiers</h2>";
909
        if ($modifiers = $this->Modifiers()) {
910
            $val .= $modifiers->debug();
911
        }
912
        $val .= "</div></div>";
913
914
        return $val;
915
    }
916
917
    /**
918
     * Provide i18n entities for the order class
919
     *
920
     * @return array
921
     */
922
    public function provideI18nEntities()
923
    {
924
        $entities = parent::provideI18nEntities();
925
926
        // collect all the payment status values
927
        foreach ($this->dbObject('Status')->enumValues() as $value) {
928
            $key = strtoupper($value);
929
            $entities[__CLASS__ . ".STATUS_$key"] = array(
930
                $value,
931
                "Translation of the order status '$value'",
932
            );
933
        }
934
935
        return $entities;
936
    }
937
}
938