360 lines
16 KiB
JavaScript
360 lines
16 KiB
JavaScript
// 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_<phone>_<ts>".
|
||
|
||
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());
|
||
}
|