Passed
Push — master ( ac9e1d...16f5f3 )
by Adrien
14:00 queued 02:27
created

DatatransAction   A

Complexity

Total Complexity 19

Size/Duplication

Total Lines 192
Duplicated Lines 0 %

Test Coverage

Coverage 98.75%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 80
c 3
b 0
f 0
dl 0
loc 192
ccs 79
cts 80
cp 0.9875
rs 10
wmc 19

6 Methods

Rating   Name   Duplication   Size   Complexity  
A dispatch() 0 21 4
A __construct() 0 5 1
A createMessage() 0 6 1
A createTransactions() 0 55 5
A process() 0 26 5
A checkSignature() 0 10 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Action;
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 Cake\Chronos\Chronos;
13
use Doctrine\ORM\EntityManager;
14
use Money\Money;
15
use Psr\Http\Message\ResponseInterface;
16
use Psr\Http\Message\ServerRequestInterface;
17
use Psr\Http\Server\RequestHandlerInterface;
18
use Zend\Diactoros\Response\HtmlResponse;
19
use Zend\Expressive\Template\TemplateRendererInterface;
20
21
class DatatransAction extends AbstractAction
22
{
23
    /** @var TemplateRendererInterface */
24
    private $template;
25
26
    /**
27
     * @var EntityManager
28
     */
29
    private $entityManager;
30
31
    /**
32
     * @var array
33
     */
34
    private $config;
35
36
    /**
37
     * DatatransAction constructor.
38
     *
39
     * @param EntityManager $entityManager
40
     * @param TemplateRendererInterface $template
41
     * @param array $config
42
     */
43 11
    public function __construct(EntityManager $entityManager, TemplateRendererInterface $template, array $config)
44
    {
45 11
        $this->entityManager = $entityManager;
46 11
        $this->template = $template;
47 11
        $this->config = $config;
48 11
    }
49
50
    /**
51
     * Webhook called by datatrans when a payment was made
52
     *
53
     * See documentation: https://api-reference.datatrans.ch/#failed-unsuccessful-authorization-response
54
     *
55
     * @param ServerRequestInterface $request
56
     * @param RequestHandlerInterface $handler
57
     *
58
     * @return ResponseInterface
59
     */
60 11
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
61
    {
62 11
        $request->getMethod();
63 11
        $body = $request->getParsedBody();
64
65
        try {
66 11
            if (!is_array($body)) {
67 1
                throw new \Exception('Parsed body is expected to be an array but got: ' . gettype($body));
68
            }
69
70 10
            if (isset($this->config['key'])) {
71 10
                $this->checkSignature($body, $this->config['key']);
72
            }
73
74 9
            $status = $body['status'] ?? '';
75
76 9
            $message = $this->dispatch($status, $body);
77 6
        } catch (\Throwable $exception) {
78 6
            $message = $this->createMessage('error', $exception->getMessage(), is_array($body) ? $body : []);
79
        }
80
81
        $viewModel = [
82 11
            'message' => $message,
83
        ];
84
85 11
        return new HtmlResponse($this->template->render('app::datatrans', $viewModel));
86
    }
87
88
    /**
89
     * Make sure the signature protecting important body fields is valid
90
     *
91
     * @param array $body
92
     * @param string $key HMAC-SHA256 signing key in hexadecimal format
93
     *
94
     * @throws \Exception
95
     */
96 10
    private function checkSignature(array $body, string $key): void
97
    {
98 10
        if (!isset($body['sign'])) {
99
            throw new \Exception('Missing HMAC signature');
100
        }
101 10
        $aliasCC = $body['aliasCC'] ?? '';
102 10
        $valueToSign = $aliasCC . @$body['merchantId'] . @$body['amount'] . @$body['currency'] . @$body['refno'];
103 10
        $expectedSign = hash_hmac('sha256', trim($valueToSign), hex2bin(trim($key)));
104 10
        if ($expectedSign !== $body['sign']) {
105 1
            throw new \Exception('Invalid HMAC signature');
106
        }
107 9
    }
108
109
    /**
110
     * Create a message in a coherent way
111
     *
112
     * @param string $status
113
     * @param string $message
114
     * @param array $detail
115
     *
116
     * @return array
117
     */
118 11
    private function createMessage(string $status, string $message, array $detail): array
119
    {
120
        return [
121 11
            'status' => $status,
122 11
            'message' => $message,
123 11
            'detail' => $detail,
124
        ];
125
    }
126
127
    /**
128
     * Dispatch the data received from Datatrans to take appropriate actions
129
     *
130
     * @param string $status
131
     * @param $body
132
     *
133
     * @return array
134
     */
135 9
    private function dispatch(string $status, array $body): array
136
    {
137 9
        switch ($status) {
138 9
            case 'success':
139 6
                $this->createTransactions($body);
140 3
                $message = $this->createMessage($status, $body['responseMessage'], $body);
141
142 3
                break;
143 3
            case 'error':
144 1
                $message = $this->createMessage($status, $body['errorMessage'], $body);
145
146 1
                break;
147 2
            case 'cancel':
148 1
                $message = $this->createMessage($status, 'Cancelled', $body);
149
150 1
                break;
151
            default:
152 1
                throw new \Exception('Unsupported status in Datatrans data: ' . $status);
153
        }
154
155 5
        return $message;
156
    }
157
158 6
    private function createTransactions(array $body): void
159
    {
160
        // Create only if a transaction with the same Datatrans reference doesn't already exist
161 6
        $datatransRef = $body['uppTransactionId'];
162 6
        $transactionRepository = $this->entityManager->getRepository(Transaction::class);
163 6
        $existing = $transactionRepository->count(['datatransRef' => $datatransRef]);
164
165 6
        if ($existing) {
166 3
            return;
167
        }
168
169 6
        $userId = $body['refno'] ?? null;
170
171
        /** @var User $user */
172 6
        $user = $this->entityManager->getRepository(User::class)->getOneById((int) $userId);
173 6
        if (!$user) {
174 1
            throw new \Exception('Cannot create transactions without a user');
175
        }
176
177 5
        $accountRepository = $this->entityManager->getRepository(Account::class);
178 5
        $userAccount = $accountRepository->getOrCreate($user);
179 5
        $bankAccount = $accountRepository->getOneById(AccountRepository::ACCOUNT_ID_FOR_BANK);
180
181 5
        if (!array_key_exists('amount', $body)) {
182
            // Do not support "registrations"
183 1
            throw new \Exception('Cannot create transactions without an amount');
184
        }
185
186 4
        $currency = $body['currency'] ?? '';
187 4
        if ($currency !== 'CHF') {
188 1
            throw new \Exception('Can only create transactions for CHF, but got: ' . $currency);
189
        }
190
191 3
        $now = Chronos::now();
192 3
        $name = 'Versement en ligne';
193
194 3
        $transaction = new Transaction();
195 3
        $this->entityManager->persist($transaction);
196 3
        $transaction->setName($name);
197 3
        $transaction->setTransactionDate($now);
198 3
        $transaction->setDatatransRef($datatransRef);
199
200
        // This could be removed later on. For now it's mostly for debugging
201 3
        $transaction->setInternalRemarks(json_encode($body, JSON_PRETTY_PRINT));
202
203 3
        $line = new TransactionLine();
204 3
        $this->entityManager->persist($line);
205 3
        $line->setName($name);
206 3
        $line->setTransactionDate($now);
207 3
        $line->setBalance(Money::CHF($body['amount']));
208 3
        $line->setTransaction($transaction);
209 3
        $line->setCredit($userAccount);
210 3
        $line->setDebit($bankAccount);
211
212 3
        $this->entityManager->flush();
213 3
    }
214
}
215