src/Controller/Payment/PaymentController.php line 135

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Payment;
  3. use App\Controller\Catalog\CatalogRepository;
  4. use App\Controller\Catalog\CurrencyRateProvider;
  5. use App\Controller\Checkout\GalaxyLinkCheckoutService;
  6. use Doctrine\DBAL\Connection;
  7. use RuntimeException;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\JsonResponse;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\Routing\Annotation\Route;
  13. use Throwable;
  14. class PaymentController extends AbstractController
  15. {
  16.     private $db;
  17.     private $catalog;
  18.     private $currencyRates;
  19.     public function __construct(Connection $dbCatalogRepository $catalogCurrencyRateProvider $currencyRates)
  20.     {
  21.         $this->db $db;
  22.         $this->catalog $catalog;
  23.         $this->currencyRates $currencyRates;
  24.     }
  25.     /**
  26.      * @Route("/pay/cryptobot", name="payment_cryptobot_create", methods={"POST"})
  27.      */
  28.     public function createCryptoBotPayment(Request $requestCryptoBotClient $cryptoBotGalaxyLinkCheckoutService $checkout)
  29.     {
  30.         $payload $this->requestPayload($request);
  31.         try {
  32.             $this->catalog->ensureSchema();
  33.             if (!$cryptoBot->isConfigured()) {
  34.                 throw new RuntimeException('Укажите CRYPTOBOT_TOKEN в .env.');
  35.             }
  36.             $productId = (int) ($payload['product_id'] ?? 0);
  37.             $quote $this->paymentQuote($productId);
  38.             $order $checkout->prepareForPayment($payload, (string) $request->getClientIp());
  39.             $orderRow $this->orderRow($order['id']);
  40.             $paymentPublicId $this->newPaymentPublicId();
  41.             $now $this->now();
  42.             $this->db->insert('catalog_payments', [
  43.                 'public_id' => $paymentPublicId,
  44.                 'order_id' => (int) $orderRow['id'],
  45.                 'order_public_id' => $order['id'],
  46.                 'provider' => 'cryptobot',
  47.                 'status' => 'created',
  48.                 'amount' => $quote['amount'],
  49.                 'currency' => $quote['currency'],
  50.                 'created_at' => $now,
  51.                 'updated_at' => $now,
  52.             ]);
  53.             $invoicePayload $this->invoicePayload($paymentPublicId$order$quote$request);
  54.             try {
  55.                 $invoice $cryptoBot->createInvoice($invoicePayload);
  56.                 $payUrl $cryptoBot->invoiceUrl($invoice);
  57.                 if ($payUrl === '') {
  58.                     throw new RuntimeException('CryptoBot не вернул ссылку на оплату.');
  59.                 }
  60.                 $this->db->update('catalog_payments', [
  61.                     'status' => 'pending',
  62.                     'provider_invoice_id' => (string) ($invoice['invoice_id'] ?? ''),
  63.                     'provider_invoice_hash' => (string) ($invoice['hash'] ?? ''),
  64.                     'provider_status' => (string) ($invoice['status'] ?? 'active'),
  65.                     'pay_url' => $payUrl,
  66.                     'invoice_json' => $this->encodeJson($invoice),
  67.                     'updated_at' => $this->now(),
  68.                 ], ['public_id' => $paymentPublicId]);
  69.                 $this->markOrderPaymentStatus($order['id'], 'payment_pending''Счёт CryptoBot создан. Ждём оплату.');
  70.             } catch (Throwable $exception) {
  71.                 $this->db->update('catalog_payments', [
  72.                     'status' => 'failed',
  73.                     'provider_status' => 'failed',
  74.                     'updated_at' => $this->now(),
  75.                 ], ['public_id' => $paymentPublicId]);
  76.                 $this->markOrderPaymentStatus($order['id'], 'payment_failed''Не удалось создать счёт CryptoBot: ' $exception->getMessage());
  77.                 throw $exception;
  78.             }
  79.             $payment $this->paymentByPublicId($paymentPublicId);
  80.             $statusUrl $this->generateUrl('payment_status_page', ['publicId' => $paymentPublicId]);
  81.             if ($this->wantsJson($request)) {
  82.                 return $this->json([
  83.                     'ok' => true,
  84.                     'payment' => $this->publicPayment($payment),
  85.                     'order' => $checkout->refresh($order['id']),
  86.                     'redirect_url' => (string) $payment['pay_url'],
  87.                     'status_url' => $statusUrl,
  88.                 ]);
  89.             }
  90.             return $this->redirect($statusUrl);
  91.         } catch (RuntimeException $error) {
  92.             return $this->paymentError($request$error->getMessage(), 422);
  93.         } catch (Throwable $error) {
  94.             return $this->paymentError($request'Не удалось создать оплату CryptoBot: ' $error->getMessage(), 502);
  95.         }
  96.     }
  97.     /**
  98.      * @Route("/pay/{publicId}", name="payment_status_page", methods={"GET"}, requirements={"publicId"="pay_[A-Za-z0-9_-]+"})
  99.      */
  100.     public function statusPage(string $publicIdGalaxyLinkCheckoutService $checkout): Response
  101.     {
  102.         try {
  103.             $payment $this->paymentByPublicId($publicId);
  104.             $order $this->safeRefreshOrder($checkout, (string) $payment['order_public_id']);
  105.             return $this->render('payment/status.html.twig', [
  106.                 'payment' => $this->publicPayment($payment),
  107.                 'order' => $order,
  108.             ]);
  109.         } catch (Throwable $exception) {
  110.             return new Response('Оплата не найдена: ' htmlspecialchars($exception->getMessage(), ENT_QUOTES ENT_SUBSTITUTE'UTF-8'), 404);
  111.         }
  112.     }
  113.     /**
  114.      * @Route("/pay/{publicId}/status", name="payment_status_json", methods={"GET"}, requirements={"publicId"="pay_[A-Za-z0-9_-]+"})
  115.      */
  116.     public function statusJson(string $publicIdGalaxyLinkCheckoutService $checkout): JsonResponse
  117.     {
  118.         try {
  119.             $payment $this->paymentByPublicId($publicId);
  120.             return $this->json([
  121.                 'ok' => true,
  122.                 'payment' => $this->publicPayment($payment),
  123.                 'order' => $this->safeRefreshOrder($checkout, (string) $payment['order_public_id']),
  124.             ]);
  125.         } catch (RuntimeException $error) {
  126.             return $this->json([
  127.                 'ok' => false,
  128.                 'error' => $error->getMessage(),
  129.             ], 422);
  130.         } catch (Throwable $error) {
  131.             return $this->json([
  132.                 'ok' => false,
  133.                 'error' => 'Не удалось обновить статус заказа: ' $error->getMessage(),
  134.             ], 502);
  135.         }
  136.     }
  137.     /**
  138.      * @Route("/api/payments/cryptobot/{secret}", name="payment_cryptobot_webhook", methods={"POST"}, requirements={"secret"="[A-Za-z0-9_\-]+"})
  139.      */
  140.     public function cryptoBotWebhook(string $secretRequest $requestCryptoBotClient $cryptoBotGalaxyLinkCheckoutService $checkout): JsonResponse
  141.     {
  142.         $expectedSecret $this->env('CRYPTOBOT_WEBHOOK_SECRET');
  143.         if ($expectedSecret === '' || !hash_equals($expectedSecret$secret)) {
  144.             return $this->json(['ok' => false'error' => 'bad_secret'], 403);
  145.         }
  146.         $rawBody = (string) $request->getContent();
  147.         $signature = (string) $request->headers->get('crypto-pay-api-signature''');
  148.         if (!$cryptoBot->verifyWebhookSignature($rawBody$signature)) {
  149.             return $this->json(['ok' => false'error' => 'bad_signature'], 403);
  150.         }
  151.         $update json_decode($rawBodytrue);
  152.         if (!is_array($update)) {
  153.             return $this->json(['ok' => false'error' => 'bad_json'], 400);
  154.         }
  155.         if ((string) ($update['update_type'] ?? '') !== 'invoice_paid') {
  156.             return $this->json(['ok' => true'ignored' => true]);
  157.         }
  158.         $invoice = isset($update['payload']) && is_array($update['payload']) ? $update['payload'] : [];
  159.         $paymentPublicId trim((string) ($invoice['payload'] ?? ''));
  160.         $invoiceId trim((string) ($invoice['invoice_id'] ?? ''));
  161.         try {
  162.             $payment $this->paymentForWebhook($paymentPublicId$invoiceId);
  163.             $now $this->now();
  164.             $this->db->update('catalog_payments', [
  165.                 'status' => 'paid',
  166.                 'provider_invoice_id' => $invoiceId !== '' $invoiceId : (string) ($payment['provider_invoice_id'] ?? ''),
  167.                 'provider_invoice_hash' => (string) ($invoice['hash'] ?? $payment['provider_invoice_hash'] ?? ''),
  168.                 'provider_status' => (string) ($invoice['status'] ?? 'paid'),
  169.                 'webhook_json' => $this->encodeJson($update),
  170.                 'paid_at' => $this->paidAt($invoice) ?? $now,
  171.                 'updated_at' => $now,
  172.             ], ['id' => (int) $payment['id']]);
  173.             $this->markOrderPaymentStatus((string) $payment['order_public_id'], 'payment_paid''Оплата получена. Создаём заказ в GalaxyLink.');
  174.             $order $checkout->sendPreparedOrderToGalaxyLink((string) $payment['order_public_id']);
  175.             $payment $this->paymentByPublicId((string) $payment['public_id']);
  176.             return $this->json([
  177.                 'ok' => true,
  178.                 'payment' => $this->publicPayment($payment),
  179.                 'order' => $order,
  180.             ]);
  181.         } catch (RuntimeException $error) {
  182.             return $this->json([
  183.                 'ok' => false,
  184.                 'error' => $error->getMessage(),
  185.             ], 422);
  186.         } catch (Throwable $error) {
  187.             return $this->json([
  188.                 'ok' => false,
  189.                 'error' => 'Оплата получена, но заказ GalaxyLink не создан: ' $error->getMessage(),
  190.             ], 502);
  191.         }
  192.     }
  193.     private function requestPayload(Request $request): array
  194.     {
  195.         $payload json_decode((string) $request->getContent(), true);
  196.         if (!is_array($payload)) {
  197.             $payload $request->request->all();
  198.         }
  199.         if (!isset($payload['fields']) || !is_array($payload['fields'])) {
  200.             $fields = [];
  201.             foreach ($payload as $key => $value) {
  202.                 if (strpos((string) $key'field_') === 0) {
  203.                     $fields[substr((string) $key6)] = $value;
  204.                 }
  205.             }
  206.             $payload['fields'] = $fields;
  207.         }
  208.         return $payload;
  209.     }
  210.     private function invoicePayload(string $paymentPublicId, array $order, array $quoteRequest $request): array
  211.     {
  212.         $description trim('PlayPapa: ' . (string) ($order['product_name'] ?? 'заказ'));
  213.         $payload = [
  214.             'amount' => $this->formatAmount((float) $quote['amount']),
  215.             'payload' => $paymentPublicId,
  216.             'description' => mb_substr($description01024'UTF-8'),
  217.             'currency_type' => 'fiat',
  218.             'fiat' => $quote['currency'],
  219.             'paid_btn_name' => 'callback',
  220.             'paid_btn_url' => $this->absoluteUrl($request$this->generateUrl('payment_status_page', ['publicId' => $paymentPublicId])),
  221.             'expires_in' => $this->invoiceExpiresIn(),
  222.         ];
  223.         $acceptedAssets trim($this->env('CRYPTOBOT_ACCEPTED_ASSETS'));
  224.         if ($acceptedAssets !== '') {
  225.             $payload['accepted_assets'] = $acceptedAssets;
  226.         }
  227.         return $payload;
  228.     }
  229.     private function paymentQuote(int $productId): array
  230.     {
  231.         if ($productId <= 0) {
  232.             throw new RuntimeException('Не выбран товар для покупки.');
  233.         }
  234.         $product $this->catalog->findProduct((string) $productId);
  235.         if (!$product) {
  236.             throw new RuntimeException('Товар не найден или скрыт.');
  237.         }
  238.         $variant = isset($product['selected_variant']) && is_array($product['selected_variant']) ? $product['selected_variant'] : [];
  239.         $sourceAmount $variant['price_amount'] ?? $product['price_amount'] ?? null;
  240.         $sourceCurrency = (string) ($variant['price_currency'] ?? $product['price_currency'] ?? 'USD');
  241.         $rubAmount $this->currencyRates->convertToRub($sourceAmount === null null : (float) $sourceAmount$sourceCurrency);
  242.         if ($rubAmount === null) {
  243.             $displayAmount $variant['price_display_amount'] ?? $product['price_display_amount'] ?? null;
  244.             $displayCurrency strtoupper((string) ($variant['price_display_currency'] ?? $product['price_display_currency'] ?? ''));
  245.             if ($displayCurrency === 'RUB' && $displayAmount !== null) {
  246.                 $rubAmount = (float) $displayAmount;
  247.             }
  248.         }
  249.         if ($rubAmount === null || $rubAmount <= 0) {
  250.             throw new RuntimeException('У товара нет цены для оплаты.');
  251.         }
  252.         $markupPercent = (float) str_replace(',''.'$this->env('PLAYPAPA_PAYMENT_MARKUP_PERCENT') ?: '0');
  253.         $amount max(1, (int) ceil($rubAmount * ($markupPercent 100)));
  254.         $currency strtoupper(trim($this->env('CRYPTOBOT_FIAT') ?: 'RUB'));
  255.         return [
  256.             'amount' => $amount,
  257.             'currency' => $currency,
  258.             'label' => number_format($amount0','' ') . ' ₽',
  259.             'product_name' => (string) ($variant['name'] ?? $product['name'] ?? 'Товар PlayPapa'),
  260.         ];
  261.     }
  262.     private function paymentForWebhook(string $paymentPublicIdstring $invoiceId): array
  263.     {
  264.         if ($paymentPublicId !== '') {
  265.             return $this->paymentByPublicId($paymentPublicId);
  266.         }
  267.         if ($invoiceId !== '') {
  268.             $row $this->db->fetchAssociative(
  269.                 'SELECT * FROM catalog_payments WHERE provider = ? AND provider_invoice_id = ? LIMIT 1',
  270.                 ['cryptobot'$invoiceId]
  271.             );
  272.             if ($row) {
  273.                 return $row;
  274.             }
  275.         }
  276.         throw new RuntimeException('Платёж CryptoBot не найден.');
  277.     }
  278.     private function paymentByPublicId(string $publicId): array
  279.     {
  280.         $this->catalog->ensureSchema();
  281.         $row $this->db->fetchAssociative('SELECT * FROM catalog_payments WHERE public_id = ? LIMIT 1', [$publicId]);
  282.         if (!$row) {
  283.             throw new RuntimeException('Платёж не найден.');
  284.         }
  285.         return $row;
  286.     }
  287.     private function safeRefreshOrder(GalaxyLinkCheckoutService $checkoutstring $orderPublicId): array
  288.     {
  289.         try {
  290.             return $checkout->refresh($orderPublicId);
  291.         } catch (Throwable $error) {
  292.             return [
  293.                 'id' => $orderPublicId,
  294.                 'status' => 'galaxylink_error',
  295.                 'status_label' => 'Ошибка GalaxyLink',
  296.                 'provider_order_id' => '',
  297.                 'partner_order_id' => '',
  298.                 'product_name' => '',
  299.                 'product_code' => '',
  300.                 'amount_label' => '',
  301.                 'message' => 'Не удалось обновить статус GalaxyLink: ' $error->getMessage(),
  302.                 'items' => [],
  303.                 'verification' => [],
  304.                 'error' => $error->getMessage(),
  305.                 'terminal' => false,
  306.             ];
  307.         }
  308.     }
  309.     private function orderRow(string $publicId): array
  310.     {
  311.         $row $this->db->fetchAssociative('SELECT * FROM catalog_orders WHERE public_id = ? LIMIT 1', [$publicId]);
  312.         if (!$row) {
  313.             throw new RuntimeException('Подготовленный заказ не найден.');
  314.         }
  315.         return $row;
  316.     }
  317.     private function markOrderPaymentStatus(string $orderPublicIdstring $statusstring $message): void
  318.     {
  319.         $this->db->update('catalog_orders', [
  320.             'status' => $status,
  321.             'delivery_json' => $this->encodeJson([
  322.                 'status' => $status,
  323.                 'message' => $message,
  324.             ]),
  325.             'updated_at' => $this->now(),
  326.         ], ['public_id' => $orderPublicId]);
  327.     }
  328.     private function publicPayment(array $row): array
  329.     {
  330.         $status = (string) ($row['status'] ?? 'created');
  331.         $amount = (float) ($row['amount'] ?? 0);
  332.         $currency = (string) ($row['currency'] ?? 'RUB');
  333.         return [
  334.             'id' => (string) ($row['public_id'] ?? ''),
  335.             'status' => $status,
  336.             'status_label' => $this->paymentStatusLabel($status),
  337.             'amount' => $amount,
  338.             'currency' => $currency,
  339.             'amount_label' => number_format($amount0','' ') . ($currency === 'RUB' ' ₽' ' ' $currency),
  340.             'invoice_id' => (string) ($row['provider_invoice_id'] ?? ''),
  341.             'provider_status' => (string) ($row['provider_status'] ?? ''),
  342.             'pay_url' => (string) ($row['pay_url'] ?? ''),
  343.             'paid_at' => (string) ($row['paid_at'] ?? ''),
  344.         ];
  345.     }
  346.     private function paymentStatusLabel(string $status): string
  347.     {
  348.         $labels = [
  349.             'created' => 'Создаём счёт',
  350.             'pending' => 'Ожидает оплаты',
  351.             'paid' => 'Оплачен',
  352.             'failed' => 'Ошибка оплаты',
  353.             'expired' => 'Счёт истёк',
  354.         ];
  355.         return $labels[$status] ?? $status;
  356.     }
  357.     private function paymentError(Request $requeststring $messageint $status)
  358.     {
  359.         if ($this->wantsJson($request)) {
  360.             return $this->json([
  361.                 'ok' => false,
  362.                 'error' => $message,
  363.             ], $status);
  364.         }
  365.         return new Response('Не удалось создать оплату: ' htmlspecialchars($messageENT_QUOTES ENT_SUBSTITUTE'UTF-8'), $status);
  366.     }
  367.     private function wantsJson(Request $request): bool
  368.     {
  369.         $accept strtolower((string) $request->headers->get('Accept'''));
  370.         $contentType strtolower((string) $request->headers->get('Content-Type'''));
  371.         return strpos($accept'application/json') !== false || strpos($contentType'application/json') !== false || $request->isXmlHttpRequest();
  372.     }
  373.     private function paidAt(array $invoice): ?string
  374.     {
  375.         $paidAt = (string) ($invoice['paid_at'] ?? '');
  376.         if ($paidAt === '') {
  377.             return null;
  378.         }
  379.         $timestamp strtotime($paidAt);
  380.         return $timestamp === false null date('Y-m-d H:i:s'$timestamp);
  381.     }
  382.     private function absoluteUrl(Request $requeststring $path): string
  383.     {
  384.         $baseUrl rtrim($this->env('PLAYPAPA_PUBLIC_BASE_URL'), '/');
  385.         if ($baseUrl === '') {
  386.             $baseUrl $request->getSchemeAndHttpHost();
  387.         }
  388.         return $baseUrl $path;
  389.     }
  390.     private function invoiceExpiresIn(): int
  391.     {
  392.         $seconds = (int) ($this->env('CRYPTOBOT_EXPIRES_IN') ?: 3600);
  393.         return max(60min(2678400$seconds));
  394.     }
  395.     private function formatAmount(float $amount): string
  396.     {
  397.         if (abs($amount round($amount)) < 0.00001) {
  398.             return (string) (int) round($amount);
  399.         }
  400.         return rtrim(rtrim(number_format($amount2'.'''), '0'), '.');
  401.     }
  402.     private function newPaymentPublicId(): string
  403.     {
  404.         return 'pay_' bin2hex(random_bytes(10));
  405.     }
  406.     private function encodeJson(array $value): string
  407.     {
  408.         $json json_encode($valueJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES);
  409.         return $json === false '[]' $json;
  410.     }
  411.     private function now(): string
  412.     {
  413.         return date('Y-m-d H:i:s');
  414.     }
  415.     private function env(string $name): string
  416.     {
  417.         if (isset($_SERVER[$name]) && $_SERVER[$name] !== '') {
  418.             return (string) $_SERVER[$name];
  419.         }
  420.         if (isset($_ENV[$name]) && $_ENV[$name] !== '') {
  421.             return (string) $_ENV[$name];
  422.         }
  423.         $value getenv($name);
  424.         return is_string($value) ? $value '';
  425.     }
  426. }