Типований магазин у Telegram Mini App: каталог → корзина → счёт. Фронтенд на Vue 3 тримає UI та корзину, бекенд на PHP перевіряє initData, перекасовує суму з нуля і викликає sendInvoice. Нижче — робочий контур без зайвого абстрактного шуму: Composable/store, оформлення замовлення і PHP-скетч інвойсу.
Цілісний потік
\n- \n
- Користувач відкриває Mini App (
t.me/Bot/Appабо кнопка Menu). \n - Vue викликає
Telegram.WebApp.ready(), запитує каталог із заголовкомX-Telegram-Init-Data. \n - Бекенд валідує HMAC, відправляє товари (id, title, ціна в копійках/мінімальних одиницях). \n
- Клієнт додає позиції до store (Pinia або простий reactive + localStorage). \n
- «Оплатити» → POST
/api/checkoutз id позицій і кількістю (не використовуючи загальну суму від клієнта як істинну). \n - Сервер пересчитає суму, створити замовлення, викликати
sendInvoice, повернути посилання або одразу відкрити інвойс через бота. \n - Клієнт:
Telegram.WebApp.openInvoice(url)(або чекає повідомлення-счету в чаті). \n - Бот обробляє
pre_checkout_queryіsuccessful_payment. \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// 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 }
}\nMainButton можна повшити на той самий pay:
const tg = window.Telegram.WebApp
tg.MainButton.setText('Оплатить')
tg.MainButton.show()
tg.MainButton.onClick(() => pay())\n\nPHP: checkout + sendInvoice
\nПісля HMAC-перевірки initData (згляньте окремо WebApp) сервер не довіряє ціні від клієнта:
<?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\n\nДля оплати Stars (
\ncurrency=XTR) полеprovider_tokenне передають. Для рублівного/карткового провайдера токен є обов'язковим. Суми вprices[].amount— цілі мінімальні одиниці валюти.
Після оплати з боку бота
\n- \n
- pre_checkout_query — швидко відповісти
answerPreCheckoutQuery(за порядком 10 секунд). Перевірте payload та суму за замовленням у БД. \n - successful_payment — помітити замовлення як оплачене за допомогою
invoice_payload, зберегтиtelegram_payment_charge_id. \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
Якщо потрібно зібрати каталог + оплату під конкретний прайс і провайдера, схема можна розібрати на botservice.biz.
" }