// ─── Kaspi Pay — Frontend App ─── const API = ''; // ─── State ─── let currentOpId = null; let invoicePollingTimer = null; let historyOpId = null; let qrPollingTimer = null; let qrCountdownTimer = null; let qrOperationId = null; // ─── Helpers ─── const $ = (id) => document.getElementById(id); const digitsOnly = (str) => str.replace(/\D/g, ''); const getSession = () => { try { return JSON.parse(localStorage.getItem('kaspi_session') || '{}'); } catch { return {}; } }; const sessionHeaders = () => { const s = getSession(); const h = {}; if (s.tokenSN) h['X-Token-SN'] = s.tokenSN; if (s.profileId) h['X-Profile-ID'] = String(s.profileId); if (s.vtokenSecret) h['X-Vtoken-Secret'] = s.vtokenSecret; return h; }; const apiFetch = async (path, opts = {}) => { opts.headers = { ...sessionHeaders(), ...(opts.headers || {}) }; const resp = await fetch(API + path, opts); return resp.json(); }; const apiPost = (path, body) => apiFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, ...(body !== undefined && { body: JSON.stringify(body) }), }); // ─── Session Persistence (localStorage) ─── const SESSION_KEY = 'kaspi_session'; const saveSession = (data) => { let prev = {}; try { prev = JSON.parse(localStorage.getItem(SESSION_KEY) || '{}'); } catch {} const merged = { ...prev, ...data }; if (data.phone) merged.phoneNumber = data.phone; localStorage.setItem(SESSION_KEY, JSON.stringify(merged)); }; const clearSession = () => localStorage.removeItem(SESSION_KEY); const checkSession = async () => { try { const resp = await apiFetch('/api/session/check'); if (resp.active === true) return { active: true }; return { active: false, error: resp.error || 'Сессия неактивна' }; } catch { return { active: false, error: 'Ошибка проверки сессии' }; } }; const tryRestoreSession = async () => { const session = getSession(); if (session.tokenSN && session.vtokenSecret) { showMainScreen(session); // Verify session is still active on the server const result = await checkSession(); if (!result.active) { clearSession(); $('mainScreen').classList.add('hidden'); $('authScreen').classList.remove('hidden'); setAuthStep(1); showAuthMsg(result.error || 'Сессия истекла. Войдите заново.', 'err'); return false; } return true; } clearSession(); return false; }; const updateRefreshAuthBtn = () => { const session = getSession(); const btn = $('btnRefreshAuth'); if (btn) btn.classList.toggle('hidden', !session.tokenSN || !session.vtokenSecret); }; const formatPhone = (digits) => { // Format up to 10 digits as "XXX XXX XX XX" const d = digits.slice(0, 10); if (d.length <= 3) return d; if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`; if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`; return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8)}`; }; const attachPhoneFormatter = (el) => { el.addEventListener('input', () => { const digits = digitsOnly(el.value); const formatted = formatPhone(digits); if (el.value !== formatted) el.value = formatted; }); }; window.addEventListener('DOMContentLoaded', () => { updateRefreshAuthBtn(); tryRestoreSession(); attachPhoneFormatter($('phoneInput')); attachPhoneFormatter($('clientPhone')); }); // ─── Auth UI Helpers ─── const setAuthStep = (n) => { for (let i = 1; i <= 3; i++) { $(`authStep${i}`).classList.toggle('hidden', i !== n); $(`dot${i}`).className = `step-dot${i < n ? ' done' : i === n ? ' active' : ''}`; } }; const showAuthMsg = (msg, type) => { const el = $('authMsg'); if (!msg) { el.classList.add('hidden'); return; } el.className = `status-bar status-${type}`; el.textContent = msg; el.classList.remove('hidden'); }; const resetAuth = () => { setAuthStep(1); $('otpInput').value = ''; showAuthMsg('', ''); }; // ─── Auth Flow ─── let authProcessId = null; const sendPhone = async () => { const phone = digitsOnly($('phoneInput').value); if (phone.length < 10) return showAuthMsg('Введите 10 цифр номера', 'err'); const btn = $('btnSendPhone'); btn.disabled = true; btn.innerHTML = 'Отправка...'; showAuthMsg('', ''); try { const init = await apiPost('/api/auth/init'); if (!init.success) { showAuthMsg(`Ошибка инициализации: ${JSON.stringify(init.body)}`, 'err'); return; } authProcessId = init.processId; const resp = await apiPost('/api/auth/send-phone', { phoneNumber: phone, processId: authProcessId }); if (resp.success) { $('otpDesc').textContent = resp.desc || `SMS отправлен на +7${phone}`; setAuthStep(2); } else { showAuthMsg(`Ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err'); } } catch (e) { showAuthMsg(`Ошибка сети: ${e.message}`, 'err'); } finally { btn.disabled = false; btn.textContent = 'Получить SMS код'; } }; const verifyOtp = async () => { const otp = digitsOnly($('otpInput').value); if (!otp) return showAuthMsg('Введите код', 'err'); const btn = $('btnVerifyOtp'); btn.disabled = true; btn.innerHTML = 'Проверка...'; showAuthMsg('', ''); try { const resp = await apiPost('/api/auth/verify-otp', { otp, processId: authProcessId }); if (resp.success && resp.step === 'finished') { saveSession(resp); authProcessId = null; showMainScreen(resp); } else { showAuthMsg(`Неверный код или ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err'); } } catch (e) { showAuthMsg(`Ошибка: ${e.message}`, 'err'); } finally { btn.disabled = false; btn.textContent = 'Подтвердить'; } }; // ─── Main Screen ─── const showMainScreen = (data) => { $('authScreen').classList.add('hidden'); $('mainScreen').classList.remove('hidden'); if (data) { $('userName').textContent = data.phone || '—'; $('userOrg').textContent = data.orgName || '—'; $('userAvatar').textContent = (data.orgName || 'K')[0].toUpperCase(); } }; const logout = async () => { const { tokenSN } = getSession(); clearSession(); await apiPost('/api/auth/logout', { tokenSN }); $('mainScreen').classList.add('hidden'); $('authScreen').classList.remove('hidden'); $('phoneInput').value = ''; $('otpInput').value = ''; setAuthStep(1); showAuthMsg('', ''); }; const switchTab = (tab) => { $('invoiceTab').classList.toggle('hidden', tab !== 'invoice'); $('qrTab').classList.toggle('hidden', tab !== 'qr'); $('historyTab').classList.toggle('hidden', tab !== 'history'); $('salesTab').classList.toggle('hidden', tab !== 'sales'); $('tabInvoice').classList.toggle('active', tab === 'invoice'); $('tabQr').classList.toggle('active', tab === 'qr'); $('tabHistory').classList.toggle('active', tab === 'history'); $('tabSales').classList.toggle('active', tab === 'sales'); if (tab === 'history') loadHistory(); if (tab === 'sales') loadSales(); }; // ─── Invoice ─── const statusBadge = (status) => { const map = { RemotePaymentCreated: ['Ожидает оплаты', 'pending'], RemotePaymentPaid: ['Оплачен', 'paid'], RemotePaymentCanceled: ['Отменён', 'canceled'], RemotePaymentExpired: ['Истёк', 'expired'], }; const [label, cls] = map[status] || [status, 'pending']; return `${label}`; }; const renderDetails = (data, containerId) => { if (!data) return; const el = $(containerId); const rows = (data.DynamicDetails || []) .sort((a, b) => a.Order - b.Order) .map( ({ Title, Data, IsBold }) => `
Загрузка...
'; try { const resp = await apiPost('/api/invoice/history'); const ops = resp.Data?.Operations || []; if (!ops.length) { list.innerHTML = 'Нет операций
'; return; } list.innerHTML = ops .map( (op) => `Ошибка: ${e.message}
`; } }; const showHistoryDetail = async (opId) => { historyOpId = opId; const panel = $('historyDetail'); const content = $('historyDetailContent'); content.innerHTML = 'Загрузка...
'; panel.classList.remove('hidden'); try { const resp = await apiFetch(`/api/invoice/details?operationId=${opId}`); renderDetails(resp.Data, 'historyDetailContent'); $('btnCancelFromHistory').classList.toggle('hidden', resp.Data?.Status !== 'RemotePaymentCreated'); } catch { content.innerHTML = 'Ошибка
'; } }; const cancelFromHistory = async () => { if (!historyOpId || !confirm('Отменить счёт?')) return; try { await apiPost('/api/invoice/cancel', { operationId: historyOpId }); showHistoryDetail(historyOpId); loadHistory(); } catch (e) { alert(`Ошибка: ${e.message}`); } }; // ─── Sales (operations history + details + refund) ─── let salesOpId = null; const loadSales = async () => { const list = $('salesList'); const stats = $('salesStats'); list.innerHTML = 'Загрузка...
'; stats.innerHTML = ''; try { const now = new Date(); const endDate = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + 'T23:59:59.000+0500'; const resp = await apiPost('/api/history/operations', { endDate }); const data = resp.Data || {}; // Stats const s = data.Statistic || {}; stats.innerHTML = `Нет операций
'; return; } let html = ''; for (const day of dailySets) { html += `Ошибка: ${e.message}
`; } }; const showSalesDetail = async (id, operationMethod) => { salesOpId = id; const panel = $('salesDetail'); const content = $('salesDetailContent'); const refundSection = $('refundSection'); const refundMsg = $('refundMsg'); content.innerHTML = 'Загрузка...
'; refundSection.classList.add('hidden'); refundMsg.classList.add('hidden'); panel.classList.remove('hidden'); try { const resp = await apiPost('/api/history/details', { id, operationMethod: operationMethod || 0 }); const d = resp.Data || {}; let rows = ''; const fields = [ ['Сумма', d.Amount ? `${d.Amount} ₸` : null], ['Клиент', d.ClientName || d.ClientShortName], ['Дата', d.OrderRegDate ? new Date(d.OrderRegDate).toLocaleString('ru') : null], ['Статус', d.StatusDescription], ['Доступно к возврату', d.AvailableReturnAmount != null ? `${d.AvailableReturnAmount} ₸` : null], ['Тип возврата', d.PossibleReturnType], ['Чек', d.ReceiptUrl ? `Открыть` : null], ]; for (const [label, value] of fields) { if (value != null) rows += `Нет данных
'; // Show refund if available const returnAmount = parseFloat(String(d.AvailableReturnAmount || '0').replace(/[^\d.]/g, '')); if (returnAmount > 0) { $('refundAmount').value = returnAmount; $('refundAmount').max = returnAmount; refundSection.classList.remove('hidden'); } } catch (e) { content.innerHTML = `Ошибка: ${e.message}
`; } }; const createRefund = async () => { const amount = $('refundAmount').value; if (!salesOpId || !amount) return alert('Укажите сумму возврата'); if (!confirm(`Вернуть ${amount} ₸?`)) return; const btn = $('btnRefund'); const msg = $('refundMsg'); btn.disabled = true; btn.innerHTML = 'Возврат...'; msg.classList.add('hidden'); try { const resp = await apiPost('/api/refund/create', { qrOperationId: salesOpId, returnAmount: Number(amount) }); if (resp.StatusCode === 0) { msg.className = 'status-bar status-ok'; msg.textContent = 'Возврат выполнен успешно'; } else { msg.className = 'status-bar status-err'; msg.textContent = resp.Description || resp.Message || 'Ошибка возврата'; } msg.classList.remove('hidden'); // Refresh detail showSalesDetail(salesOpId, 0); } catch (e) { msg.className = 'status-bar status-err'; msg.textContent = `Ошибка: ${e.message}`; msg.classList.remove('hidden'); } finally { btn.disabled = false; btn.textContent = 'Сделать возврат'; } }; // ─── Expose to HTML onclick handlers ─── Object.assign(window, { sendPhone, verifyOtp, resetAuth, logout, switchTab, createInvoice, refreshInvoice, cancelInvoice, createQr, resetQr, loadHistory, showHistoryDetail, cancelFromHistory, loadSales, showSalesDetail, createRefund, }); // ─── Init ─── (() => { setAuthStep(1); const session = getSession(); if (session.tokenSN && session.vtokenSecret) { showMainScreen(session); } })();