Каталог товарів в Telegram Mini App на Vue: Pinia, localStorage і оплата через Invoice

Типований магазин у Telegram Mini App: каталог → корзина → счёт. Фронтенд на Vue 3 тримає UI та корзину, бекенд на PHP перевіряє initData, перекасовує суму з нуля і викликає sendInvoice. Нижче — робочий контур без зайвого абстрактного шуму: Composable/store, оформлення замовлення і PHP-скетч інвойсу.

\n\n

Цілісний потік

\n
    \n
  1. Користувач відкриває Mini App (t.me/Bot/App або кнопка Menu).
  2. \n
  3. Vue викликає Telegram.WebApp.ready(), запитує каталог із заголовком X-Telegram-Init-Data.
  4. \n
  5. Бекенд валідує HMAC, відправляє товари (id, title, ціна в копійках/мінімальних одиницях).
  6. \n
  7. Клієнт додає позиції до store (Pinia або простий reactive + localStorage).
  8. \n
  9. «Оплатити» → POST /api/checkout з id позицій і кількістю (не використовуючи загальну суму від клієнта як істинну).
  10. \n
  11. Сервер пересчитає суму, створити замовлення, викликати sendInvoice, повернути посилання або одразу відкрити інвойс через бота.
  12. \n
  13. Клієнт: Telegram.WebApp.openInvoice(url) (або чекає повідомлення-счету в чаті).
  14. \n
  15. Бот обробляє pre_checkout_query і successful_payment.
  16. \n
\n\n

Каталог: запит з initData

\n
// api/client.js
export async function fetchCatalog() {
const initData = window.Telegram.WebApp.initData
const res = await fetch('/api/catalog', {
method: 'GET',
headers: { 'X-Telegram-Init-Data': initData },
})
if (!res.ok) throw new Error('catalog ' + res.status)
return res.json() // [{ id, title, price, currency, photoUrl }]
}

export async function checkout(items) {
// items: [{ id, qty }] — без client-side total как источника правды
const res = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telegram-Init-Data': window.Telegram.WebApp.initData,
},
body: JSON.stringify({ items }),
})
if (!res.ok) throw new Error('checkout ' + res.status)
return res.json() // { invoiceUrl } или { ok: true } если счёт ушёл в чат
}
\n\n

Хранилище корзини (Pinia-скетч)

\n
// stores/cart.js
import { defineStore } from 'pinia'

const STORAGE_KEY = 'tma_cart_v1'

export const useCartStore = defineStore('cart', {
state: () => ({
lines: [], // { id, title, price, qty }
}),
getters: {
total() {
return this.lines.reduce((s, l) => s + l.price * l.qty, 0)
},
count() {
return this.lines.reduce((s, l) => s + l.qty, 0)
},
},
actions: {
load() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) this.lines = JSON.parse(raw)
} catch (_) {
this.lines = []
}
},
persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.lines))
},
add(product) {
const row = this.lines.find((l) => l.id === product.id)
if (row) row.qty += 1
else this.lines.push({
id: product.id,
title: product.title,
price: product.price,
qty: 1,
})
this.persist()
},
setQty(id, qty) {
const row = this.lines.find((l) => l.id === id)
if (!row) return
if (qty <= 0) this.lines = this.lines.filter((l) => l.id !== id)
else row.qty = qty
this.persist()
},
clear() {
this.lines = []
this.persist()
},
},
})
\n

У main.js після створення додатку: useCartStore().load(). localStorage зручний для UX, але сума замовлення на сервері завжди перекасовується з БД за id товарів.

\n\n

Композабль оформлення

\n
// composables/useCheckout.js
import { ref } from 'vue'
import { useCartStore } from '../stores/cart'
import { checkout } from '../api/client'

export function useCheckout() {
const loading = ref(false)
const error = ref('')
const cart = useCartStore()

async function pay() {
loading.value = true
error.value = ''
try {
const items = cart.lines.map((l) => ({ id: l.id, qty: l.qty }))
if (items.length === 0) throw new Error('Корзина пуста')

const result = await checkout(items)
const tg = window.Telegram.WebApp

if (result.invoiceUrl) {
tg.openInvoice(result.invoiceUrl, (status) => {
// status: 'paid' | 'cancelled' | 'failed' | 'pending'
if (status === 'paid') cart.clear()
})
} else {
// счёт отправили сообщением в чат — закрываем или показываем подсказку
tg.showAlert('Счёт отправлен в чат с ботом')
cart.clear()
}
} catch (e) {
error.value = e.message || 'Ошибка оплаты'
} finally {
loading.value = false
}
}

return { loading, error, pay }
}
\n

