diff --git a/docs/database.md b/docs/database.md
new file mode 100644
index 0000000..ade8654
--- /dev/null
+++ b/docs/database.md
@@ -0,0 +1,80 @@
+# База данных — схема и миграции
+
+SQLite-база, путь задаётся в `config.ini` (`db/dbPath`). Управляется через `DatabaseManager` (singleton) + DAO-слой.
+
+---
+
+## Инициализация схемы
+
+При каждом запуске приложения вызывается `DatabaseManager::initSchema()` (`database/DatabaseManager.cpp`).
+Именно здесь происходят все миграции — до открытия главного окна.
+
+При **обновлении из меню** приложение скачивает новый `.exe`, применяет его и перезапускается, поэтому `initSchema()` тоже выполняется заново на новой версии.
+
+---
+
+## Что применяется автоматически при обновлении
+
+| Изменение | Применяется? | Как |
+|-----------|-------------|-----|
+| Новая таблица | Да | `CREATE TABLE IF NOT EXISTS` |
+| Новая колонка | Да | `ALTER TABLE ... ADD COLUMN` |
+| Удаление таблицы | Да | `DROP TABLE IF EXISTS` |
+| Удаление колонки | Нет | SQLite не поддерживает |
+| Переименование колонки | Нет | Нужна пересборка таблицы |
+| Изменение типа колонки | Нет | Нужна пересборка таблицы |
+| Изменение DEFAULT у существующей колонки | Нет | `CREATE TABLE IF NOT EXISTS` не выполняется повторно |
+
+---
+
+## Паттерн добавления колонки
+
+```cpp
+// 1. Добавить колонку в CREATE TABLE IF NOT EXISTS (для новых установок)
+q.exec(R"(CREATE TABLE IF NOT EXISTS my_table (
+ ...
+ new_col TEXT DEFAULT ''
+))");
+
+// 2. Добавить ALTER TABLE для существующих БД (ошибка "column already exists" игнорируется)
+q.exec("ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT ''");
+```
+
+Оба шага обязательны: `CREATE TABLE` покрывает свежие установки, `ALTER TABLE` — обновление существующей БД.
+
+---
+
+## Сложные миграции (изменение типа, переименование)
+
+Для изменений, которые SQLite не поддерживает напрямую, используется ручная миграция с пересозданием таблицы и `PRAGMA user_version` для версионирования:
+
+```cpp
+QSqlQuery q(db);
+q.exec("PRAGMA user_version");
+q.next();
+int version = q.value(0).toInt();
+
+if (version < 2) {
+ // пересоздаём таблицу
+ q.exec("ALTER TABLE my_table RENAME TO my_table_old");
+ q.exec("CREATE TABLE my_table (...)");
+ q.exec("INSERT INTO my_table SELECT ... FROM my_table_old");
+ q.exec("DROP TABLE my_table_old");
+ q.exec("PRAGMA user_version = 2");
+}
+```
+
+---
+
+## Таблицы
+
+| Таблица | Описание |
+|---------|----------|
+| `devices` | Android-устройства, подключённые по ADB |
+| `bank_profiles` | Банковские профили (приложение + устройство) |
+| `materials` | Карты/счета профиля |
+| `transactions` | Транзакции по картам |
+| `events` | Задачи из очереди API |
+| `banks` | Справочник банков (alias, name, nspk_name) |
+| `general_logs` | Общий лог приложения |
+| `settings` | Настройки key-value |
\ No newline at end of file
diff --git a/tests/mock_server/test_monitoring_log_format.py b/tests/mock_server/test_monitoring_log_format.py
new file mode 100644
index 0000000..1e28025
--- /dev/null
+++ b/tests/mock_server/test_monitoring_log_format.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""
+Test: monitoring log format for task_success / task_error.
+
+Starts the mock server in a subprocess, sends POST /monitoring/log
+requests that replicate what ARC C++ sends, then fetches the logs via
+/_admin/monitoring_logs and asserts the JSON format.
+
+Usage:
+ python3 test_monitoring_log_format.py
+
+Requirements: flask (same as mock_server.py), requests
+ pip install flask requests
+"""
+
+import json
+import os
+import subprocess
+import sys
+import time
+
+import requests
+
+PORT = 18889
+BASE = f"http://127.0.0.1:{PORT}"
+
+TX = {
+ "amount": 5000,
+ "bank_participator_id": "test-participator",
+ "bank_transaction_id": "dushanbe_2026-06-03T18:34:18Z",
+ "created_at": "2026-06-03T18:34:18Z",
+ "currency": 972,
+ "extra": {"fee": 0},
+ "real_created_at": "2026-06-03T18:34:34Z",
+ "status": "completed",
+ "transaction_type": "bank",
+}
+
+
+def build_task_success_message(tx: dict, *, pretty: bool) -> str:
+ """Build the monitoring message the way C++ sendTaskSuccess does.
+
+ pretty=True — new format (after the fix): 'JSON: ' + indented JSON
+ pretty=False — old format (before the fix): compact JSON on one line
+ """
+ lines = [
+ "[TASK_SUCCESS] [FETCH_TRANSACTIONS] [DUSHANBE_CITY_BANK]",
+ "Desktop: f7ff2724-78bd-4783-876d-faece1106c6a",
+ "DesktopVersion: 1.0.118",
+ "Profile: 4b9a2bbd-7201-49cb-beb3-7839f21acc2e",
+ "Material: bb537b06-155b-4e39-82ff-4b14f5b7c327",
+ "Phone: +992919275251",
+ "AppVersion: 3.2.41",
+ ]
+ if pretty:
+ lines.append("JSON: " + json.dumps(tx, indent=2, ensure_ascii=False))
+ else:
+ lines.append(json.dumps(tx, ensure_ascii=False, separators=(",", ":")))
+ return "\n".join(lines)
+
+
+def build_task_error_message(tx: dict) -> str:
+ """Build the monitoring message the way C++ sendTaskError does."""
+ lines = [
+ "[TASK_ERROR] [FETCH_TRANSACTIONS] [DUSHANBE_CITY_BANK]",
+ "Desktop: f7ff2724-78bd-4783-876d-faece1106c6a",
+ "DesktopVersion: 1.0.118",
+ "Profile: 4b9a2bbd-7201-49cb-beb3-7839f21acc2e",
+ "Material: bb537b06-155b-4e39-82ff-4b14f5b7c327",
+ "Phone: +992919275251",
+ "AppVersion: 3.2.41",
+ "Error: some error description",
+ "JSON: " + json.dumps(tx, indent=2, ensure_ascii=False),
+ ]
+ return "\n".join(lines)
+
+
+def post_log(msg: str, event: str = "task_success") -> None:
+ resp = requests.post(
+ f"{BASE}/monitoring/log",
+ data={"type": "info", "event": event, "message": msg},
+ timeout=5,
+ )
+ resp.raise_for_status()
+
+
+def get_logs(event: str) -> list:
+ resp = requests.get(f"{BASE}/_admin/monitoring_logs", params={"event": event}, timeout=5)
+ resp.raise_for_status()
+ return resp.json()
+
+
+# ------------------------------------------------------------------ tests
+
+PASS = "\033[32mPASS\033[0m"
+FAIL = "\033[31mFAIL\033[0m"
+
+
+def assert_eq(a, b, label):
+ if a == b:
+ print(f" {PASS}: {label}")
+ else:
+ print(f" {FAIL}: {label}")
+ print(f" expected: {b!r}")
+ print(f" got: {a!r}")
+ sys.exit(1)
+
+
+def assert_true(cond, label):
+ assert_eq(bool(cond), True, label)
+
+
+def test_new_format_is_pretty():
+ msg = build_task_success_message(TX, pretty=True)
+ post_log(msg, event="task_success")
+
+ logs = get_logs("task_success")
+ assert_true(len(logs) >= 1, "at least one task_success log received")
+
+ latest = logs[-1]["message"]
+
+ assert_true("JSON: " in latest, "message contains 'JSON: ' prefix")
+
+ json_part = latest.split("JSON: ", 1)[1]
+ parsed = json.loads(json_part)
+ assert_eq(parsed["amount"], 5000, "JSON 'amount' parsed correctly")
+ assert_eq(parsed["status"], "completed", "JSON 'status' parsed correctly")
+
+ assert_true("\n" in json_part, "JSON is multi-line (indented)")
+ assert_true(" " in json_part, "JSON has two-space indentation")
+
+ compact = json.dumps(TX, ensure_ascii=False, separators=(",", ":"))
+ assert_true(compact not in latest, "compact JSON not present (was replaced by pretty)")
+
+
+def test_new_format_matches_error_style():
+ success_msg = build_task_success_message(TX, pretty=True)
+ error_msg = build_task_error_message(TX)
+
+ success_json = success_msg.split("JSON: ", 1)[1]
+ error_json = error_msg.split("JSON: ", 1)[1]
+
+ assert_eq(success_json, error_json, "task_success JSON block equals task_error JSON block")
+
+
+def test_old_format_is_compact():
+ """Sanity check: confirm old format had no 'JSON: ' prefix and was compact."""
+ msg = build_task_success_message(TX, pretty=False)
+ assert_true("JSON: " not in msg, "old format has no 'JSON: ' prefix")
+ assert_true(
+ json.dumps(TX, ensure_ascii=False, separators=(",", ":")) in msg,
+ "old format contains compact JSON",
+ )
+
+
+# ------------------------------------------------------------------ runner
+
+def wait_for_server(timeout=10):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ requests.get(f"{BASE}/_admin/state", timeout=1)
+ return True
+ except Exception:
+ time.sleep(0.2)
+ return False
+
+
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ server = subprocess.Popen(
+ [sys.executable, os.path.join(here, "mock_server.py"), "--port", str(PORT)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ try:
+ if not wait_for_server():
+ print("ERROR: mock server did not start in time")
+ sys.exit(1)
+
+ print("Running tests against mock server...")
+ print()
+
+ print("test_old_format_is_compact")
+ test_old_format_is_compact()
+
+ print("test_new_format_is_pretty")
+ test_new_format_is_pretty()
+
+ print("test_new_format_matches_error_style")
+ test_new_format_matches_error_style()
+
+ print()
+ print("All tests passed.")
+ finally:
+ server.terminate()
+ server.wait()
+
+
+if __name__ == "__main__":
+ main()