Passed
Push — master ( e8cad4...36e03f )
by Adrien
08:46
created

DatatransAction::process()   A

Complexity

Conditions 5
Paths 10

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

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