Каталог и корзина в Telegram Mini App на Vue 3: Pinia, createInvoiceLink и внешняя оплата

Разработка полнофункционального интернет-магазина внутри Telegram Mini App требует решения трех ключевых задач: построение быстрого UI каталога, надежное сохранение состояния корзины на клиенте и безопасный расчет итоговой суммы на бэкенде. Ошибки на любом из этих этапов приводят к расхождению цен, защищенным от пользователя потерянным товарам или уязвимостям при проведении платежей.

В этом руководстве мы разберем построение клиентской части на Vue 3 с использованием стейт-менеджера Pinia, сохранение данных в localStorage, а также напишем бэкенд на PHP для проверки initData и создания платежных счетов через Telegram Bot API и внешние эквайринги.

Схема работы: от выбора товара до проведения платежа

Типовой сценарий взаимодействия пользователя с e-commerce интерфейсом внутри WebApp включает следующие шаги:

  1. Просмотр каталога и формирование корзины: Пользователь добавляет позиции, меняет количество. Состояние мгновенно сохраняется в Pinia и дублируется в localStorage для защиты от случайной перезагрузки вкладки.
  2. Инициализация чекаута: По нажатию кнопки оплаты фронтенд отправляет состав корзины и сырую строку initData на сервер. Передавать итоговую цену с клиента нельзя — сервер обязать пересчитать стоимость каждой позиции по собственной БД.
  3. Валидация и создание заказа: Сервер проверяет подлинность initData с помощью HMAC-SHA-256, формирует уникальный lead_id и создает запись со статусом pending.
  4. Выбор платежного шлюза: Сервер либо вызывает метод Bot API createInvoiceLink и возвращает нативную ссылку Telegram Invoice, либо генерирует URL внешней платежной системы ( ЮKassa, Tinkoff, Stripe).
  5. Завершение сделки: При успешной оплате бот отправляет уведомление пользователю, а Mini App очищает локальную корзину и закрывает WebApp окно.

Хранилище корзины на Vue 3 и Pinia с поддержкой localStorage

Для управления состоянием элементов корзины используем Pinia. Нам необходимо реализовать добавление, изменение количества, удаление позиций, а также подсчет итоговой суммы и количества товаров. Автоматическая синхронизация с localStorage гарантирует сохранность пользовательских данных.

import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
state: () => ({
items: JSON.parse(localStorage.getItem('app_cart') || '[]')
}),
getters: {
totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0)
},
actions: {
addItem(product) {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
this.items.push({
id: product.id,
title: product.title,
price: product.price,
quantity: 1
});
}
this.saveToStorage();
},
removeItem(productId) {
this.items = this.items.filter(i => i.id !== productId);
this.saveToStorage();
},
updateQuantity(productId, quantity) {
const item = this.items.find(i => i.id === productId);
if (item) {
item.quantity = Math.max(1, quantity);
this.saveToStorage();
}
},
clearCart() {
this.items = [];
this.saveToStorage();
},
saveToStorage() {
localStorage.setItem('app_cart', JSON.stringify(this.items));
}
}
});

Отправка заказа из Vue в Telegram WebApp SDK

На клиентской стороне мы считываем Telegram.WebApp.initData, отправляем данные на PHP-бэкенд и обрабатываем полученный ответ. В зависимости от типа платежа мы вызываем метод openInvoice для нативных инвойсов или openLink для сторонних эквайрингов.

import { useCartStore } from './cartStore';

async function processCheckout() {
const cart = useCartStore();
const tg = window.Telegram?.WebApp;

if (!tg || !tg.initData) {
alert('Пожалуйста, откройте приложение внутри Telegram');
return;
}

if (cart.items.length === 0) {
tg.showAlert('Ваша корзина пуста');
return;
}

tg.MainButton.showProgress();

try {
const response = await fetch('/api/checkout.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
initData: tg.initData,
cart: cart.items.map(item => ({ id: item.id, quantity: item.quantity }))
})
});

const result = await response.json();

if (!response.ok || !result.ok) {
throw new Error(result.error || 'Ошибка при формировании заказа');
}

if (result.pay_type === 'telegram' && result.invoice_url) {
// Нативный счет Telegram Payments
tg.openInvoice(result.invoice_url, (status) => {
if (status === 'paid') {
cart.clearCart();
tg.close();
} else if (status === 'failed') {
tg.showAlert('Оплата не прошла или была отменена');
}
});
} else if (result.pay_type === 'external' && result.payment_url) {
// Внешний платежный шлюз
tg.openLink(result.payment_url);
}
} catch (err) {
tg.showAlert(err.message || 'Сетевая ошибка');
} finally {
tg.MainButton.hideProgress();
}
}

Бэкенд на PHP: проверка initData, пересчет цен и createInvoiceLink

На бэкенде категорически нельзя доверять ценам, переданным с фронтенда. Сервер должен извлечь идентификаторы товаров из базы данных, рассчитать сумму, валидировать HMAC подпись Telegram и вызвать метод createInvoiceLink через cURL.