MainButton можна повшити на той самий pay:

\n
const tg = window.Telegram.WebApp
tg.MainButton.setText('Оплатить')
tg.MainButton.show()
tg.MainButton.onClick(() => pay())
\n\n

PHP: checkout + sendInvoice

\n

Після HMAC-перевірки initData (згляньте окремо WebApp) сервер не довіряє ціні від клієнта:

\n
<?php

function tgApi(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
$ch = curl_init('https://api.telegram.org/bot' . $token . '/' . $method);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
curl_close($ch);
$decoded = json_decode($raw, true);
if (empty($decoded['ok'])) {
throw new RuntimeException($decoded['description'] ?? 'Telegram error');
}
return $decoded['result'];
}

/**
* $items — [{id, qty}] из Mini App
* цены берём только из своей БД
*/
function createInvoiceForCart(int $chatId, array $items): array
{
$prices = [];
$total = 0;
foreach ($items as $line) {
$product = findProduct((int) $line['id']); // ваша БД
if (!$product || !$product['active']) {
throw new RuntimeException('Product unavailable');
}
$qty = max(1, (int) $line['qty']);
$amount = (int) $product['price'] * $qty; // минимальные единицы (копейки)
$total += $amount;
$prices[] = [
'label' => $product['title'] . ' ×' . $qty,
'amount' => $amount,
];
}
if ($prices === []) {
throw new RuntimeException('Empty cart');
}

$payload = bin2hex(random_bytes(8)); // ≤ 128 байт, корреляция заказа
saveOrder([
'payload' => $payload,
'chat_id' => $chatId,
'total' => $total,
'status' => 'pending',
]);

// Обычный провайдер (не Stars): нужен provider_token от BotFather
$result = tgApi('sendInvoice', [
'chat_id' => $chatId,
'title' => 'Заказ в магазине',
'description' => 'Оплата заказа в Mini App',
'payload' => $payload,
'provider_token' => getenv('TELEGRAM_PROVIDER_TOKEN'),
'currency' => 'RUB',
'prices' => json_encode($prices, JSON_UNESCAPED_UNICODE),
]);

// Для openInvoice из Mini App удобнее createInvoiceLink
$link = tgApi('createInvoiceLink', [
'title' => 'Заказ в магазине',
'description' => 'Оплата заказа в Mini App',
'payload' => $payload,
'provider_token' => getenv('TELEGRAM_PROVIDER_TOKEN'),
'currency' => 'RUB',
'prices' => json_encode($prices, JSON_UNESCAPED_UNICODE),
]);

return [
'message' => $result,
'invoiceUrl' => is_string($link) ? $link : ($link['url'] ?? $link),
];
}
\n
\n

Для оплати Stars (currency=XTR) поле provider_token не передають. Для рублівного/карткового провайдера токен є обов'язковим. Суми в prices[].amount — цілі мінімальні одиниці валюти.

\n
\n\n

Після оплати з боку бота

\n
    \n
  • pre_checkout_query — швидко відповісти answerPreCheckoutQuery (за порядком 10 секунд). Перевірте payload та суму за замовленням у БД.
  • \n
  • successful_payment — помітити замовлення як оплачене за допомогою invoice_payload, зберегти telegram_payment_charge_id.
  • \n
  • Ідемпотентність: одна й та сама оновлення не повинна двічі «закривати» замовлення.
  • \n
\n
<?php

function handlePreCheckout(array $query): void
{
$order = findOrderByPayload($query['invoice_payload'] ?? '');
$ok = $order
&& $order['status'] === 'pending'
&& (int) $order['total'] === (int) $query['total_amount'];

tgApi('answerPreCheckoutQuery', [
'pre_checkout_query_id' => $query['id'],
'ok' => $ok,
'error_message' => $ok ? null : 'Заказ недоступен',
]);
}
\n\n

Практичні зауваження

\n
    \n
  • Не приймайте итогову суму з фронту як істинну — лише id і qty.
  • \n
  • payload інвойсу ≤ 128 байт; туди кладуть непрозоровий id замовлення.
  • \n
  • localStorage корзини може зникнути; критичні черновики дублюйте на сервері після логину за initData.
  • \n
  • openInvoice доступний у актуальних клієнтах; резервний шлях — sendInvoice в особисту чат.
  • \n
\n

Якщо потрібно зібрати каталог + оплату під конкретний прайс і провайдера, схема можна розібрати на botservice.biz.

" }

Нові статті — у Telegram

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