Passed
Push — master ( f3b4d6...85dd39 )
by Sam
11:35
created

DatatransHandler::dispatch()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

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