Completed
Push — master ( 3c661d...2a57f9 )
by Will
26s queued 12s
created

src/Checkout/OrderProcessor.php (5 issues)

1
<?php
2
3
namespace SilverShop\Checkout;
4
5
use ErrorException;
6
use Exception;
7
use SilverShop\Cart\ShoppingCart;
8
use SilverShop\Extension\OrderManipulationExtension;
9
use SilverShop\Extension\ShopConfigExtension;
10
use SilverShop\Model\Order;
11
use SilverShop\ShopTools;
12
use SilverStripe\Control\Controller;
13
use SilverStripe\Core\Config\Config_ForClass;
14
use SilverStripe\Core\Config\Configurable;
15
use SilverStripe\Core\Injector\Injectable;
16
use SilverStripe\Omnipay\GatewayInfo;
17
use SilverStripe\Omnipay\Model\Payment;
18
use SilverStripe\Omnipay\Service\ServiceFactory;
19
use SilverStripe\Omnipay\Service\ServiceResponse;
20
use SilverStripe\ORM\DB;
21
use SilverStripe\ORM\FieldType\DBDatetime;
22
use SilverStripe\Security\Member;
23
use SilverStripe\Security\Security;
24
25
/**
26
 * Handles tasks to be performed on orders, particularly placing and processing/fulfilment.
27
 * Placing, Emailing Reciepts, Status Updates, Printing, Payments - things you do with a completed order.
28
 *
29
 * @package shop
30
 */
