diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index 585e9ae..f783936 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -740,13 +740,15 @@ void GetLastTransactionsScript::searchAll( } } - // Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи) + // Проверка времени по HH:MM:SS (+10 минут, одностороннее окно, с учётом wraparound полуночи) if (cand.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); const QTime searchLocalTime = cand.toTimeZone(tjTz).time(); if (listTime.isValid() && searchLocalTime.isValid()) { - const int diffSecs = qAbs(listTime.secsTo(searchLocalTime)); - if (diffSecs > 600 && diffSecs < 85800) continue; // 85800 = 23h50m + int diffSecs = searchLocalTime.secsTo(listTime); // >0 = listTime (tx) позже цели + if (diffSecs < -43200) diffSecs += 86400; // wraparound через полночь вперёд + else if (diffSecs > 43200) diffSecs -= 86400; // wraparound назад + if (diffSecs < 0 || diffSecs > 600) continue; // tx раньше цели или позже неё на >10 мин } } @@ -915,7 +917,7 @@ void GetLastTransactionsScript::searchAll( << "date=" << detail.date << "time=" << detail.time << "amount=" << detail.amount << "receiver=" << detail.receiverAccount; - // Проверяем время ±10 минут + // Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно) if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { QDateTime txTime = QDateTime::fromString( detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); @@ -924,9 +926,9 @@ void GetLastTransactionsScript::searchAll( } if (txTime.isValid()) { txTime.setTimeZone(tjTz); - const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC())); - if (diffSecs > 600) { - qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping"; + const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели + if (diffSecs < 0 || diffSecs > 600) { + qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (out of +10min window), skipping"; AdbUtils::goBack(m_deviceId); QThread::msleep(1000); continue; @@ -956,9 +958,15 @@ void GetLastTransactionsScript::searchAll( if (txTime.isValid()) txTime.setTimeZone(tjTz); QJsonObject txJson; - txJson["bank_transaction_id"] = detail.operationId.isEmpty() - ? QString("dushanbe_") + detail.date + "_" + detail.time - : detail.operationId; + // Фолбэк-id (когда «Номер операции» не распарсился) строим из + // created_at задачи — он детерминирован и стабилен между запусками, + // что важно для дедупликации на сервере. Раньше брали распарсенные + // с экрана дату+время, которые могли «плавать» из-за OCR. + txJson["bank_transaction_id"] = !detail.operationId.isEmpty() + ? detail.operationId + : (searchTime.isValid() + ? QStringLiteral("dushanbe_") + searchTime.toUTC().toString(Qt::ISODate) + : QStringLiteral("dushanbe_") + detail.date + "_" + detail.time); // Списание хранится с отрицательным знаком, пополнение — положительным. // detail.amount всегда положителен (OCR), знак берём из списка Выписки. const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount); @@ -971,7 +979,11 @@ void GetLastTransactionsScript::searchAll( QJsonObject extraObj; extraObj["fee"] = static_cast(finalFee * 100); txJson["extra"] = extraObj; - txJson["created_at"] = txTime.isValid() + // created_at — время из задачи (поисковый target), real_created_at — + // фактическое время транзакции, спарсенное с экрана приложения. + txJson["created_at"] = searchTime.isValid() + ? searchTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); + txJson["real_created_at"] = txTime.isValid() ? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:" diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 97a14bb..a5c45de 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -618,10 +618,10 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI } } - // Проверяем время (±10 минут) + // Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно) if (txTime.isValid() && searchTime.isValid()) { - const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC())); - if (diffSecs > 600) { + const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели + if (diffSecs < 0 || diffSecs > 600) { qDebug() << "[FetchTransactions] Time mismatch:" << dtStr << "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm") << "diff=" << diffSecs << "sec"; @@ -644,6 +644,16 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI QString externalId = detail.bankTrExternalId; + // Фолбэк-id (когда реальный ID банка не распарсился) строим из + // created_at задачи — он детерминирован и стабилен между запусками, + // что важно для дедупликации на сервере. Раньше брали распарсенное + // с экрана время (dtStr), которое могло «плавать» из-за OCR. + const QString finalExternalId = externalId.isEmpty() + ? QStringLiteral("ozon_") + (searchTime.isValid() + ? searchTime.toUTC().toString(Qt::ISODate) + : QString(dtStr).remove(',').trimmed().replace(' ', '_')) + : externalId; + // Сохраняем if (accountId != -1) { TransactionInfo tx; @@ -651,9 +661,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI tx.amount = parsed.amount; tx.bankName = m_appCode; tx.bankTime = dtStr; - tx.bankTrExternalId = externalId.isEmpty() - ? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_') - : externalId; + tx.bankTrExternalId = finalExternalId; tx.status = TransactionStatus::Complete; tx.timestamp = DateUtils::getUtcNow(); if (txTime.isValid()) tx.completeTime = txTime; @@ -671,10 +679,6 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI // Отдаём результат в EventHandler через сигнал (как Dushanbe) { - const QString finalExternalId = externalId.isEmpty() - ? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_') - : externalId; - QJsonObject txJson; txJson["bank_transaction_id"] = finalExternalId; txJson["amount"] = static_cast(parsed.amount * 100); @@ -687,7 +691,11 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI QJsonObject extraObj; extraObj["fee"] = 0; txJson["extra"] = extraObj; - txJson["created_at"] = txTime.isValid() + // created_at — время из задачи (поисковый target), real_created_at — + // фактическое время транзакции, спарсенное с экрана приложения. + txJson["created_at"] = searchTime.isValid() + ? searchTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); + txJson["real_created_at"] = txTime.isValid() ? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); qDebug() << "[FetchTransactions] Emitting parsed tx:" diff --git a/config.ini b/config.ini index 3139a03..d425f46 100644 --- a/config.ini +++ b/config.ini @@ -21,6 +21,8 @@ pathDevice=/api/v1/device/ pathDeviceScreenshot=/api/v1/device/add_screenshot/ pathMaterial=/api/v1/profile/material pathProfilePing=/api/v1/profile/ping +pathGetTasks=/api/v1/task/get_tasks +pathTaskResult=/api/v1/task/task_result pathMonitoringLog=/monitoring/log pathMonitoringDump=/monitoring/dump pathMonitoringScreenshot=/monitoring/screenshot diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 16ee2a6..154eb88 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -35,7 +35,7 @@ #include "dushanbe/PayByPhoneScript.h" static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1, bool updateStatus = true); -static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {}, int accountId = -1); +static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {}, int accountId = -1, const QString &taskJson = {}); static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false); EventHandler::EventHandler(QObject *parent) : QObject(parent) {} @@ -79,7 +79,7 @@ void EventHandler::start() { if (e.sentToServer) continue; EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted"); EventDAO::markSentToServer(e.id); - sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, e.id, e.transactionId, e.accountId); + sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, e.id, e.transactionId, e.accountId, e.json); qDebug() << "[EventHandler] Closed stale event id:" << e.id; } } @@ -120,7 +120,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { const MaterialInfo account = MaterialDAO::findByMaterialId(materialId); if (account.id == -1) { qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId; - sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId); + sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId, -1, QString::fromUtf8(QJsonDocument(task).toJson(QJsonDocument::Compact))); continue; } @@ -128,7 +128,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (eventType == EventType::FetchProfile) { const QList cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile); for (const EventInfo &c : cancelled) { - sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, c.id, c.transactionId, c.accountId); + sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, c.id, c.transactionId, c.accountId, c.json); EventDAO::markSentToServer(c.id); } } @@ -169,7 +169,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (!onlineDevices.contains(account.deviceId)) { qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId; EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline"); - sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, eventId, incomingTransactionId, account.id); + sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, eventId, incomingTransactionId, account.id, event.json); } } @@ -216,7 +216,7 @@ static void sendTaskResult(const QString &type, const QString &materialId, const static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot, int eventId, - const QString &transactionId, int accountId) { + const QString &transactionId, int accountId, const QString &taskJson) { QJsonObject errorData; errorData["status"] = QStringLiteral("error"); errorData["event_type"] = type; @@ -261,6 +261,15 @@ static void sendTaskError(const QString &type, const QString &materialId, const if (!appVersion.isEmpty()) lines << QStringLiteral("AppVersion: %1").arg(appVersion.toHtmlEscaped()); lines << QStringLiteral("Error: %1").arg(description.toHtmlEscaped()); + // JSON задачи, пришедшей с сервера (pretty-print). НЕ экранируем — админка + // (_render_msg) экранирует один раз и выносит блок после «JSON: » в
.
+    if (!taskJson.isEmpty()) {
+        const QJsonDocument doc = QJsonDocument::fromJson(taskJson.toUtf8());
+        const QString pretty = doc.isNull()
+            ? taskJson
+            : QString::fromUtf8(doc.toJson(QJsonDocument::Indented));
+        lines << QStringLiteral("JSON: %1").arg(pretty);
+    }
     const QString message = lines.join(QStringLiteral("\n"));
     sendMonitoringLog("error", "task_error", message, screenshot, eventId);
 
