Каталог та кошик у 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

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.