Passed
Push — master ( e3c27b...b957a6 )
by Adrien
17:10
created

DatatransHandler::checkSignature()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 10
rs 10
ccs 8
cts 8
cp 1
cc 3
nc 3
nop 2
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Handler;
6
7
use Application\Model\Account;
8
use Application\Model\Transaction;
9
use Application\Model\TransactionLine;
10
use Application\Model\User;
11
use Application\Repository\AccountRepository;
12
use Application\Repository\LogRepository;
13
use Application\Repository\UserRepository;
14
use Cake\Chronos\Chronos;
15
use Doctrine\ORM\EntityManager;
16
use Ecodev\Felix\Handler\AbstractHandler;
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
     * DatatransAction constructor.
29
     */
30
    public function __construct(private readonly EntityManager $entityManager, private readonly TemplateRendererInterface $template, private readonly array $config)
31
    {
32
    }
33
34
    /**
35
     * Webhook called by datatrans when a payment was made.
36
     *
37
     * See documentation: https://api-reference.datatrans.ch/#failed-unsuccessful-authorization-response
38
     */
39 12
    public function handle(ServerRequestInterface $request): ResponseInterface
40
    {
41 12
        $body = $request->getParsedBody();
42 12
        $extraToLog = is_array($body) ? $body : ['rawBody' => $request->getBody()->getContents()];
43
44 12
        _log()->info(LogRepository::DATATRANS_WEBHOOK_BEGIN, $extraToLog);
45
46
        try {
47 12
            if (!is_array($body)) {
48 1
                throw new Exception('Parsed body is expected to be an array but got: ' . gettype($body));
49
            }
50
51 11
            if (isset($this->config['datatrans'], $this->config['datatrans']['key'])) {
52 11
                $this->checkSignature($body, $this->config['datatrans']['key']);
53
            }
54
55 9
            $status = $body['status'] ?? '';
56
57 9
            $message = $this->dispatch($status, $body);
58 7
        } catch (Throwable $exception) {
59 7
            $message = $this->createMessage('error', $exception->getMessage(), is_array($body) ? $body : []);
60
        }
61
62 12
        $viewModel = [
63 12
            'message' => $message,
64
        ];
65
66 12
        _log()->info(LogRepository::DATATRANS_WEBHOOK_END, $message);
67
68 12
        return new HtmlResponse($this->template->render('app::datatrans', $viewModel));
69
    }
70
71
    /**
72
     * Make sure the signature protecting important body fields is valid.
73
     *
74
     * @param string $key HMAC-SHA256 signing key in hexadecimal format
75
     */
76 11
    private function checkSignature(array $body, string $key): void
77
    {
78 11
        if (!isset($body['sign'])) {
79 1
            throw new Exception('Missing HMAC signature');
80
        }
81 10
        $aliasCC = $body['aliasCC'] ?? '';
82 10
        $valueToSign = $aliasCC . @$body['merchantId'] . @$body['amount'] . @$body['currency'] . @$body['refno'];
83 10
        $expectedSign = hash_hmac('sha256', trim($valueToSign), hex2bin(trim($key)));
84 10
        if ($expectedSign !== $body['sign']) {
85 1
            throw new Exception('Invalid HMAC signature');
86
        }
87
    }
88
89
    /**
90
     * Create a message in a coherent way.
91
     */
92 12
    private function createMessage(string $status, string $message, array $detail): array
93
    {
94
        return [
95 12
            'status' => $status,
96 12
            'message' => $message,
97 12
            'detail' => $detail,
98
        ];
99
    }
100
101
    /**
102
     * Dispatch the data received from Datatrans to take appropriate actions.
103
     */
104 9
    private function dispatch(string $status, array $body): array
105
    {
106
        switch ($status) {
107 9
            case 'success':
108 6
                $this->createTransactions($body);
109 3
                $message = $this->createMessage($status, $body['responseMessage'], $body);
110
111 3
                break;
112 3
            case 'error':
113 1
                $message = $this->createMessage($status, $body['errorMessage'], $body);
114
115 1
                break;
116 2
            case 'cancel':
117 1
                $message = $this->createMessage($status, 'Cancelled', $body);
118
119 1
                break;
120
            default:
121 1
                throw new Exception('Unsupported status in Datatrans data: ' . $status);
122
        }
123
124 5
        return $message;
125
    }
126
127 6
    private function createTransactions(array $body): void
128
    {
129
        // Create only if a transaction with the same Datatrans reference doesn't already exist
130 6
        $datatransRef = $body['uppTransactionId'];
131 6
        $transactionRepository = $this->entityManager->getRepository(Transaction::class);
132 6
        $existing = $transactionRepository->count(['datatransRef' => $datatransRef]);
133
134 6
        if ($existing) {
135 3
            return;
136
        }
137
138 6
        $userId = $body['refno'] ?? null;
139
140
        /** @var UserRepository $userRepository */
141 6
        $userRepository = $this->entityManager->getRepository(User::class);
142 6
        $user = $userRepository->getOneById((int) $userId);
143 6
        if (!$user) {
144 1
            throw new Exception('Cannot create transactions without a user');
145
        }
146
147
        /** @var AccountRepository $accountRepository */
148 5
        $accountRepository = $this->entityManager->getRepository(Account::class);
149 5
        $userAccount = $accountRepository->getOrCreate($user);
150 5
        if (!isset($this->config['accounting'], $this->config['accounting']['bankAccountCode'])) {
151
            throw new Exception('Missing config accounting/bankAccountCode');
152
        }
153 5
        $bankAccountCode = $this->config['accounting']['bankAccountCode'];
154 5
        $bankAccount = $accountRepository->getAclFilter()->runWithoutAcl(fn () => $accountRepository->findOneByCode($bankAccountCode));
155
156 5
        if (!array_key_exists('amount', $body)) {
157
            // Do not support "registrations"
158 1
            throw new Exception('Cannot create transactions without an amount');
159
        }
160
161 4
        $currency = $body['currency'] ?? '';
162 4
        if ($currency !== 'CHF') {
163 1
            throw new Exception('Can only create transactions for CHF, but got: ' . $currency);
164
        }
165
166 3
        $now = Chronos::now();
167 3
        $name = 'Versement en ligne';
168
169 3
        $transaction = new Transaction();
170 3
        $this->entityManager->persist($transaction);
171 3
        $transaction->setName($name);
172 3
        $transaction->setTransactionDate($now);
173 3
        $transaction->setDatatransRef($datatransRef);
174
175
        // This could be removed later on. For now it's mostly for debugging
176 3
        $transaction->setInternalRemarks(json_encode($body, JSON_PRETTY_PRINT));
177
178 3
        $line = new TransactionLine();
179 3
        $this->entityManager->persist($line);
180 3
        $line->setName($name);
181 3
        $line->setTransactionDate($now);
182 3
        $line->setBalance(Money::CHF($body['amount']));
183 3
        $line->setTransaction($transaction);
184 3
        $line->setCredit($userAccount);
185 3
        $line->setDebit($bankAccount);
186
187 3
        $this->entityManager->flush();
188
    }
189
}
190