@@ -268,6 +277,50 @@ static void sendTaskError(const QString &type, const QString &materialId, const
     // чтобы покрывать также non-event-driven задачи (login flow и т.п.).
 }
 
+// Успешное завершение задачи в arc-monitoring (мониторинговый дроплет, не apiBase).
+// Бизнес-результат уходит отдельно (sendTaskResult / per-tx streaming) — здесь
+// только человекочитаемый лог в админку с теми же метаданными, что у task_error
+// (Desktop/Profile/Material/Phone/AppVersion), чтобы корректно парсились и
+// фильтровались. `summary` — операционные детали (Amount/Found/…).
+static void sendTaskSuccess(const QString &type, const QString &materialId, const QString &bankName,
+                            const QString &summary, const QByteArray &screenshot, int eventId,
+                            int accountId) {
+    QString profileUuid, profilePhone, appVersion;
+    if (accountId > 0) {
+        const MaterialInfo acc = MaterialDAO::getAccountById(accountId);
+        if (acc.id != -1) {
+            const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode);
+            profileUuid  = app.bankProfileId;
+            profilePhone = app.phone;
+            appVersion   = app.appVersion;
+        }
+    }
+    const QString desktopId = SettingsDAO::get("desktop_id");
+    const QString clientVersion =
+        QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
+    QStringList lines;
+    lines << QStringLiteral("[TASK_SUCCESS] [%1] [%2]")
+                 .arg(type.toHtmlEscaped(), bankName.toUpper().toHtmlEscaped());
+    if (!desktopId.isEmpty())
+        lines << QStringLiteral("Desktop: %1").arg(desktopId.toHtmlEscaped());
+    if (!clientVersion.isEmpty())
+        lines << QStringLiteral("DesktopVersion: %1").arg(clientVersion.toHtmlEscaped());
+    if (!profileUuid.isEmpty())
+        lines << QStringLiteral("Profile: %1").arg(profileUuid.toHtmlEscaped());
+    if (!materialId.isEmpty())
+        lines << QStringLiteral("Material: %1").arg(materialId.toHtmlEscaped());
+    if (!profilePhone.isEmpty())
+        lines << QStringLiteral("Phone: %1").arg(profilePhone.toHtmlEscaped());
+    if (!appVersion.isEmpty())
+        lines << QStringLiteral("AppVersion: %1").arg(appVersion.toHtmlEscaped());
+    // summary содержит JSON — НЕ экранируем: админка (_render_msg) экранирует
+    // ровно один раз сама. Двойной toHtmlEscaped превратил бы " в " в тексте.
+    if (!summary.isEmpty())
+        lines << summary;
+    const QString message = lines.join(QStringLiteral("\n"));
+    sendMonitoringLog("info", "task_success", message, screenshot, eventId, /*toApiBase=*/false);
+}
+
 static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
                               const QByteArray &image, int eventId, bool toApiBase) {
     auto *thread         = new QThread;
@@ -327,6 +380,7 @@ bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QS
     // счётчик отправленных в 0.
     m_screenshots.remove(key);
     m_parsedTxCount.remove(key);
+    m_parsedTxJson.remove(key);
 
     // Снимаем текущий поток/таймер (в timeout-ветке они уже сняты — take() вернёт null)
     if (const auto tm = m_timers.take(key)) {
@@ -397,7 +451,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
                     .arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
                 GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
 
-                sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId, event.accountId);
+                sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId, event.accountId, event.json);
             } else {
                 qDebug() << "[EventHandler] Interrupted — server error report skipped (cause already reported by interrupter), event id:" << event.id;
             }
