1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Http\Controllers\Payments;
7
8use App\Libraries\OrderCheckout;
9use App\Libraries\Payments\ShopifySignature;
10use App\Models\Store\Order;
11use App\Models\Store\Payment;
12use Carbon\Carbon;
13use Log;
14use Sentry\State\Scope;
15
16class ShopifyController extends Controller
17{
18 private $params;
19
20 public function callback()
21 {
22 $signature = new ShopifySignature(request());
23 $signature->assertValid();
24
25 // X-Shopify-Hmac-Sha256
26 // X-Shopify-Order-Id
27 // X-Shopify-Shop-Domain
28 // X-Shopify-Test
29 // X-Shopify-Topic
30
31 $type = $this->getWebookType();
32 $orderId = $this->getOrderId();
33
34 if ($orderId === null) {
35 $params = $this->getParams();
36 // just log info that can be used for lookup if necessary.
37 $data = [
38 'shopify_gid' => $params['id'],
39 'shopify_order_number' => $params['order_number'],
40 'webhook_type' => $type,
41 ];
42 Log::info('Shopify callback with missing orderId', $data);
43
44 return response([], 204);
45 }
46
47 /** @var Order $order */
48 $order = Order::findOrFail($orderId);
49
50 switch ($type) {
51 case 'orders/cancelled':
52 // FIXME: We're relying on Shopify not sending cancel multiple times otherwise this will explode.
53 $order->getConnection()->transaction(function () use ($order) {
54 $payment = $order->payments()->where('cancelled', false)->first();
55 $payment->cancel();
56 $order->cancel();
57 });
58 break;
59 case 'orders/fulfilled':
60 $order->update(['status' => Order::STATUS_SHIPPED, 'shipped_at' => now()]);
61 break;
62 case 'orders/create':
63 if ($order->isShipped() && $this->isDuplicateOrder()) {
64 return response([], 204);
65 }
66
67 (new OrderCheckout($order))->completeCheckout();
68 break;
69 case 'orders/paid':
70 $this->updateOrderPayment($order);
71 break;
72 default:
73 app('sentry')->getClient()->captureMessage(
74 'Received unknown webhook for order from Shopify',
75 null,
76 (new Scope())
77 ->setExtra('type', $type)
78 ->setExtra('order_id', $orderId)
79 );
80 break;
81 }
82
83 return response([], 204);
84 }
85
86 private function getWebookType()
87 {
88 return request()->header('X-Shopify-Topic');
89 }
90
91 private function getOrderId()
92 {
93 // array of name-value pairs.
94 $attributes = $this->getParams()['note_attributes'];
95
96 foreach ($attributes as $attribute) {
97 if ($attribute['name'] === 'orderId') {
98 return get_int($attribute['value']);
99 }
100 }
101 }
102
103 private function getParams()
104 {
105 if ($this->params === null) {
106 $this->params = static::extractParams(request());
107 }
108
109 return $this->params;
110 }
111
112 /**
113 * Replacement orders created at the Shopify end by duplicating? the previous order.
114 *
115 * @return bool
116 */
117 private function isDuplicateOrder()
118 {
119 $params = $this->getParams();
120
121 return $params['source_name'] === 'shopify_draft_order' && $this->isManualOrder();
122 }
123
124 /**
125 * Manually created replacement orders created at the Shopify end that might not have
126 * the orderId included.
127 *
128 * @return bool
129 */
130 private function isManualOrder()
131 {
132 $params = $this->getParams();
133
134 return array_get($params, 'browser_ip') === null
135 && array_get($params, 'checkout_id') === null
136 && array_get($params, 'gateway') === 'manual'
137 && array_get($params, 'payment_gateway_names') === ['manual']
138 && array_get($params, 'processing_method') === 'manual';
139 }
140
141 private function updateOrderPayment(Order $order)
142 {
143 $params = $this->getParams();
144 $payment = new Payment([
145 'provider' => Order::PROVIDER_SHOPIFY,
146 'transaction_id' => $order->getProviderReference(),
147 'country_code' => array_get($params, 'billing_address.country_code'),
148 'paid_at' => Carbon::parse(array_get($params, 'processed_at')),
149 ]);
150
151 $order->paid($payment);
152 }
153}