Passed
Push — master ( 5fc488...70f83a )
by Adrien
08:01
created

DatatransHandler::validateOrder()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 41
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 41
ccs 22
cts 22
cp 1
rs 8.9777
c 0
b 0
f 0
cc 6
nc 5
nop 1
crap 6
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Handler;
6
7
use Application\Model\Order;
8
use Application\Model\User;
9
use Application\Repository\LogRepository;
10
use Application\Repository\OrderRepository;
11
use Application\Repository\UserRepository;
12
use Application\Service\MessageQueuer;
13
use Doctrine\ORM\EntityManager;
14
use Ecodev\Felix\Handler\AbstractHandler;
15
use Ecodev\Felix\Service\Mailer;
16
use Exception;
17
use Laminas\Diactoros\Response\HtmlResponse;
18
use Mezzio\Template\TemplateRendererInterface;
19
use Money\Currencies\ISOCurrencies;
20
use Money\Formatter\DecimalMoneyFormatter;
21
use Money\Money;
22
use Psr\Http\Message\ResponseInterface;
23
use Psr\Http\Message\ServerRequestInterface;
24
use Throwable;
25
26
class DatatransHandler extends AbstractHandler
27
{
28
    /**
29
     * @var TemplateRendererInterface
30
     */
31
    private $template;
32
33
    /**
34
     * @var EntityManager
35
     */
36
    private $entityManager;
37
38
    /**
39
     * @var array
40
     */
41
    private $config;
42
43
    /**
44
     * @var Mailer
45
     */
46
    private $mailer;
47
48
    /**
49
     * @var MessageQueuer
50
     */
51
    private $messageQueuer;
52
53 14
    public function __construct(EntityManager $entityManager, TemplateRendererInterface $template, array $config, Mailer $mailer, MessageQueuer $messageQueuer)
54
    {
55 14
        $this->entityManager = $entityManager;
56 14
        $this->template = $template;
57 14
        $this->config = $config;
58 14
        $this->mailer = $mailer;
59 14
        $this->messageQueuer = $messageQueuer;
60 14
    }
61
62
    /**
63
     * Webhook called by datatrans when a payment was made
64
     *
65
     * See documentation: https://api-reference.datatrans.ch/#failed-unsuccessful-authorization-response
66
     */
67 14
    public function handle(ServerRequestInterface $request): ResponseInterface
68
    {
69 14
        $body = $request->getParsedBody();
70 14
        $extraToLog = is_array($body) ? $body : ['rawBody' => $request->getBody()->getContents()];
71
72 14
        _log()->info(LogRepository::DATATRANS_WEBHOOK_BEGIN, $extraToLog);
73
74
        try {
75 14
            if (!is_array($body)) {
76 1
                throw new Exception('Parsed body is expected to be an array but got: ' . gettype($body));
77
            }
78
79 13
            if (isset($this->config['key'])) {
80 12
                $this->checkSignature($body, $this->config['key']);
81
            }
82 11
            $status = $body['status'] ?? '';
83
84 11
            $message = $this->dispatch($status, $body);
85 10
        } catch (Throwable $exception) {
86 10
            $message = $this->createMessage('error', $exception->getMessage(), is_array($body) ? $body : []);
87
        }
88
89
        $viewModel = [
90 14
            'message' => $message,
91
        ];
92
93 14
        _log()->info(LogRepository::DATATRANS_WEBHOOK_END, $message);
94
95 14
        return new HtmlResponse($this->template->render('app::datatrans', $viewModel));
96
    }
97
98
    /**
99
     * Make sure the signature protecting important body fields is valid
100
     *
101
     * @param string $key HMAC-SHA256 signing key in hexadecimal format
102
     */
103 12
    private function checkSignature(array $body, string $key): void
104
    {
105 12
        if (!isset($body['sign'])) {
106 1
            throw new Exception('Missing HMAC signature');
107
        }
108
109 11
        $aliasCC = $body['aliasCC'] ?? '';
110 11
        $valueToSign = $aliasCC . @$body['merchantId'] . @$body['amount'] . @$body['currency'] . @$body['refno'];
111 11
        $expectedSign = hash_hmac('sha256', trim($valueToSign), hex2bin(trim($key)));
112 11
        if ($expectedSign !== $body['sign']) {
113 1
            throw new Exception('Invalid HMAC signature');
114
        }
115 10
    }
116
117
    /**
118
     * Create a message in a coherent way
119
     */
120 14
    private function createMessage(string $status, string $message, array $detail): array
121
    {
122
        return [
123 14
            'status' => $status,
124 14
            'message' => $message,
125 14
            'detail' => $detail,
126
        ];
127
    }
128
129
    /**
130
     * Dispatch the data received from Datatrans to take appropriate actions
131
     */
132 11
    private function dispatch(string $status, array $body): array
133
    {
134
        switch ($status) {
135 11
            case 'success':
136 8
                $this->validateOrder($body);
137 2
                $message = $this->createMessage($status, $body['responseMessage'], $body);
138
139 2
                break;
140 3
            case 'error':
141 1
                $message = $this->createMessage($status, $body['errorMessage'], $body);
142
143 1
                break;
144 2
            case 'cancel':
145 1
                $message = $this->createMessage($status, 'Cancelled', $body);
146
147 1
                break;
148
            default:
149 1
                throw new Exception('Unsupported status in Datatrans data: ' . $status);
150
        }
151
152 4
        return $message;
153
    }
154
155 8
    private function validateOrder(array $body): void
156
    {
157 8
        $orderId = $body['refno'] ?? null;
158
159
        /** @var OrderRepository $orderRepository */
160 8
        $orderRepository = $this->entityManager->getRepository(Order::class);
161
162
        /** @var null|Order $order */
163 8
        $order = $orderRepository->getAclFilter()->runWithoutAcl(function () use ($orderRepository, $orderId) {
164 8
            return $orderRepository->findOneById($orderId);
165 8
        });
166
167 8
        if (!$order) {
168 1
            throw new Exception('Cannot validate an order without a valid order ID');
169
        }
170
171 7
        if ($order->getPaymentMethod() !== \Application\DBAL\Types\PaymentMethodType::DATATRANS) {
172 1
            throw new Exception('Cannot validate an order whose payment method is: ' . $order->getPaymentMethod());
173
        }
174
175 6
        if ($order->getStatus() === Order::STATUS_VALIDATED) {
176 1
            throw new Exception('Cannot validate an order which is already validated');
177
        }
178
179 5
        $money = $this->getMoney($body);
180
181 3
        if (!$order->getBalanceCHF()->equals($money) && !$order->getBalanceEUR()->equals($money)) {
182 1
            $expectedCHF = $this->formatMoney($order->getBalanceCHF());
183 1
            $expectedEUR = $this->formatMoney($order->getBalanceEUR());
184 1
            $actual = $this->formatMoney($money);
185
186 1
            throw new Exception("Cannot validate an order with incorrect balance. Expected $expectedCHF, or $expectedEUR, but got: " . $actual);
187
        }
188
189
        // Actually validate
190 2
        $order->setStatus(Order::STATUS_VALIDATED);
191 2
        $order->setInternalRemarks(json_encode($body, JSON_PRETTY_PRINT));
192
193 2
        $this->entityManager->flush();
194
195 2
        $this->notify($order);
196 2
    }
197
198 1
    private function formatMoney(Money $money): string
199
    {
200 1
        $currencies = new ISOCurrencies();
201 1
        $moneyFormatter = new DecimalMoneyFormatter($currencies);
202
203 1
        return $moneyFormatter->format($money) . ' ' . $money->getCurrency()->getCode();
204
    }
205
206 5
    private function getMoney(array $body): Money
207
    {
208 5
        if (!array_key_exists('amount', $body)) {
209
            // Do not support "registrations"
210 1
            throw new Exception('Cannot validate an order without an amount');
211
        }
212 4
        $amount = $body['amount'];
213
214 4
        $currency = $body['currency'] ?? '';
215 4
        if ($currency === 'CHF') {
216 3
            return Money::CHF($amount);
217
        }
218
219 1
        if ($currency === 'EUR') {
220
            return Money::EUR($amount);
221
        }
222
223 1
        throw new Exception('Can only accept payment in CHF or EUR, but got: ' . $currency);
224
    }
225
226
    /**
227
     * Notify the user and the admins
228
     */
229 2
    private function notify(Order $order): void
230
    {
231
        /** @var UserRepository $repository */
232 2
        $repository = $this->entityManager->getRepository(User::class);
233
234 2
        $user = $order->getOwner();
235
236 2
        if ($user) {
237 2
            $message = $repository->getAclFilter()->runWithoutAcl(function () use ($user, $order) {
238 2
                return $this->messageQueuer->queueUserValidatedOrder($user, $order);
239 2
            });
240 2
            $this->mailer->sendMessageAsync($message);
241
        }
242
243 2
        foreach ($this->messageQueuer->getAllEmailsToNotify() as $adminEmail) {
244 2
            $message = $this->messageQueuer->queueAdminValidatedOrder($adminEmail, $order);
245 2
            $this->mailer->sendMessageAsync($message);
246
        }
247 2
    }
248
}
249