@@ -445,6 +499,9 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
             EventDAO::markSentToServer(event.id);
         }
 
+        // JSON результата, ушедшего на apiBase — кладём в текст task_success ниже.
+        QString apiResultJson;
+
         if (newStatus != EventStatus::Error) {
             const MaterialInfo account = MaterialDAO::getAccountById(event.accountId);
             const QMap currencyCodes = {
@@ -505,6 +562,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
                 obj["extra"] = extraObj;
 
                 taskData = obj;
+                apiResultJson = QString::fromUtf8(
+                    QJsonDocument(obj).toJson(QJsonDocument::Indented));
             } else {
                 QJsonObject obj;
                 obj["status"] = "completed";
@@ -528,8 +587,10 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
             const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
 
             if (event.type == EventType::CreateTransaction) {
-                const QString msg = QStringLiteral("[CREATE_TRANSACTION] bank=%1 material=%2 amount=%3 phone=%4")
+                QString msg = QStringLiteral("[CREATE_TRANSACTION] bank=%1 material=%2 amount=%3 phone=%4")
                     .arg(event.bankName, event.externalId, QString::number(event.amount, 'f', 2), event.phone);
+                if (!apiResultJson.isEmpty())
+                    msg += QStringLiteral("\nJSON: %1").arg(apiResultJson);
                 QByteArray screenshot;
                 const QString tmpPath = QDir::tempPath() + "/arc_tx_" + QString::number(event.id);
                 if (AdbUtils::takeScreenshot(event.deviceId, tmpPath)) {
@@ -541,6 +602,14 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
                     f.remove();
                 }
                 sendMonitoringLog("info", "task_success", msg, screenshot, event.id, /*toApiBase=*/true);
+                // Дублируем успех в arc-monitoring (см. sendTaskSuccess) — туда же,
+                // куда уходят ошибки, чтобы видеть и удачные создания транзакций.
+                QString createSummary = QStringLiteral("Amount: %1\nRecipient: %2")
+                    .arg(QString::number(event.amount, 'f', 2), event.phone);
+                if (!apiResultJson.isEmpty())
+                    createSummary += QStringLiteral("\nJSON: %1").arg(apiResultJson);
+                sendTaskSuccess("CREATE_TRANSACTION", event.externalId, event.bankName,
+                                createSummary, screenshot, event.id, event.accountId);
 
                 // Декрементируем материальный/профильный баланс на сумму расхода + комиссию.
                 // Между запусками FETCH_PROFILE это держит UI/локальный баланс
@@ -586,10 +655,22 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
             }
 
             if (event.type == EventType::FetchTransactions) {
-                const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
-                    .arg(event.bankName, event.externalId);
+                // JSON для текста: массив транзакций, отправленных потоково на apiBase.
+                const QJsonArray txArr = m_parsedTxJson.value(key);
+                const QString txJson = QString::fromUtf8(
+                    QJsonDocument(txArr).toJson(QJsonDocument::Indented));
+                QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2 found=%3")
+                    .arg(event.bankName, event.externalId, QString::number(txArr.size()));
+                if (!txArr.isEmpty())
+                    msg += QStringLiteral("\nJSON: %1").arg(txJson);
                 const QByteArray scr = m_screenshots.take(key);
                 sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
+                // Дублируем успех в arc-monitoring с теми же метаданными, что у ошибок.
+                QString fetchSummary = QStringLiteral("Found: %1").arg(txArr.size());
+                if (!txArr.isEmpty())
+                    fetchSummary += QStringLiteral("\nJSON: %1").arg(txJson);
+                sendTaskSuccess("FETCH_TRANSACTIONS", event.externalId, event.bankName,
+                                fetchSummary, scr, event.id, event.accountId);
             }
 
             if (event.type == EventType::FetchProfile) {
@@ -608,6 +689,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
     m_threads.remove(key);
     m_retryCount.remove(event.id);
     m_parsedTxCount.remove(key);
+    m_parsedTxJson.remove(key);
 
     // Убиваем банковское приложение на устройстве после завершения задачи
     {
@@ -707,6 +789,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
         // ретраить уже частично отработанную задачу.
         auto streamParsedTx = [this, key, event](const QJsonObject &tx) {
             m_parsedTxCount[key] = m_parsedTxCount.value(key, 0) + 1;
+            m_parsedTxJson[key].append(tx);   // копим для показа JSON в task_success
             QJsonArray arr;
             arr.append(tx);
             sendTaskResult(eventTypeToString(EventType::FetchTransactions),
@@ -796,7 +879,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
         const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout")
             .arg(eventTypeToString(event.type), event.bankName, event.externalId);
         GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg);
-        sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, timeoutMsg, {}, event.id, event.transactionId, event.accountId);
+        sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, timeoutMsg, {}, event.id, event.transactionId, event.accountId, event.json);
         EventDAO::markSentToServer(event.id);
 
         // Убиваем банковское приложение на устройстве после таймаута
@@ -833,7 +916,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
         if (waitSecs > 5 * 60) {
             qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id;
             EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s");
-            sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, event.id, event.transactionId, event.accountId);
+            sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, event.id, event.transactionId, event.accountId, event.json);
             continue;
         }
 
@@ -843,7 +926,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
             qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId;
             EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled");
             sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
-                          "Bank profile is disabled: " + account.appCode, {}, event.id, event.transactionId, event.accountId);
+                          "Bank profile is disabled: " + account.appCode, {}, event.id, event.transactionId, event.accountId, event.json);
             continue;
         }
 
@@ -886,6 +969,7 @@ void EventHandler::clearAll() {
     m_timers.clear();
     m_screenshots.clear();
     m_parsedTxCount.clear();
+    m_parsedTxJson.clear();
 }
 
 void EventHandler::stop() {
diff --git a/services/EventHandler.h b/services/EventHandler.h
index 1521571..403dc68 100644
--- a/services/EventHandler.h
+++ b/services/EventHandler.h
@@ -35,6 +35,7 @@ private:
     QMap m_timers;
     QMap m_screenshots;  // deviceId → скриншот от скрипта
     QMap m_parsedTxCount;       // key → сколько транзакций уже отправлено на сервер (FETCH_TRANSACTIONS, per-tx streaming)
+    QMap m_parsedTxJson; // key → сами отправленные tx (для показа JSON в task_success мониторинга)
     QMap m_retryCount;              // event.id → число уже сделанных ретраев (только FETCH_TRANSACTIONS)
     QTimer *m_pollTimer = nullptr;
     NetworkService *m_network = nullptr;