PageSourceSearch

https://osm2cdr.com/js/account_billing.js?v=20260924wallet

js osm2cdr.com collected 2026-09-25 20:41:01 UTC 15,730 bytes, 346 lines download raw bytes

1/**
2 * account_billing.js — раздел «Оплата» кабинета: счёт карт, покупка, движения.
3 *
4 * Раздел ПОКАЗЫВАЕТ СЕБЯ САМ, и это не украшение, а правило. Пока продажа
5 * закрыта, `/api/billing/catalog` отвечает ровно как несуществующий путь —
6 * значит вкладки быть не должно вовсе: пустая витрина без товара обещала бы
7 * покупку, которой нет. Поэтому вкладка в разметке лежит с `hidden`, и снимает
8 * его только успешный ответ витрины.
9 *
10 * Правило честности про остаток: `balance: null` — это «счётчик не ответил», а
11 * НЕ «карт нет». Ноль и прочерк — разные ответы, и путать их нельзя: по этому
12 * числу человек судит о своих деньгах.
13 *
14 * Подключать ПОСЛЕ account.js — берёт его VMSAccount.request (тот один раз
15 * продлевает сессию на 401 и повторяет запрос).
16 */
17
18(function () {
19  'use strict';
20
21  var T = window.VMSAccountText;
22
23  //: Сколько раз перепроверить счёт после возврата с оплаты. Карты кладёт
24  //: вебхук банка, и он может прийти на секунду-другую позже покупателя.
25  var TOPUP_POLL_TRIES = 6;
26  var TOPUP_POLL_MS = 2000;
27
28  function el(id) { return document.getElementById(id); }
29
30  /**
31   * Русское склонение слова «карта» по числу: пять строк вместо библиотеки, зато
32   * «1 карта», «2 карты», «5 карт». Английскому хватает «map/maps».
33   *
34   * @param {number} n Количество.
35   * @returns {string} Число со словом.
36   */
37  function cards(n) {
38    var v = Math.abs(Number(n) || 0);
39    var lang = 'ru';
40    try {
41      if (window.I18N && typeof I18N.getLang === 'function') lang = I18N.getLang();
42    } catch (e) { /* словарь не поднялся — считаем русским, как и остальной запас */ }
43    if (lang !== 'ru') return v + (v === 1 ? ' map' : ' maps');
44
45    var tail100 = v % 100;
46    var tail10 = v % 10;
47    var word = 'карт';
48    if (tail100 < 11 || tail100 > 14) {
49      if (tail10 === 1) word = 'карта';
50      else if (tail10 >= 2 && tail10 <= 4) word = 'карты';
51    }
52    return v + ' ' + word;
53  }
54
55  /** Сумма с разрядами и знаком рубля — так же, как экран гейта и тарифы. */
56  function price(rub) {
57    var n = Number(rub) || 0;
58    var grouped = String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
59    return T.tf('acc_bill_price', { price: grouped });
60  }
61
62  function say(id, key, bad) {
63    var node = el(id);
64    if (!node) return;
65    node.hidden = !key;
66    node.className = 'acc-msg' + (bad ? ' acc-msg-error' : '');
67    node.textContent = key ? T.t(key) : '';
68  }
69
70  // ---------------------------------------------------------------------------
71  // Счёт
72  // ---------------------------------------------------------------------------
73
74  /**
75   * Показать остаток карт.
76   *
77   * @param {Object|null} body Тело `/api/auth/me/credits` либо null.
78   */
79  function renderBalance(body) {
80    var node = el('accBillBalance');
81    if (!node) return;
82    // Прочерк — это ответ «не знаем». Ноль пишем цифрой: у человека
83    // действительно может быть пустой счёт, и молчать об этом нечестно.
84    var known = body && typeof body.balance === 'number';
85    node.textContent = known ? cards(body.balance) : '—';
86  }
87
88  function loadBalance() {
89    var A = window.VMSAccount;
90    if (!A) return Promise.resolve(null);
91    return A.request('/api/auth/me/credits').then(function (res) {
92      var body = (res.ok && res.body) ? res.body : null;
93      renderBalance(body);
94      return body;
95    });
96  }
97
98  // ---------------------------------------------------------------------------
99  // Витрина
100  // ---------------------------------------------------------------------------
101
102  /**
103   * Одна позиция витрины.
104   *
105   * @param {Object} item {sku, cards, price_rub, base_price_rub?}
106   * @returns {HTMLElement}
107   */
108  function shopItem(item) {
109    var li = document.createElement('li');
110    li.className = 'acc-bill-item';
111
112    var head = document.createElement('div');
113    head.className = 'acc-bill-item-head';
114
115    var qty = document.createElement('b');
116    qty.textContent = cards(item.cards);
117    head.appendChild(qty);
118
119    var sum = document.createElement('span');
120    sum.className = 'acc-bill-item-price';
121    sum.textContent = price(item.price_rub);
122    head.appendChild(sum);
123
124    // Зачёркнутая цена появляется ТОЛЬКО когда сервер прислал базовую: скидку
125    // на клиенте не выдумываем — её либо назвал прайс, либо её нет.
126    if (item.base_price_rub) {
127      var was = document.createElement('s');
128      was.className = 'acc-bill-item-was';
129      was.textContent = price(item.base_price_rub);
130      head.appendChild(was);
131    }
132    li.appendChild(head);
133
134    var btn = document.createElement('button');
135    btn.type = 'button';
136    btn.className = 'acc-btn acc-btn-primary acc-bill-buy';
137    btn.textContent = T.t('acc_bill_buy');
138    btn.addEventListener('click', function () { buy(item.sku, btn); });
139    li.appendChild(btn);
140
141    // Второй способ оплаты той же позиции — деньгами кошелька (#1266). Кнопку
142    // делает САМ кошелёк: он знает остаток и перерисует счёт. Нет его — нет её.
143    if (window.VMSAccountWallet && VMSAccountWallet.packButton) {
144      li.appendChild(VMSAccountWallet.packButton(item));
145    }
146
147    return li;
148  }
149
150  function renderShop(payload) {
151    var list = el('accBillShop');
152    var card = el('accBillShopCard');
153    if (!list) return;
154    list.textContent = '';
155    var items = (payload && payload.items) || [];
156    items.forEach(function (item) { list.appendChild(shopItem(item)); });
157    // Витрина без единого товара не показывается: карточка «купите» с пустым
158    // списком выглядит поломкой, а не отсутствием предложения.
159    if (card) card.hidden = items.length === 0;
160
161    // Порядок списания (accBillBeta) пишет ОДИН модуль — account_cards.js:
162    // фраза выбирается по состоянию гейта взимания, и второй писатель
163    // однажды показал бы во вкладке не то, что на «Главной» (T-260924-36b8).
164  }
165
166  /**
167   * Купить пакет: заказ на сервере, затем уход на платёжную страницу банка.
168   *
169   * @param {string} sku Идентификатор товара.
170   * @param {HTMLElement} btn Нажатая кнопка — её гасим на время запроса.
171   */
172  function buy(sku, btn) {
173    var A = window.VMSAccount;
174    if (!A) return;
175    if (btn) btn.disabled = true;
176    say('accBillStatus', 'acc_bill_wait');
177
178    A.request('/api/billing/topup', {
179      method: 'POST',
180      body: JSON.stringify({ sku: sku })
181    }).then(function (res) {
182      if (res.status === 401) return;          // request уже увёл на вход
183      if (res.ok && res.body && res.body.payment_url) {
184        // Уходим на страницу банка. Кнопку НЕ включаем обратно: страница
185        // сейчас сменится, а живая кнопка успела бы завести второй заказ.
186        window.location.href = res.body.payment_url;
187        return;
188      }
189      if (btn) btn.disabled = false;
190      say('accBillStatus', 'acc_bill_fail', true);
191    });
192  }
193
194  // ---------------------------------------------------------------------------
195  // Движения
196  // ---------------------------------------------------------------------------
197
198  /**
199   * Перечитать движения счёта. Рисует их account_statement.js: журнал карт и
200   * ВЫПИСКА кошелька (#1266) — одна тема и один список на экране, а держать
201   * двух рисователей одного узла значит рано или поздно дать им затереть
202   * друг друга.
203   *
204   * Кошелёк жив — показываем выписку: она шире, деньги и карты вместе.
205   */
206  function refreshHistory() {
207    var S = window.VMSAccountStatement;
208    if (!S) return;
209    var W = window.VMSAccountWallet;
210    if (W && W.alive()) S.load(); else S.loadHistory();
211  }
212
213  // ---------------------------------------------------------------------------
214  // Возврат с оплаты
215  // ---------------------------------------------------------------------------
216
217  /** Параметр адресной строки без разбора всего URL. */
218  function param(name) {
219    try {
220      return new URLSearchParams(window.location.search).get(name);
221    } catch (e) {
222      return null;
223    }
224  }
225
226  /**
227   * Дождаться, пока карты доедут до счёта.
228   *
229   * Карты кладёт уведомление банка, а покупатель возвращается своим ходом и
230   * может обогнать его на секунду-другую. Поэтому счёт перечитывается
231   * несколько раз, и только исчерпав попытки, страница честно говорит, что
232   * оплата принята, а карты вот-вот появятся.
233   *
234   * @param {number} before Остаток до оплаты (null — был неизвестен).
235   * @param {number} tries Сколько попыток осталось.
236   */
237  function awaitTopup(before, tries) {
238    loadBalance().then(function (body) {
239      var now = (body && typeof body.balance === 'number') ? body.balance : null;
240      if (now !== null && (before === null || now > before)) {
241        say('accBillStatus', 'acc_bill_paid');
242        refreshHistory();
243        return;
244      }
245      if (tries <= 0) {
246        say('accBillStatus', 'acc_bill_slow');
247        return;
248      }
249      window.setTimeout(function () { awaitTopup(before, tries - 1); }, TOPUP_POLL_MS);
250    });
251  }
252
253  //: Слово кошелька о возврате и отложенный разбор: кто ответит первым —
254  //: витрина со своим счётом или кошелёк со своим — заранее не известно (#1266).
255  var walletWord = null;
256  var pendingReturn = null;
257
258  /** Придержать разбор возврата до слова кошелька. */
259  function deferReturn(before) {
260    if (walletWord === null) pendingReturn = function () { handleReturn(before); };
261    else if (!walletWord) handleReturn(before);
262  }
263
264  /**
265   * Слово кошелька: возврат его — витрина молчит.
266   * @param {boolean} taken Кошелёк жив и разбирает возврат сам.
267   */
268  function yieldReturn(taken) {
269    walletWord = !!taken;
270    var deferred = pendingReturn;
271    pendingReturn = null;
272    if (deferred && !walletWord) deferred();
273  }
274
275  /** Разобрать возврат из банка: успех ждём, отказ называем сразу. */
276  function handleReturn(before) {
277    if (param('topup_failed')) {
278      say('accBillStatus', 'acc_bill_fail', true);
279      return;
280    }
281    if (!param('topup')) return;
282    say('accBillStatus', 'acc_bill_wait');
283    awaitTopup(before, TOPUP_POLL_TRIES);
284  }
285
286  // ---------------------------------------------------------------------------
287  // Запуск
288  // ---------------------------------------------------------------------------
289
290  /** Открыть раздел и убрать `hidden` со вкладки. */
291  function reveal() {
292    var tab = el('accTabBilling');
293    if (tab) tab.hidden = false;
294    // Возврат из банка ведёт на /account?topup=…, без якоря раздела — открываем
295    // раздел сами, иначе человек вернулся бы на главный экран кабинета.
296    if ((param('topup') || param('topup_failed')) && window.VMSAccountShell) {
297      window.VMSAccountShell.show('billing', true);
298    }
299  }
300
301  function init() {
302    var A = window.VMSAccount;
303    if (!A) return;
304    A.request('/api/billing/catalog').then(function (res) {
305      // Тарифы «Главной» кормит ТОТ ЖЕ ответ: второго запроса за теми же
306      // ценами нет, а закрытая витрина доезжает туда null'ом (T-260921-b791).
307      var C = window.VMSAccountCards;
308      if (C) C.catalog((res.ok && res.body) ? res.body : null);
309      // 404 = продажа закрыта. Молча остаёмся невидимыми: это не ошибка
310      // страницы, а сегодняшнее состояние продукта.
311      if (!res.ok || !res.body) return;
312      reveal();
313      renderShop(res.body);
314      loadBalance().then(function (body) {
315        var before = (body && typeof body.balance === 'number') ? body.balance : null;
316        // Возврат может оказаться пополнением ДЕНЕГ: счёт карт по нему не
317        // сдвинется никогда, и опрос досчитал бы до «оплата принята, карты
318        // вот-вот появятся» — вранья. При живом кошельке возврат разбирает
319        // ОН (#1266), обоими счетами сразу.
320        if (window.VMSAccountWalletReturn
321            && (param('topup') || param('topup_failed'))) {
322          deferReturn(before);
323          return;
324        }
325        handleReturn(before);
326      });
327      // Движения грузит КОШЕЛЁК, когда он на странице есть: он один знает,
328      // ответил ли ему `/api/billing/wallet`, а значит — выписку показывать
329      // или прежний журнал карт. Нет кошелька — решаем сами.
330      if (!window.VMSAccountWallet) refreshHistory();
331    });
332  }
333
334  // Наружу — то, что нужно соседям: перечитать счёт карт, склонить «карт»,
335  // прочитать параметр возврата (вторых копий не заводим) и принять слово
336  // кошелька о том, чей это возврат.
337  window.VMSAccountBilling = {
338    reload: loadBalance, cards: cards, price: price, param: param, yieldReturn: yieldReturn
339  };
340
341  if (document.readyState === 'loading') {
342    document.addEventListener('DOMContentLoaded', init);
343  } else {
344    init();
345  }
346})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.