Passed
Push — master ( a59cba...d7e91e )
by Sylvain
08:27
created

DatatransAction   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 217
Duplicated Lines 0 %

Test Coverage

Coverage 98.95%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 92
c 2
b 0
f 0
dl 0
loc 217
ccs 94
cts 95
cp 0.9895
rs 10
wmc 28

9 Methods

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