<?php
namespace App\Controller\Payment;
use App\Controller\Catalog\CatalogRepository;
use App\Controller\Catalog\CurrencyRateProvider;
use App\Controller\Checkout\GalaxyLinkCheckoutService;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Throwable;
class PaymentController extends AbstractController
{
private $db;
private $catalog;
private $currencyRates;
public function __construct(Connection $db, CatalogRepository $catalog, CurrencyRateProvider $currencyRates)
{
$this->db = $db;
$this->catalog = $catalog;
$this->currencyRates = $currencyRates;
}
/**
* @Route("/pay/cryptobot", name="payment_cryptobot_create", methods={"POST"})
*/
public function createCryptoBotPayment(Request $request, CryptoBotClient $cryptoBot, GalaxyLinkCheckoutService $checkout)
{
$payload = $this->requestPayload($request);
try {
$this->catalog->ensureSchema();
if (!$cryptoBot->isConfigured()) {
throw new RuntimeException('Укажите CRYPTOBOT_TOKEN в .env.');
}
$productId = (int) ($payload['product_id'] ?? 0);
$quote = $this->paymentQuote($productId);
$order = $checkout->prepareForPayment($payload, (string) $request->getClientIp());
$orderRow = $this->orderRow($order['id']);
$paymentPublicId = $this->newPaymentPublicId();
$now = $this->now();
$this->db->insert('catalog_payments', [
'public_id' => $paymentPublicId,
'order_id' => (int) $orderRow['id'],
'order_public_id' => $order['id'],
'provider' => 'cryptobot',
'status' => 'created',
'amount' => $quote['amount'],
'currency' => $quote['currency'],
'created_at' => $now,
'updated_at' => $now,
]);
$invoicePayload = $this->invoicePayload($paymentPublicId, $order, $quote, $request);
try {
$invoice = $cryptoBot->createInvoice($invoicePayload);
$payUrl = $cryptoBot->invoiceUrl($invoice);
if ($payUrl === '') {
throw new RuntimeException('CryptoBot не вернул ссылку на оплату.');
}
$this->db->update('catalog_payments', [
'status' => 'pending',
'provider_invoice_id' => (string) ($invoice['invoice_id'] ?? ''),
'provider_invoice_hash' => (string) ($invoice['hash'] ?? ''),
'provider_status' => (string) ($invoice['status'] ?? 'active'),
'pay_url' => $payUrl,
'invoice_json' => $this->encodeJson($invoice),
'updated_at' => $this->now(),
], ['public_id' => $paymentPublicId]);
$this->markOrderPaymentStatus($order['id'], 'payment_pending', 'Счёт CryptoBot создан. Ждём оплату.');
} catch (Throwable $exception) {
$this->db->update('catalog_payments', [
'status' => 'failed',
'provider_status' => 'failed',
'updated_at' => $this->now(),
], ['public_id' => $paymentPublicId]);
$this->markOrderPaymentStatus($order['id'], 'payment_failed', 'Не удалось создать счёт CryptoBot: ' . $exception->getMessage());
throw $exception;
}
$payment = $this->paymentByPublicId($paymentPublicId);
$statusUrl = $this->generateUrl('payment_status_page', ['publicId' => $paymentPublicId]);
if ($this->wantsJson($request)) {
return $this->json([
'ok' => true,
'payment' => $this->publicPayment($payment),
'order' => $checkout->refresh($order['id']),
'redirect_url' => (string) $payment['pay_url'],
'status_url' => $statusUrl,
]);
}
return $this->redirect($statusUrl);
} catch (RuntimeException $error) {
return $this->paymentError($request, $error->getMessage(), 422);
} catch (Throwable $error) {
return $this->paymentError($request, 'Не удалось создать оплату CryptoBot: ' . $error->getMessage(), 502);
}
}
/**
* @Route("/pay/{publicId}", name="payment_status_page", methods={"GET"}, requirements={"publicId"="pay_[A-Za-z0-9_-]+"})
*/
public function statusPage(string $publicId, GalaxyLinkCheckoutService $checkout): Response
{
try {
$payment = $this->paymentByPublicId($publicId);
$order = $this->safeRefreshOrder($checkout, (string) $payment['order_public_id']);
return $this->render('payment/status.html.twig', [
'payment' => $this->publicPayment($payment),
'order' => $order,
]);
} catch (Throwable $exception) {
return new Response('Оплата не найдена: ' . htmlspecialchars($exception->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 404);
}
}
/**
* @Route("/pay/{publicId}/status", name="payment_status_json", methods={"GET"}, requirements={"publicId"="pay_[A-Za-z0-9_-]+"})
*/
public function statusJson(string $publicId, GalaxyLinkCheckoutService $checkout): JsonResponse
{
try {
$payment = $this->paymentByPublicId($publicId);
return $this->json([
'ok' => true,
'payment' => $this->publicPayment($payment),
'order' => $this->safeRefreshOrder($checkout, (string) $payment['order_public_id']),
]);
} catch (RuntimeException $error) {
return $this->json([
'ok' => false,
'error' => $error->getMessage(),
], 422);
} catch (Throwable $error) {
return $this->json([
'ok' => false,
'error' => 'Не удалось обновить статус заказа: ' . $error->getMessage(),
], 502);
}
}
/**
* @Route("/api/payments/cryptobot/{secret}", name="payment_cryptobot_webhook", methods={"POST"}, requirements={"secret"="[A-Za-z0-9_\-]+"})
*/
public function cryptoBotWebhook(string $secret, Request $request, CryptoBotClient $cryptoBot, GalaxyLinkCheckoutService $checkout): JsonResponse
{
$expectedSecret = $this->env('CRYPTOBOT_WEBHOOK_SECRET');
if ($expectedSecret === '' || !hash_equals($expectedSecret, $secret)) {
return $this->json(['ok' => false, 'error' => 'bad_secret'], 403);
}
$rawBody = (string) $request->getContent();
$signature = (string) $request->headers->get('crypto-pay-api-signature', '');
if (!$cryptoBot->verifyWebhookSignature($rawBody, $signature)) {
return $this->json(['ok' => false, 'error' => 'bad_signature'], 403);
}
$update = json_decode($rawBody, true);
if (!is_array($update)) {
return $this->json(['ok' => false, 'error' => 'bad_json'], 400);
}
if ((string) ($update['update_type'] ?? '') !== 'invoice_paid') {
return $this->json(['ok' => true, 'ignored' => true]);
}
$invoice = isset($update['payload']) && is_array($update['payload']) ? $update['payload'] : [];
$paymentPublicId = trim((string) ($invoice['payload'] ?? ''));
$invoiceId = trim((string) ($invoice['invoice_id'] ?? ''));
try {
$payment = $this->paymentForWebhook($paymentPublicId, $invoiceId);
$now = $this->now();
$this->db->update('catalog_payments', [
'status' => 'paid',
'provider_invoice_id' => $invoiceId !== '' ? $invoiceId : (string) ($payment['provider_invoice_id'] ?? ''),
'provider_invoice_hash' => (string) ($invoice['hash'] ?? $payment['provider_invoice_hash'] ?? ''),
'provider_status' => (string) ($invoice['status'] ?? 'paid'),
'webhook_json' => $this->encodeJson($update),
'paid_at' => $this->paidAt($invoice) ?? $now,
'updated_at' => $now,
], ['id' => (int) $payment['id']]);
$this->markOrderPaymentStatus((string) $payment['order_public_id'], 'payment_paid', 'Оплата получена. Создаём заказ в GalaxyLink.');
$order = $checkout->sendPreparedOrderToGalaxyLink((string) $payment['order_public_id']);
$payment = $this->paymentByPublicId((string) $payment['public_id']);
return $this->json([
'ok' => true,
'payment' => $this->publicPayment($payment),
'order' => $order,
]);
} catch (RuntimeException $error) {
return $this->json([
'ok' => false,
'error' => $error->getMessage(),
], 422);
} catch (Throwable $error) {
return $this->json([
'ok' => false,
'error' => 'Оплата получена, но заказ GalaxyLink не создан: ' . $error->getMessage(),
], 502);
}
}
private function requestPayload(Request $request): array
{
$payload = json_decode((string) $request->getContent(), true);
if (!is_array($payload)) {
$payload = $request->request->all();
}
if (!isset($payload['fields']) || !is_array($payload['fields'])) {
$fields = [];
foreach ($payload as $key => $value) {
if (strpos((string) $key, 'field_') === 0) {
$fields[substr((string) $key, 6)] = $value;
}
}
$payload['fields'] = $fields;
}
return $payload;
}
private function invoicePayload(string $paymentPublicId, array $order, array $quote, Request $request): array
{
$description = trim('PlayPapa: ' . (string) ($order['product_name'] ?? 'заказ'));
$payload = [
'amount' => $this->formatAmount((float) $quote['amount']),
'payload' => $paymentPublicId,
'description' => mb_substr($description, 0, 1024, 'UTF-8'),
'currency_type' => 'fiat',
'fiat' => $quote['currency'],
'paid_btn_name' => 'callback',
'paid_btn_url' => $this->absoluteUrl($request, $this->generateUrl('payment_status_page', ['publicId' => $paymentPublicId])),
'expires_in' => $this->invoiceExpiresIn(),
];
$acceptedAssets = trim($this->env('CRYPTOBOT_ACCEPTED_ASSETS'));
if ($acceptedAssets !== '') {
$payload['accepted_assets'] = $acceptedAssets;
}
return $payload;
}
private function paymentQuote(int $productId): array
{
if ($productId <= 0) {
throw new RuntimeException('Не выбран товар для покупки.');
}
$product = $this->catalog->findProduct((string) $productId);
if (!$product) {
throw new RuntimeException('Товар не найден или скрыт.');
}
$variant = isset($product['selected_variant']) && is_array($product['selected_variant']) ? $product['selected_variant'] : [];
$sourceAmount = $variant['price_amount'] ?? $product['price_amount'] ?? null;
$sourceCurrency = (string) ($variant['price_currency'] ?? $product['price_currency'] ?? 'USD');
$rubAmount = $this->currencyRates->convertToRub($sourceAmount === null ? null : (float) $sourceAmount, $sourceCurrency);
if ($rubAmount === null) {
$displayAmount = $variant['price_display_amount'] ?? $product['price_display_amount'] ?? null;
$displayCurrency = strtoupper((string) ($variant['price_display_currency'] ?? $product['price_display_currency'] ?? ''));
if ($displayCurrency === 'RUB' && $displayAmount !== null) {
$rubAmount = (float) $displayAmount;
}
}
if ($rubAmount === null || $rubAmount <= 0) {
throw new RuntimeException('У товара нет цены для оплаты.');
}
$markupPercent = (float) str_replace(',', '.', $this->env('PLAYPAPA_PAYMENT_MARKUP_PERCENT') ?: '0');
$amount = max(1, (int) ceil($rubAmount * (1 + $markupPercent / 100)));
$currency = strtoupper(trim($this->env('CRYPTOBOT_FIAT') ?: 'RUB'));
return [
'amount' => $amount,
'currency' => $currency,
'label' => number_format($amount, 0, ',', ' ') . ' ₽',
'product_name' => (string) ($variant['name'] ?? $product['name'] ?? 'Товар PlayPapa'),
];
}
private function paymentForWebhook(string $paymentPublicId, string $invoiceId): array
{
if ($paymentPublicId !== '') {
return $this->paymentByPublicId($paymentPublicId);
}
if ($invoiceId !== '') {
$row = $this->db->fetchAssociative(
'SELECT * FROM catalog_payments WHERE provider = ? AND provider_invoice_id = ? LIMIT 1',
['cryptobot', $invoiceId]
);
if ($row) {
return $row;
}
}
throw new RuntimeException('Платёж CryptoBot не найден.');
}
private function paymentByPublicId(string $publicId): array
{
$this->catalog->ensureSchema();
$row = $this->db->fetchAssociative('SELECT * FROM catalog_payments WHERE public_id = ? LIMIT 1', [$publicId]);
if (!$row) {
throw new RuntimeException('Платёж не найден.');
}
return $row;
}
private function safeRefreshOrder(GalaxyLinkCheckoutService $checkout, string $orderPublicId): array
{
try {
return $checkout->refresh($orderPublicId);
} catch (Throwable $error) {
return [
'id' => $orderPublicId,
'status' => 'galaxylink_error',
'status_label' => 'Ошибка GalaxyLink',
'provider_order_id' => '',
'partner_order_id' => '',
'product_name' => '',
'product_code' => '',
'amount_label' => '',
'message' => 'Не удалось обновить статус GalaxyLink: ' . $error->getMessage(),
'items' => [],
'verification' => [],
'error' => $error->getMessage(),
'terminal' => false,
];
}
}
private function orderRow(string $publicId): array
{
$row = $this->db->fetchAssociative('SELECT * FROM catalog_orders WHERE public_id = ? LIMIT 1', [$publicId]);
if (!$row) {
throw new RuntimeException('Подготовленный заказ не найден.');
}
return $row;
}
private function markOrderPaymentStatus(string $orderPublicId, string $status, string $message): void
{
$this->db->update('catalog_orders', [
'status' => $status,
'delivery_json' => $this->encodeJson([
'status' => $status,
'message' => $message,
]),
'updated_at' => $this->now(),
], ['public_id' => $orderPublicId]);
}
private function publicPayment(array $row): array
{
$status = (string) ($row['status'] ?? 'created');
$amount = (float) ($row['amount'] ?? 0);
$currency = (string) ($row['currency'] ?? 'RUB');
return [
'id' => (string) ($row['public_id'] ?? ''),
'status' => $status,
'status_label' => $this->paymentStatusLabel($status),
'amount' => $amount,
'currency' => $currency,
'amount_label' => number_format($amount, 0, ',', ' ') . ($currency === 'RUB' ? ' ₽' : ' ' . $currency),
'invoice_id' => (string) ($row['provider_invoice_id'] ?? ''),
'provider_status' => (string) ($row['provider_status'] ?? ''),
'pay_url' => (string) ($row['pay_url'] ?? ''),
'paid_at' => (string) ($row['paid_at'] ?? ''),
];
}
private function paymentStatusLabel(string $status): string
{
$labels = [
'created' => 'Создаём счёт',
'pending' => 'Ожидает оплаты',
'paid' => 'Оплачен',
'failed' => 'Ошибка оплаты',
'expired' => 'Счёт истёк',
];
return $labels[$status] ?? $status;
}
private function paymentError(Request $request, string $message, int $status)
{
if ($this->wantsJson($request)) {
return $this->json([
'ok' => false,
'error' => $message,
], $status);
}
return new Response('Не удалось создать оплату: ' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $status);
}
private function wantsJson(Request $request): bool
{
$accept = strtolower((string) $request->headers->get('Accept', ''));
$contentType = strtolower((string) $request->headers->get('Content-Type', ''));
return strpos($accept, 'application/json') !== false || strpos($contentType, 'application/json') !== false || $request->isXmlHttpRequest();
}
private function paidAt(array $invoice): ?string
{
$paidAt = (string) ($invoice['paid_at'] ?? '');
if ($paidAt === '') {
return null;
}
$timestamp = strtotime($paidAt);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function absoluteUrl(Request $request, string $path): string
{
$baseUrl = rtrim($this->env('PLAYPAPA_PUBLIC_BASE_URL'), '/');
if ($baseUrl === '') {
$baseUrl = $request->getSchemeAndHttpHost();
}
return $baseUrl . $path;
}
private function invoiceExpiresIn(): int
{
$seconds = (int) ($this->env('CRYPTOBOT_EXPIRES_IN') ?: 3600);
return max(60, min(2678400, $seconds));
}
private function formatAmount(float $amount): string
{
if (abs($amount - round($amount)) < 0.00001) {
return (string) (int) round($amount);
}
return rtrim(rtrim(number_format($amount, 2, '.', ''), '0'), '.');
}
private function newPaymentPublicId(): string
{
return 'pay_' . bin2hex(random_bytes(10));
}
private function encodeJson(array $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $json === false ? '[]' : $json;
}
private function now(): string
{
return date('Y-m-d H:i:s');
}
private function env(string $name): string
{
if (isset($_SERVER[$name]) && $_SERVER[$name] !== '') {
return (string) $_SERVER[$name];
}
if (isset($_ENV[$name]) && $_ENV[$name] !== '') {
return (string) $_ENV[$name];
}
$value = getenv($name);
return is_string($value) ? $value : '';
}
}