<?php
declare(strict_types=1);

header('Content-Type: application/json');

$botToken = getenv('BOT_TOKEN') ?: '';
if (empty($botToken)) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Server configuration error']);
exit;
}

$rawInput = file_get_contents('php://input');
$payload = json_decode($rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE || empty($payload['initData']) || empty($payload['cart'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid payload']);
exit;
}

// 1. Валидация Telegram initData
if (!validateTelegramInitData($payload['initData'], $botToken)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Invalid initData signature']);
exit;
}

// 2. Имитация базы данных товаров и пересчет суммы
$catalogDb = [
1 => ['name' => 'Футболка Dev', 'price' => 150000], // Цена в копейках/сантимах (1500.00 RUB)
2 => ['name' => 'Худи VueJS', 'price' => 350000],
];

$totalAmount = 0;
$labeledPrices = [];

foreach ($payload['cart'] as $item) {
$id = (int)($item['id'] ?? 0);
$qty = (int)($item['quantity'] ?? 0);

if (isset($catalogDb[$id]) && $qty > 0) {
$itemPrice = $catalogDb[$id]['price'];
$totalAmount += $itemPrice * $qty;
$labeledPrices[] = [
'label' => $catalogDb[$id]['name'] . " x{$qty}",
'amount' => $itemPrice * $qty
];
}
}

if ($totalAmount === 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Cart contains invalid items']);
exit;
}

// 3. Генерация уникального идентификатора заказа (lead_id)
$leadId = bin2hex(random_bytes(7)); // 14 hex символов

// 4. Запрос к Telegram Bot API для получения createInvoiceLink
$providerToken = getenv('PAYMENT_PROVIDER_TOKEN') ?: ''; // Пусто для Telegram Stars
$invoiceData = [
'title' => "Заказ #{$leadId}",
'description' => 'Оплата заказа в магазине Mini App',
'payload' => $leadId,
'provider_token' => $providerToken,
'currency' => 'RUB',
'prices' => json_encode($labeledPrices)
];

$ch = curl_init('https://api.telegram.org/bot' . $botToken . '/createInvoiceLink');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query($invoiceData),
CURLOPT_TIMEOUT => 10,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);

if ($curlErr || $httpCode !== 200) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Failed to reach Telegram API']);
exit;
}

$resData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($resData['ok'])) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $resData['description'] ?? 'Telegram API error']);
exit;
}

echo json_encode([
'ok' => true,
'pay_type' => 'telegram',
'invoice_url' => $resData['result'],
'lead_id' => $leadId
]);

function validateTelegramInitData(string $initData, string $botToken): bool {
parse_str($initData, $data);
if (!isset($data['hash'])) {
return false;
}

$hash = $data['hash'];
unset($data['hash']);

$dataCheckArray = [];
foreach ($data as $key => $val) {
$dataCheckArray[] = $key . '=' . $val;
}
sort($dataCheckArray);
$dataCheckString = implode("
", $dataCheckArray);

$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

return hash_equals($calculatedHash, $hash);
}

Альтернатива: Использование внешнего платежного шлюза

Если товары требуют специфического эквайринга или кассовых чеков по 54-ФЗ, нативные инвойсы Telegram можно заменить прямой генерацией ссылки на платежную систему (например, ЮKassa или Сбербанк).

В этом случае логика работы бэкенда выглядит следующей образом:

  • Сервер так же проверяет initData и рассчитывает итоговую сумму заказа по БД.
  • Создается запись в локальной БД со статусом pending и уникальным lead_id.
  • Сервер отправляет REST-запрос к API платежной системы, передавая return_url и webhook_url для фиксации проведения платежа.
  • Сгенерированный URL возвращается в Mini App и открывается через метод tg.openLink(url).

Важно: При открытии внешних редиректов внутри Mini App всегда передавайте параметр отслеживания заказа (lead_id) в метаданных платежа, чтобы обработчик вебхука от платежного шлюза мог однозначно идентифицировать оплаченный чек.

Частые ошибки при проектировании E-commerce в Mini Apps

  • Доверие к цене из фронтенда: Передача готовой суммы покупки (например, totalAmount) из Vue в PHP позволяет злоумышленнику подменить тело запроса и купить товар за 1 рубль. Передавайте только массив ID и их количество.
  • Отсутствие проверки срока жизни initData: Параметр auth_date из initData необходимо проверять на бэкенде. Если с момента подписи прошло более 24 часов, запрос следует отклонять.
  • Использование $SESSION в API: REST API для Mini App должен работать без сессий на стороне сервера. Все запросы аутентифицируются строго через валидацию подписи initData.
  • Игнорирование ошибок сети при cURL: Всегда проверяйте HTTP-код ответа Telegram API, результат работы curl_error() и поле json_last_error().

Если вам требуется профессиональная интеграция сложных каталогов, складов и платежных решений в Telegram Mini Apps, специалисты BotCreator помогут реализовать проект под ключ.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.