Passed
Push — master ( 5ef242...54bf99 )
by Sam
16:07
created

DatatransHandler::getMoney()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4.016

Importance

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