// ozon/pay_by_card.js // // JS-порт Ozon::PayByCardScript. Логика 1:1 с C++ (см. // android/ozon/PayByCardScript.cpp). // // Контракт: // params.account — {id, materialId, appCode, deviceId, lastNumbers, cardNumber, currency} // params.cardNumber — номер карты получателя // params.amount — сумма перевода // Возвращает: // "" / null / undefined → success // строка → бизнес-ошибка // // OCR для bank_transaction_id здесь не используется (см. комментарий в // PayByCardScript.cpp:453). Externalid формируется как fallback "ozon__". function run(host) { var account = host.params.account; var cardNumber = host.params.cardNumber; var amount = host.params.amount; var device = host.dao.deviceGetById(account.deviceId); var app = host.dao.bankProfileGet(account.deviceId, account.appCode); if (!device || !device.id || !app || app.id < 0) { var msg = "Device or app not found: " + account.deviceId; host.log.error(msg); return msg; } var adbId = device.adbSerial; var counter = 0; host.dump.beginScope("ozon", "CREATE_TRANSACTION_CARD", adbId, { materialId: account.materialId, amount: amount, card: cardNumber, }); try { host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); // Закрываем рекламный bottom sheet var adNode = host.parser.tryToFindAdBanner(); if (!adNode.isEmpty) { host.log.debug("Ad banner detected, closing..."); host.adb.makeTap(adbId, adNode.x, adNode.y); host.timer.sleep(1000); host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); } // Если не на домашнем — перезапускаем if (!host.parser.isHomeScreen()) { if (!host.helpers.runAppAndGoToHomeScreen(adbId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { var err = "Cannot reach home screen: " + device.name + " (" + device.id + ")"; host.adb.tryToKillApplication(adbId, app.package); return err; } host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); } // Закрываем баннеры host.helpers.tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight); host.timer.sleep(500); host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); // Google Play "Доступно обновление" var gp = host.parser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); if (!gp.isEmpty) { host.adb.makeTap(adbId, gp.x, gp.y); host.timer.sleep(1500); host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); } // Баннер "Заказать карту" var orderCard = host.parser.findTextNode("Заказать карту"); if (!orderCard.isEmpty) { host.adb.makeTap(adbId, Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 20)); host.timer.sleep(1000); host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter)); } var result = makePayment(host, adbId, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight, account, cardNumber, amount, counter); host.dump.endScope(result || ""); return result; } catch (e) { host.dump.endScope(String(e)); throw e; } } // Возвращает m_error (пустая строка = success). function makePayment(host, deviceId, packageName, pinCode, bankProfilePhone, width, height, account, cardNumber, amount, counter) { function checkApp() { if (!host.helpers.isAppRunning(deviceId, packageName)) { return "Приложение было закрыто"; } return null; } // 1. "Перевести" на домашнем экране var transferBtn = host.parser.findButtonNode("Перевести", false); if (transferBtn.isEmpty) return "Кнопка 'Перевести' не найдена на домашнем экране"; host.adb.makeTap(deviceId, transferBtn.x, transferBtn.y); host.timer.sleep(2000); // 2. Ждём экран "Платежи и переводы" var cardBtn = {isEmpty: true}; var paymentsPageLoaded = false; var titleSeen = false; { var freezePrev = "", freezeStreak = 0; for (var i = 0; i < 10; ++i) { var e = checkApp(); if (e) return e; var xml = host.adb.getScreenDumpAsXml(deviceId, ++counter); host.parser.parseAndSaveXml(xml); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); if (host.helpers.isUsefulDump(xml)) { if (freezePrev && xml === freezePrev) { if (++freezeStreak >= 3) { host.log.warn("screen frozen waiting payments"); break; } } else freezeStreak = 0; freezePrev = xml; } var title = host.parser.findTextNode("Платежи и переводы"); if (!title.isEmpty) titleSeen = true; cardBtn = host.parser.findButtonNode("По номеру карты", false); if (!title.isEmpty && !cardBtn.isEmpty) { paymentsPageLoaded = true; break; } host.timer.sleep(1500); } } if (!paymentsPageLoaded) { return titleSeen ? "Кнопка 'По номеру карты' не найдена на экране платежей" : "Экран 'Платежи и переводы' не загрузился"; } // 3. Тапаем "По номеру карты" host.adb.makeTap(deviceId, cardBtn.x, cardBtn.y); host.timer.sleep(2000); // 4. Ждём "Перевод по номеру карты" var cardScreenLoaded = false; { var fp = "", fs = 0; for (var j = 0; j < 10; ++j) { var e2 = checkApp(); if (e2) return e2; var xml2 = host.adb.getScreenDumpAsXml(deviceId, ++counter); host.parser.parseAndSaveXml(xml2); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); if (host.helpers.isUsefulDump(xml2)) { if (fp && xml2 === fp) { if (++fs >= 3) { host.log.warn("screen frozen waiting card screen"); break; } } else fs = 0; fp = xml2; } if (!host.parser.findTextNode("Перевод по номеру карты").isEmpty) { cardScreenLoaded = true; break; } host.timer.sleep(1500); } } if (!cardScreenLoaded) return "Экран 'Перевод по номеру карты' не загрузился"; // 5. Поле "Номер карты получателя" + PAN fallback var cardInput = {isEmpty: true}; for (var k = 0; k < 10; ++k) { var e3 = checkApp(); if (e3) return e3; cardInput = host.parser.findEditTextByHint("Номер карты получателя"); if (cardInput.isEmpty) cardInput = host.parser.findPanCardNumberInput(); if (!cardInput.isEmpty) break; host.timer.sleep(1500); host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter)); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); } if (cardInput.isEmpty) return "Поле 'Номер карты получателя' не найдено"; host.adb.makeTap(deviceId, cardInput.x, cardInput.y); host.timer.sleep(500); host.adb.inputText(deviceId, cardNumber); host.timer.sleep(2000); host.adb.hideKeyboard(deviceId); host.timer.sleep(800); // 6. Поле суммы (с fallback по input___ rid / bottom-most EditText) var sumInput = {isEmpty: true}; for (var s = 0; s < 10; ++s) { var e4 = checkApp(); if (e4) return e4; host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter)); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); sumInput = host.parser.findEditTextByHint("Сумма от"); if (!sumInput.isEmpty) break; var candidates = []; var allNodes = host.parser.findAllNodes(); for (var ni = 0; ni < allNodes.length; ++ni) { var n = allNodes[ni]; if (n.className !== "android.widget.EditText") continue; if (n.height <= 0 || n.height > height * 4 / 5) continue; candidates.push(n); } // Strategy 1: rid="input___" for (var c1 = 0; c1 < candidates.length; ++c1) { if (String(candidates[c1].resourceId || "").indexOf("input___") === 0) { sumInput = candidates[c1]; break; } } // Strategy 2: bottom-most if (sumInput.isEmpty && candidates.length >= 2) { candidates.sort(function(a, b) { return a.y1 - b.y1; }); sumInput = candidates[candidates.length - 1]; } if (!sumInput.isEmpty) break; host.timer.sleep(1500); } if (sumInput.isEmpty) return "Поле ввода суммы не найдено"; host.adb.makeTap(deviceId, sumInput.x, sumInput.y); host.timer.sleep(500); host.adb.inputText(deviceId, String(Math.floor(amount))); host.timer.sleep(1000); host.adb.hideKeyboard(deviceId); host.timer.sleep(800); // 7. Ждём расчёта комиссии var fee = 0; var feeCalculated = false; for (var f = 0; f < 15; ++f) { var e5 = checkApp(); if (e5) return e5; host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter)); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); var foundKomissiya = false; var nodesf = host.parser.findAllNodes(); for (var nf = 0; nf < nodesf.length; ++nf) { var node = nodesf[nf]; if (node.className === "android.widget.TextView" && (node.text || "").trim() === "Комиссия") { foundKomissiya = true; continue; } if (foundKomissiya && node.className === "android.widget.TextView" && node.text) { var feeText = (node.text || "").trim(); if (feeText.indexOf("Рассчитаем") === 0) { foundKomissiya = false; break; } var digits = feeText.replace(/[^0-9,\.]/g, '').replace(',', '.'); fee = parseFloat(digits) || 0; feeCalculated = true; host.log.debug("Fee parsed: " + fee + " from text: " + feeText); break; } } if (feeCalculated) break; host.timer.sleep(1500); } if (!feeCalculated) host.log.warn("Fee not calculated, proceeding with fee=0"); // 8. Тапаем "Перевести" (retry если bounds 0) var transferBtn2 = {isEmpty: true}; for (var a = 0; a < 5; ++a) { host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter)); transferBtn2 = host.parser.findButtonNode("Перевести", true); if (!transferBtn2.isEmpty && transferBtn2.height > 0) break; host.adb.hideKeyboard(deviceId); host.timer.sleep(1000); } if (transferBtn2.isEmpty || transferBtn2.height === 0) { return "Кнопка 'Перевести' не найдена или перекрыта клавиатурой"; } host.adb.makeTap(deviceId, transferBtn2.x, transferBtn2.y); host.timer.sleep(3000); // 8.5. Возможный PIN-экран после confirm host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter)); var pinNodes = host.parser.tryToFindPinCode(pinCode, "Введите ПИН"); if (pinNodes && pinNodes.length > 0) { host.helpers.inputPinCode(deviceId, pinNodes); host.timer.sleep(3000); } // 9. Ждём результат var freezePrev2 = "", freezeStreak2 = 0; for (var w = 0; w < 15; ++w) { var e6 = checkApp(); if (e6) return e6; var xml3 = host.adb.getScreenDumpAsXml(deviceId, ++counter); host.parser.parseAndSaveXml(xml3); host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height); if (host.helpers.isUsefulDump(xml3)) { if (freezePrev2 && xml3 === freezePrev2) { if (++freezeStreak2 >= 3) { host.log.warn("screen frozen waiting result"); break; } } else freezeStreak2 = 0; freezePrev2 = xml3; } // PIN-экран мог появиться позже var pinLater = host.parser.tryToFindPinCode(pinCode, "Введите ПИН"); if (pinLater && pinLater.length > 0) { host.helpers.inputPinCode(deviceId, pinLater); host.timer.sleep(3000); continue; } if (!host.parser.findTextNode("Успешно").isEmpty) { // Сохраняем транзакцию var nowMs = Date.now(); var tx = { accountId: account.id, amount: -amount, fee: fee, phone: cardNumber, bankName: "ozon", type: "card", status: "complete", timestamp: new Date().toISOString(), completeTime: new Date().toISOString(), bankTime: formatBankTime(new Date()), }; var txId = host.dao.transactionInsert(tx); host.log.debug("Saved tx to DB, id: " + txId + " fee: " + fee); // Receipt + external_id if (txId !== -1) { host.helpers.saveTransactionReceipt(deviceId, txId); var externalId = "ozon_" + bankProfilePhone + "_" + Math.floor(nowMs / 1000); host.dao.transactionUpdateBankTrExternalId(txId, externalId); } host.adb.goBack(deviceId); host.timer.sleep(1000); host.adb.goBack(deviceId); host.timer.sleep(1000); return ""; // success } if (!host.parser.findTextNode("Не удалось").isEmpty) { host.adb.goBack(deviceId); host.timer.sleep(1000); host.adb.goBack(deviceId); host.timer.sleep(1000); return "Перевод не удался (экран 'Не удалось')"; } host.timer.sleep(2000); } return "Не дождались результата перевода"; } // "dd.MM.yyyy, HH:mm" — MSK-таймзона как в C++ // (.toTimeZone(QTimeZone("Europe/Moscow"))). function formatBankTime(d) { // Берём смещение MSK (+03:00) от UTC и форматируем вручную, чтобы не зависеть // от локали JS-движка. var mskMs = d.getTime() + (3 * 60 - d.getTimezoneOffset() * -1) * 60 * 1000 + d.getTimezoneOffset() * 60 * 1000; // Проще: добавляем 3 часа к UTC. var utc = d.getTime(); var msk = new Date(utc + 3 * 3600 * 1000); function pad(n) { return n < 10 ? '0' + n : '' + n; } return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear() + ", " + pad(msk.getUTCHours()) + ":" + pad(msk.getUTCMinutes()); }