Ecodev /
my-ichtus
| 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) |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 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 |