31
class OrderProcessor
32
{
33
    use Injectable;
34
    use Configurable;
35
36
    /**
37
     * @var Order
38
     */
39
    protected $order;
40
41
    /**
42
     * @var OrderEmailNotifier
43
     */
44
    protected $notifier;
45
46
    /**
47
     * @var string
48
     */
49
    protected $error;
50
51
52
    /**
53
     * Assign the order to a local variable
54
     *
55
     * @param Order $order
56
     */
57
    public function __construct(Order $order)
58
    {
59
        $this->order = $order;
60
        $this->notifier = OrderEmailNotifier::create($order);
61
    }
62
63
    /**
64
     * URL to display success message to the user.
65
     * Happens after any potential offsite gateway redirects.
66
     *
67
     * @return String Relative URL
68
     */
69
    public function getReturnUrl()
70
    {
71
        return $this->order->Link();
72
    }
73
74
    /**
75
     * Create a payment model, and provide link to redirect to external gateway,
76
     * or redirect to order link.
77
     *
78
     * @param string $gateway the gateway to use
79
     * @param array $gatewaydata the data that should be passed to the gateway
80
     * @param string $successUrl (optional) return URL for successful payments.
81
     *                            If left blank, the default return URL will be
82
     *                            used @see getReturnUrl
83
     * @param string $cancelUrl (optional) return URL for cancelled/failed payments
84
     *
85
     * @return ServiceResponse|null
86
     * @throws \SilverStripe\Omnipay\Exception\InvalidConfigurationException
87
     */
88
    public function makePayment($gateway, $gatewaydata = array(), $successUrl = null, $cancelUrl = null)
89
    {
90
        //create payment
91
        $payment = $this->createPayment($gateway);
92
        if (!$payment) {
93
            //errors have been stored.
94
            return null;
95
        }
96
97
        $payment->setSuccessUrl($successUrl ? $successUrl : $this->getReturnUrl());
98
99
        // Explicitly set the cancel URL
100
        if ($cancelUrl) {
101
            $payment->setFailureUrl($cancelUrl);
102
        }
103
104
        // Create a payment service, by using the Service Factory. This will automatically choose an
105
        // AuthorizeService or PurchaseService, depending on Gateway configuration.
106
        // Set the user-facing success URL for redirects
107
        /**
108
         * @var ServiceFactory $factory
109
         */
110
        $factory = ServiceFactory::create();
111
        $service = $factory->getService($payment, ServiceFactory::INTENT_PAYMENT);
112
113
        // Initiate payment, get the result back
114
        try {
115
            $serviceResponse = $service->initiate($this->getGatewayData($gatewaydata));
116
        } catch (\SilverStripe\Omnipay\Exception\Exception $ex) {
117
            // error out when an exception occurs
118
            $this->error($ex->getMessage());
119
            return null;
120
        }
121
122
        // Check if the service response itself contains an error
123
        if ($serviceResponse->isError()) {
124
            if ($opResponse = $serviceResponse->getOmnipayResponse()) {
125
                $this->error($opResponse->getMessage());
126
            } else {
127
                $this->error('An unspecified payment error occurred. Please check the payment messages.');
128
            }
129
        }
130
131
        // For an OFFSITE payment, serviceResponse will now contain a redirect
132
        // For an ONSITE payment, ShopPayment::onCaptured will have been called, which will have called completePayment
133
134
        return $serviceResponse;
135
    }
136
137
    /**
138
     * Map shop data to omnipay fields
139
     *
140
     * @param array $customData Usually user submitted data.
141
     *
142
     * @return array
143
     */
144
    protected function getGatewayData($customData)
145
    {
146
        $shipping = $this->order->getShippingAddress();
147
        $billing = $this->order->getBillingAddress();
148
149
        $numPayments = Payment::get()
150
            ->filter(array('OrderID' => $this->order->ID))
151
            ->count() - 1;
152
153
        $transactionId = $this->order->Reference . ($numPayments > 0 ? "-$numPayments" : '');
154
155
        return array_merge(
156
            $customData,
157
            array(
158
                'transactionId'    => $transactionId,
159
                'firstName'        => $this->order->FirstName,
160
                'lastName'         => $this->order->Surname,
161
                'email'            => $this->order->Email,
162
                'company'          => $this->order->Company,
0 ignored issues
show
Bug Best Practice introduced by
The property Company does not exist on SilverShop\Model\Order. Since you implemented __get, consider adding a @property annotation.
Loading history...
163
                'billingAddress1'  => $billing->Address,
164
                'billingAddress2'  => $billing->AddressLine2,
165
                'billingCity'      => $billing->City,
166
                'billingPostcode'  => $billing->PostalCode,
167
                'billingState'     => $billing->State,
168
                'billingCountry'   => $billing->Country,
169
                'billingPhone'     => $billing->Phone,
170
                'shippingAddress1' => $shipping->Address,
171
                'shippingAddress2' => $shipping->AddressLine2,
172
                'shippingCity'     => $shipping->City,
173
                'shippingPostcode' => $shipping->PostalCode,
174
                'shippingState'    => $shipping->State,
175
                'shippingCountry'  => $shipping->Country,
176
                'shippingPhone'    => $shipping->Phone,
177
            )
178
        );
179
    }
180
181
    /**
182
     * Create a new payment for an order
183
     */
184
    public function createPayment($gateway)
185
    {
186
        if (!GatewayInfo::isSupported($gateway)) {
187
            $this->error(
188
                _t(
189
                    __CLASS__ . ".InvalidGateway",
190
                    "`{gateway}` isn't a valid payment gateway.",
191
                    'gateway is the name of the payment gateway',
192
                    array('gateway' => $gateway)
193
                )
194
            );
195
            return false;
196
        }
197
        if (!$this->order->canPay(Security::getCurrentUser())) {
198
            $this->error(_t(__CLASS__ . ".CantPay", "Order can't be paid for."));
199
            return false;
200
        }
201
        $payment = Payment::create()->init(
202
            $gateway,
203
            $this->order->TotalOutstanding(true),
204
            ShopConfigExtension::config()->base_currency
205
        );
206
        $this->order->Payments()->add($payment);
0 ignored issues
show
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

206
        $this->order->/** @scrutinizer ignore-call */ 
207
                      Payments()->add($payment);
Loading history...
207
        return $payment;
208
    }
209
210
    /**
211
     * Complete payment processing
212
     *    - send receipt
213
     *    - update order status accordingling
214
     *    - fire event hooks
215
     */
216
    public function completePayment()
217
    {
218
        if (!$this->order->IsPaid()) {
219
220
            $this->order->extend('onPayment'); //a payment has been made
221
            //place the order, if not already placed
222
            if ($this->canPlace($this->order)) {
223
                $this->placeOrder();
224
            } else {
225
                if ($this->order->Locale) {
226
                    ShopTools::install_locale($this->order->Locale);
227
                }
228
            }
229
230
            if (($this->order->GrandTotal() > 0 && $this->order->TotalOutstanding(false) <= 0)
231
                // Zero-dollar order (e.g. paid with loyalty points)
232
                || ($this->order->GrandTotal() == 0 && Order::config()->allow_zero_order_total)
233
            ) {
234
                //set order as paid
235
                $this->order->Status = 'Paid';
0 ignored issues
show
Documentation Bug introduced by
It seems like 'Paid' of type string is incompatible with the declared type SilverStripe\ORM\FieldType\DBEnum of property $Status.

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...
236
                $this->order->write();
237
            }
238
        }
239
    }
240
241
    /**
242
     * Determine if an order can be placed.
243
     *
244
     * @param boolean $order
245
     */
246
    public function canPlace(Order $order)
247
    {
248
        if (!$order) {
0 ignored issues
show
$order is of type SilverShop\Model\Order, thus it always evaluated to true.
Loading history...
249
            $this->error(_t(__CLASS__ . ".NoOrder", "Order does not exist."));
250
            return false;
251
        }
252
        //order status is applicable
253
        if (!$order->IsCart()) {
254
            $this->error(_t(__CLASS__ . ".NotCart", "Order is not a cart."));
255
            return false;
256
        }
257
        //order has products
258
        if ($order->Items()->Count() <= 0) {
259
            $this->error(_t(__CLASS__ . ".NoItems", "Order has no items."));
260
            return false;
261
        }
262
263
        return true;
264
    }
265
266
    /**
267
     * Takes an order from being a cart to awaiting payment.
268
     *
269
     * @return boolean - success/failure
270
     */
271
    public function placeOrder()
272
    {
273
        if (!$this->order) {
274
            $this->error(_t(__CLASS__ . ".NoOrderStarted", "A new order has not yet been started."));
275
            return false;
276
        }
277
        if (!$this->canPlace($this->order)) { //final cart validation
278
            return false;
279
        }
280
281
        if ($this->order->Locale) {
282
            ShopTools::install_locale($this->order->Locale);
283
        }
284
285
        if (DB::get_conn()->supportsTransactions()) {
286
            DB::get_conn()->transactionStart();
287
        }
288
289
        //update status
290
        if ($this->order->TotalOutstanding(false)) {
291
            $this->order->Status = 'Unpaid';
0 ignored issues
show
Documentation Bug introduced by
It seems like 'Unpaid' of type string is incompatible with the declared type SilverStripe\ORM\FieldType\DBEnum of property $Status.

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...
292
        } else {
293
            $this->order->Status = 'Paid';
294
        }
295
296
        if (!$this->order->Placed) {
297
            $this->order->Placed = DBDatetime::now()->Rfc2822(); //record placed order datetime
298
            if ($request = Controller::curr()->getRequest()) {
299
                $this->order->IPAddress = $request->getIP(); //record client IP
300
            }
301
        }
302
303
        // Add an error handler that throws an exception upon error, so that we can catch errors as exceptions
304
        // in the following block.
305
        set_error_handler(
306
            function ($severity, $message, $file, $line) {
307
                throw new ErrorException($message, 0, $severity, $file, $line);
308
            },
309
            E_ALL & ~(E_STRICT | E_NOTICE | E_DEPRECATED | E_USER_DEPRECATED)
310
        );
311
312
        try {
313
            //re-write all attributes and modifiers to make sure they are up-to-date before they can't be changed again
314
            $items = $this->order->Items();
315
            if ($items->exists()) {
316
                foreach ($items as $item) {
317
                    $item->onPlacement();
318
                    $item->write();
319
                }
320
            }
321
            $modifiers = $this->order->Modifiers();
322
            if ($modifiers->exists()) {
323
                foreach ($modifiers as $modifier) {
324
                    $modifier->write();
325
                }
326
            }
327
            //add member to order & customers group
328
            if ($member = Security::getCurrentUser()) {
329
                if (!$this->order->MemberID) {
330
                    $this->order->MemberID = $member->ID;
331
                }
332
                $cgroup = ShopConfigExtension::current()->CustomerGroup();
333
                if ($cgroup->exists()) {
334
                    $member->Groups()->add($cgroup);
335
                }
336
            }
337
            //allow decorators to do stuff when order is saved.
338
            $this->order->extend('onPlaceOrder');
339
            $this->order->write();
340
        } catch (Exception $ex) {
341
            // Rollback the transaction if an error occurred
342
            if (DB::get_conn()->supportsTransactions()) {
343
                DB::get_conn()->transactionRollback();
344
            }
345
            $this->error($ex->getMessage());
346
            return false;
347
        } finally {
348
            // restore the error handler, no matter what
349
            restore_error_handler();
350
        }
351
352
        // Everything went through fine, complete the transaction
353
        if (DB::get_conn()->supportsTransactions()) {
354
            DB::get_conn()->transactionEnd();
355
        }
356
357
        //remove from session
358
        ShoppingCart::singleton()->clear(false);
359
        /*
360
        $cart = ShoppingCart::curr();
361
        if ($cart && $cart->ID == $this->order->ID) {
362
            // clear the cart, but don't write the order in the process (order is finalized and should NOT be overwritten)
363
        }
364
        */
365
366
        //send confirmation if configured and receipt hasn't been sent
367
        if (self::config()->send_confirmation
368
            && !$this->order->ReceiptSent
369
        ) {
370
            $this->notifier->sendConfirmation();
371
        }
372
373
        //notify admin, if configured
374
        if (self::config()->send_admin_notification) {
375
            $this->notifier->sendAdminNotification();
376
        }
377
378
        // Save order reference to session
379
        OrderManipulationExtension::add_session_order($this->order);
380
381
        return true; //report success
382
    }
383
384
    /**
385
     * @return Order
386
     */
387
    public function getOrder()
388
    {
389
        return $this->order;
390
    }
391
392
    public function getError()
393
    {
394
        return $this->error;
395
    }
396
397
    protected function error($message)
398
    {
399
        $this->error = $message;
400
    }
401
}
402