Add native notification ingest and source-app allowlist

- Android NotificationListenerService plugin + queue store, MethodChannel
  bridge, and Flutter ingest worker / access controller
- source_apps allowlist (DAO, repo, settings + source_apps screen)
- schema v8 migration + decision_gate / dedup / draft_codec updates
- l10n strings and tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 00:08:18 +03:00
co-authored by Claude Opus 4.8
parent 209e5dd9c5
commit 76f301b758
53 changed files with 2090 additions and 246 deletions
+2 -1
View File
@@ -20,7 +20,8 @@
"Bash(find \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\" -name \"*.gradle\" 2>/dev/null | head -5)",
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\flutter.gradle\")",
"Bash(grep -r \"compileSdk\\\\|minSdk\\\\|targetSdk\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\")",
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")"
"Bash(grep -E \"compileSdkVersion|minSdkVersion|targetSdkVersion\" \"C:\\\\\\\\Sanders\\\\\\\\FlutterSDK\\\\\\\\flutter\\\\\\\\packages\\\\\\\\flutter_tools\\\\\\\\gradle\\\\\\\\bin\\\\\\\\main\\\\\\\\FlutterExtension.kt\")",
"Bash(xargs wc -l)"
]
}
}
+13 -4
View File
@@ -60,7 +60,7 @@ lib/
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
core/
database/app_database.dart # @DriftDatabase, schemaVersion=7
database/app_database.dart # @DriftDatabase, schemaVersion=8
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
database/daos/ # *_dao.dart with .watch*() methods
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
@@ -105,11 +105,19 @@ Every domain table has a `userId` FK → `users`.
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt |
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`,
`parse_rules`, `rule_candidates`, `account_bindings` (+`isDefault` per-app default binding),
`parse_rules` (+`txType` — transaction type pinned at rule creation, checked by the gate),
`rule_candidates`, `account_bindings` (+`isDefault` per-app default binding),
`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
imported by `app_database.dart`).
**Auto-apply gate** (`data/parser/decision_gate.dart`): no numeric confidence threshold —
a checklist of named `AutoApplyCheck`s (rule matched, amount literally found in body,
currency known, draft type == rule `txType` (null = skip), account resolved+trusted,
amount ≤ 100 000 ₽), gated by the `autoApplyEnabled` settings toggle. Failed checks are
cached in `draftJson` (`DraftBundle.failedChecks`) and shown as "why not automatic" in the
inbox card / parsing log. Numeric per-field scores remain only for the "?" badge in Inbox.
Enums live alongside their Drift tables; `enum_converters.dart` is the single import point for UI.
## Code-gen gotchas
@@ -174,8 +182,9 @@ Icon/color helpers that used to live in `_mock_data.dart` now live with their fe
(key never hardcoded; tests `skip:` when it's absent so default `flutter test` stays
green/offline). Override `aiKeyStoreProvider` with a fake key store + `isOnlineProvider`
with `Stream.value(true)` (connectivity_plus has no binding under `flutter test`); assert
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. AI-sourced
drafts never auto-apply (amount confidence ≤ 60 < strictness 85) — expect `inbox`.
the terminal `RawMessageStatus` and decode `draftJson` via `decodeDraftBundle`. Drafts for
merchants WITHOUT a user rule never auto-apply (gate check `ruleMatched` fails) — expect
`inbox`; with a rule + amount present in the body + trusted account the gate auto-applies.
## What's left (priority order)
+19
View File
@@ -25,6 +25,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Слушатель уведомлений: нативный источник для pipeline парсинга.
Разрешение BIND_NOTIFICATION_LISTENER_SERVICE объявляется самим
сервисом; пользователь включает доступ в системных настройках. -->
<service
android:name=".notifications.NotificationIngestService"
android:exported="true"
android:label="@string/notif_listener_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -41,5 +54,11 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Видимость запускаемых приложений для экрана выбора источников
уведомлений (Android 11+). QUERY_ALL_PACKAGES не используем. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
</manifest>
@@ -1,5 +1,12 @@
package com.sanders.budget.new_budget
import com.sanders.budget.new_budget.notifications.NotificationIngestPlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
NotificationIngestPlugin.register(flutterEngine, this)
}
}
@@ -0,0 +1,176 @@
package com.sanders.budget.new_budget.notifications
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
/**
* Регистрирует платформенные каналы между Dart и нативным слоем уведомлений.
*
* - MethodChannel [METHOD_CHANNEL]: проверка/выдача разрешения, синк allowlist,
* дрейн очереди.
* - EventChannel [EVENT_CHANNEL]: сигнал-тик «появилось новое уведомление,
* продренируй очередь» (без payload — данные всегда идут через `drainPending`).
*/
object NotificationIngestPlugin {
private const val METHOD_CHANNEL = "com.sanders.budget/notifications"
private const val EVENT_CHANNEL = "com.sanders.budget/notifications/events"
fun register(engine: FlutterEngine, context: Context) {
val appContext = context.applicationContext
val store = NotificationQueueStore(appContext)
MethodChannel(engine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"isPermissionGranted" -> {
val flat = Settings.Secure.getString(
appContext.contentResolver,
"enabled_notification_listeners",
)
val granted = flat != null && flat.split(":").any {
val cn = ComponentName.unflattenFromString(it)
cn != null && cn.packageName == appContext.packageName
}
result.success(granted)
}
"openSettings" -> {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(intent)
result.success(null)
}
"setMonitoredPackages" -> {
@Suppress("UNCHECKED_CAST")
val packages = (call.arguments as? List<String>) ?: emptyList()
store.setMonitoredPackages(packages.toSet())
result.success(null)
}
"getInstalledApps" -> {
// Тяжёлый I/O (загрузка иконок) — уводим с main-потока,
// результат резолвим обратно на main (требование канала).
val mainHandler = Handler(Looper.getMainLooper())
Thread {
val apps = loadLaunchableApps(appContext)
mainHandler.post { result.success(apps) }
}.start()
}
"drainPending" -> {
val array = store.drainAll()
val out = ArrayList<Map<String, Any?>>(array.length())
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
out.add(
mapOf(
NotificationQueueStore.KEY_PACKAGE to
obj.optString(NotificationQueueStore.KEY_PACKAGE),
NotificationQueueStore.KEY_TITLE to
if (obj.isNull(NotificationQueueStore.KEY_TITLE)) null
else obj.optString(NotificationQueueStore.KEY_TITLE),
NotificationQueueStore.KEY_BODY to
obj.optString(NotificationQueueStore.KEY_BODY),
NotificationQueueStore.KEY_RECEIVED_AT to
obj.optLong(NotificationQueueStore.KEY_RECEIVED_AT),
)
)
}
result.success(out)
}
else -> result.notImplemented()
}
}
EventChannel(engine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
LiveSink.sink = events
}
override fun onCancel(arguments: Any?) {
LiveSink.sink = null
}
})
}
/**
* Список запускаемых приложений (intent MAIN/LAUNCHER) для экрана выбора
* источников. Возвращает packageName, человекочитаемое имя и иконку (PNG-байты).
* Собственный пакет исключаем, дубли по packageName схлопываем, сортируем по
* имени. Запросы видимости пакетов объявлены в `<queries>` манифеста, поэтому
* `QUERY_ALL_PACKAGES` не требуется.
*/
private fun loadLaunchableApps(context: Context): List<Map<String, Any?>> {
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val resolved = pm.queryIntentActivities(intent, 0)
val byPackage = LinkedHashMap<String, Map<String, Any?>>()
for (info in resolved) {
val pkg = info.activityInfo?.packageName ?: continue
if (pkg == context.packageName) continue
if (byPackage.containsKey(pkg)) continue
val label = info.loadLabel(pm)?.toString().orEmpty().ifEmpty { pkg }
val icon = runCatching { drawableToPng(info.loadIcon(pm)) }.getOrNull()
byPackage[pkg] = mapOf(
"packageName" to pkg,
"appName" to label,
"icon" to icon,
)
}
return byPackage.values.sortedBy {
(it["appName"] as? String).orEmpty().lowercase()
}
}
/** Рендерит drawable иконку (вкл. adaptive) в PNG-байты фиксированного размера. */
private fun drawableToPng(drawable: Drawable?, sizePx: Int = 96): ByteArray? {
if (drawable == null) return null
val bitmap = if (drawable is BitmapDrawable && drawable.bitmap != null) {
Bitmap.createScaledBitmap(drawable.bitmap, sizePx, sizePx, true)
} else {
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bmp
}
return ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.toByteArray()
}
}
}
/**
* Держатель живого EventChannel sink. Сервис работает вне UI-потока, поэтому
* тик отправляется через main-handler. Когда приложение закрыто, [sink] = null
* и тик просто игнорируется (очередь дождётся дрейна).
*/
object LiveSink {
@Volatile
var sink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun tick() {
val s = sink ?: return
mainHandler.post { s.success(1) }
}
}
@@ -0,0 +1,41 @@
package com.sanders.budget.new_budget.notifications
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
/**
* Android NotificationListenerService — нативный источник для pipeline парсинга.
*
* Ловит уведомления, префильтрует по allowlist мониторимых пакетов (синк из
* Dart), складывает в [NotificationQueueStore] и — если приложение открыто и
* слушает EventChannel — будит Dart сигналом-тиком, чтобы он сразу дренировал
* очередь. Очередь остаётся источником истины: если движок выключен, тик
* теряется, но уведомление дождётся дрейна на следующем старте/resume.
*/
class NotificationIngestService : NotificationListenerService() {
private val store by lazy { NotificationQueueStore(applicationContext) }
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val notification = sbn?.notification ?: return
val packageName = sbn.packageName ?: return
// Префильтр: интересуют только разрешённые приложения.
if (packageName !in store.getMonitoredPackages()) return
val extras = notification.extras ?: return
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
// big-text полнее обычного text — берём его при наличии.
val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT))
?.toString()
?.trim()
.orEmpty()
if (body.isEmpty()) return
store.enqueue(packageName, title, body, sbn.postTime)
LiveSink.tick()
}
}
@@ -0,0 +1,83 @@
package com.sanders.budget.new_budget.notifications
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
/**
* Персистентное хранилище входящих уведомлений на нативной стороне.
*
* Источник истины для ingestion: [NotificationIngestService] складывает сюда
* пойманные уведомления (даже когда Flutter-движок выключен), а Dart дренит
* очередь через MethodChannel `drainPending`. Дополнительно хранит allowlist
* мониторимых пакетов (синхронизируется из Dart), чтобы префильтровать на
* нативной стороне и не плодить мусор.
*/
class NotificationQueueStore(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Добавляет уведомление в очередь (с ограничением размера). */
@Synchronized
fun enqueue(packageName: String, title: String?, body: String, postTimeMillis: Long) {
val array = readQueue()
val item = JSONObject().apply {
put(KEY_PACKAGE, packageName)
put(KEY_TITLE, title ?: JSONObject.NULL)
put(KEY_BODY, body)
put(KEY_RECEIVED_AT, postTimeMillis)
}
array.put(item)
// Держим только последние MAX_QUEUE элементов, чтобы не разрастаться.
val trimmed = if (array.length() > MAX_QUEUE) {
JSONArray().also { out ->
for (i in array.length() - MAX_QUEUE until array.length()) {
out.put(array.get(i))
}
}
} else {
array
}
prefs.edit().putString(KEY_QUEUE, trimmed.toString()).apply()
}
/** Возвращает и атомарно очищает очередь. */
@Synchronized
fun drainAll(): JSONArray {
val array = readQueue()
prefs.edit().remove(KEY_QUEUE).commit()
return array
}
@Synchronized
fun setMonitoredPackages(packages: Set<String>) {
prefs.edit().putStringSet(KEY_MONITORED, packages).apply()
}
@Synchronized
fun getMonitoredPackages(): Set<String> =
prefs.getStringSet(KEY_MONITORED, emptySet()) ?: emptySet()
private fun readQueue(): JSONArray {
val raw = prefs.getString(KEY_QUEUE, null) ?: return JSONArray()
return try {
JSONArray(raw)
} catch (e: Exception) {
JSONArray()
}
}
companion object {
private const val PREFS_NAME = "notif_ingest"
private const val KEY_QUEUE = "queue"
private const val KEY_MONITORED = "monitored_packages"
private const val MAX_QUEUE = 500
const val KEY_PACKAGE = "packageName"
const val KEY_TITLE = "title"
const val KEY_BODY = "body"
const val KEY_RECEIVED_AT = "receivedAt"
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notif_listener_label">NewBudget — распознавание уведомлений</string>
</resources>
+332
View File
@@ -0,0 +1,332 @@
# План развития тестов NewBudget
Документ — результат ревью существующих тестов (23 файла, ~4000 строк, июнь 2026).
Существующие тесты в хорошем состоянии: их **не трогаем** (кроме пары мелочей в Этапе 6).
План закрывает пробелы покрытия в порядке убывания ценности.
## Сводка этапов
| Этап | Что | Зачем | Объём (~тестов) |
|---|---|---|---|
| 1 | `month_summary` — агрегаты главного экрана | Не покрыта ключевая бизнес-логика; запланирован фикс переводов | 12–15 |
| 2 | `parseAmountToMinor` + `Money` | Денежный ввод не тестируется нигде | 15–20 |
| 3 | Миграции → drift `SchemaVerifier` | Текущий подход «отката» схемы растёт квадратично | 3 (переписать) |
| 4 | Негативные сценарии `ParsingPipeline` | Покрыт только happy-path; инфраструктура уже готова | 6–8 |
| 5 | Accounts / Categories / UserSeeder | Ноль тестов на целые фичи | 12–15 |
| 6 | Мелкие улучшения существующих тестов | Хрупкость и дубли | — |
Этапы независимы — можно делать выборочно. Внутри этапа порядок кейсов = приоритет.
## Конвенции (обязательны для всех новых тестов)
Из `CLAUDE.md` и сложившейся практики:
- **Без mocktail/mockito.** Фейки: `FakeXxxRepository implements XxxRepository` с
`noSuchMethod => throw UnimplementedError(...)` для неиспользуемых методов;
`FakeXxxController extends XxxController` с переопределённым `build()`.
- Unit: `ProviderContainer(overrides: [...])` + `addTearDown(container.dispose)`.
Widget: `ProviderScope(overrides: [...], child: MaterialApp(...))` +
`GoogleFonts.config.allowRuntimeFetching = false` в `setUpAll`.
- Реальная БД: `AppDatabase.forTesting(NativeDatabase.memory())`, сидим FK-цепочку
user → account → category перед транзакциями.
- `import 'package:drift/drift.dart' hide isNull, isNotNull;` при конфликте с matcher.
- Тексты в UI-тестах — через `AppLocalizations.delegate.load(...)`, не литералами
(см. `inbox_card_test.dart:139` как образец).
- Имена тестов — по-русски, описывают поведение, а не реализацию.
---
## Этап 1. `month_summary` — агрегаты главного экрана
**Файл кода:** `lib/src/features/home/presentation/month_summary.dart`
**Новый тест:** `test/features/home/month_summary_test.dart`
Это самая важная дыра: чистая агрегатная логика (доход/расход/переводы, «все счета»
vs конкретный счёт), которую пользователь видит каждый день. В бэклоге висит
«Transfer transactions: fix balance aggregation» — тесты должны **запиннить текущее
поведение до фикса**, чтобы фикс делался осознанным изменением ассертов.
### Инфраструктура
Провайдер читает `transactionsStreamProvider(userId, from:, to:)` через
`.watch(...).value ?? []`. Переопределяем стрим:
```dart
ProviderContainer(overrides: [
transactionsStreamProvider(userId, from: monthStart, to: monthEnd)
.overrideWith((ref) => Stream.value(txs)),
]);
```
**Гочи:**
- `from`/`to` в override должны бит-в-бит совпадать с тем, что вычисляет провайдер:
`monthStart = DateTime(y, m)`, `monthEnd = DateTime(y, m + 1).subtract(Duration(milliseconds: 1))`.
Месяц фиксируем через `selectedMonthProvider` (см. ниже), иначе провайдер возьмёт
`DateTime.now()` и override не сматчится.
- `Stream.value` эмитит асинхронно — первый синхронный `read` увидит `AsyncLoading`
и вернёт пустой список. Перед ассертами:
`await container.read(transactionsStreamProvider(...).future);`
- Месяц: `container.read(selectedMonthProvider.notifier)` не имеет сеттера на произвольную
дату (только `previous`/`next`). Два варианта: (а) строить тестовые транзакции в
текущем месяце `DateTime.now()`, как делает `tx_row_habit_test`; (б) добавить в
`SelectedMonth` метод `select(DateTime)` — он пригодится и для UI. Предпочтителен (б),
но это изменение кода — согласовать. По умолчанию — (а).
- Счёт: `container.read(selectedAccountProvider.notifier).select('a-1')`;
«Все счета» = дефолт (`kAllAccountsId == ''`).
### Кейсы `monthSummaryProvider`
Фикстура: счета `a1`, `a2`; категории `c1`, `c2`. Транзакции (минорные единицы):
| id | тип | счёт | категория | сумма | примечание |
|---|---|---|---|---|---|
| t1 | expense | a1 | c1 | 1000 | |
| t2 | expense | a1 | c2 | 2000 | |
| t3 | expense | a2 | c1 | 400 | |
| t4 | income | a1 | — | 5000 | |
| t5 | transfer a1→a2 | a1 | — | 700 | `transferToAccountId: a2` |
| t6 | expense | a1 | null | 300 | без категории |
1. **«Все счета»: income/expense/balance.** income=5000, expense=1000+2000+400+300=3700,
balance=1300. Переводы (t5) не входят ни в доход, ни в расход.
2. **«Все счета»: spendByCategory.** `{c1: 1400, c2: 2000}`; t6 (без категории) в карту
не попадает, но в `expensesMinor` входит. Отдельный ассерт на это расхождение —
оно неочевидно и влияет на донат-чарт.
3. **«Все счета»: transactionsCount = 6** (переводы считаются в количестве).
4. **Счёт a1:** income=5000, expense=1000+2000+300+700(перевод-исход)=4000, balance=1000.
5. **Счёт a2:** income=700 (входящий перевод t5), expense=400, balance=300.
Это пиннит текущее поведение «перевод НА счёт = доход» — при будущем фиксе
агрегации ассерт меняется осознанно.
6. **Счёт a2: transactionsCount = 2** (t3 + входящий t5).
7. **Пустой месяц** (нет транзакций) → все нули, пустая карта.
8. **spendByCategory не зависит от категория-фильтра** — фильтр категорий влияет только
на `filteredTransactions`, не на summary (задокументированное поведение).
### Кейсы `filteredTransactionsProvider`
9. Без фильтров → все 6.
10. Счёт a2 → t3 + входящий перевод t5 (для конкретного счёта переводы НА него включаются).
11. Категория-фильтр `{c1}` → t1, t3 (t5/t4/t6 отпадают: их `categoryId` не в множестве).
12. Комбинация счёт a1 + категория `{c2}` → только t2.
### Кейсы `categoriesBySpend` (чистая функция, без контейнера)
13. Сортировка по убыванию суммы.
14. Категории с нулевой/отсутствующей тратой не попадают в результат.
15. Категория есть в spend, но нет в списке categories (удалена/архив) → не попадает,
без исключения.
---
## Этап 2. Деньги: `parseAmountToMinor` + `Money`
### 2a. `parseAmountToMinor`
**Файл кода:** `lib/src/features/transactions/presentation/widgets/amount_input.dart:12`
**Новый тест:** `test/features/transactions/presentation/amount_input_test.dart`
Чистая функция, тестируется без виджетов. Кейсы:
| Ввод | Ожидание | Что проверяет |
|---|---|---|
| `'1234'` | 123400 | целое → ×100 |
| `'1234.56'` | 123456 | точка-разделитель |
| `'1234,56'` | 123456 | запятая-разделитель |
| `'1 234,56'` | 123456 | пробелы внутри числа |
| `'.5'` / `',5'` | 50 | пустая целая часть |
| `'12.5'` | 1250 | один знак дробной — `padRight` |
| `'12.567'` | 1256 | >2 знаков — усечение (НЕ округление: 12.567 → 1256). Пиннит текущее поведение |
| `''` / `' '` | 0 | пусто |
| `'1.2.3'` | 0 | два разделителя |
| `'abc'`, `'12a'` | 0 | мусор |
| `'0'` | 0 | ноль (форма должна отклонить как «Enter an amount») |
Плюс 1–2 widget-теста на сам `AmountInput`: ввод `'1 234,56'` через
`tester.enterText``onChanged` получил 123456; `inputFormatters` не пропускают буквы.
(Это закроет разрыв: текущие тесты формы задают сумму через `notifier.setAmount`,
минуя парсинг.)
### 2b. `Money`
**Файл кода:** `lib/src/core/money/money.dart`
**Новый тест:** `test/core/money/money_test.dart`
1. `fromAmount(12.34, 'RUB')` → 1234; `fromAmount(0.1 + 0.2, ...)` → 30 (round спасает от FP).
2. `fromAmount(100, 'JPY')` → 100 (нулевые decimals); `amount` для JPY не делит на 100.
3. Round-trip: `fromAmount(x).amount == x` для типичных значений.
4. Операторы `+`/`-`/`*` (включая `* 0.5` с округлением `.round()` — банковское не используется, пиннить как есть).
5. `==`/`hashCode`: равенство по minorUnits+currency; разные валюты не равны.
6. `toString`: `'RUB 12.34'`, `'JPY 100'`.
7. (Опционально) assert при `+` разных валют — проверять через `throwsA(isA<AssertionError>())`,
работает только в debug; пометить комментарием.
---
## Этап 3. Миграции БД → drift `SchemaVerifier`
**Сейчас:** `migration_v6/v7/v8_test.dart` поднимают актуальную схему и «откатывают» её
вручную (`ALTER TABLE ... DROP COLUMN`, `PRAGMA user_version`). Проблемы:
- Квадратичный рост: v6-тест уже вынужден удалять артефакты v7 **и** v8; каждая новая
версия схемы требует править все старые тесты.
- Тестируется «схема, похожая на старую», а не реальная старая: отличия в дефолтах,
индексах, constraint'ах не ловятся.
**Целевое состояние** — штатный механизм drift:
1. Снять снапшоты схем: `dart run drift_dev schema dump lib/src/core/database/app_database.dart drift_schemas/`
(создаст `drift_schemas/drift_schema_vN.json`; коммитятся в репозиторий).
Снапшоты старых версий генерируются один раз из git-истории: checkout коммита с
`schemaVersion = N` → dump → вернуть HEAD. Версии для checkout искать по
`git log -S 'schemaVersion' -- lib/src/core/database/app_database.dart`.
2. Сгенерировать тестовую обвязку:
`dart run drift_dev schema generate drift_schemas/ test/core/database/generated/`.
3. Переписать три теста на `SchemaVerifier` (`package:drift_dev/api/migrations.dart`):
```dart
final verifier = SchemaVerifier(GeneratedHelper());
final connection = await verifier.startAt(5);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 8);
```
4. Сохранить смысловые round-trip-проверки из текущих тестов (вставка строки с
`obligation`/`txType` после миграции) — `migrateAndValidate` проверяет структуру,
но не конвертеры enum'ов.
5. Добавить в `CLAUDE.md` правило: при бампе `schemaVersion` — новый dump + тест
`startAt(N-1) → migrateAndValidate(N)`.
**Definition of done:** старые три файла удалены, новые тесты зелёные, процедура
снапшота задокументирована. Если шаг 1 (восстановление старых схем из git) окажется
дорогим — допустимый компромисс: зафиксировать текущую v8 как первый снапшот и
переводить на SchemaVerifier только будущие миграции (v8 → v9+), оставив старые
тесты как есть до их естественного устаревания.
---
## Этап 4. Негативные сценарии `ParsingPipeline` / воркера
**Новый тест:** `test/features/notification_parsing/application/parsing_pipeline_negative_test.dart`
**Переиспользовать:** `_fakeAiParser` (MockClient), `_seed`, `_activateWorker`,
`_waitTerminal` из `parsing_worker_allowlist_test.dart` — **вынести их в
`test/features/notification_parsing/support.dart`**, чтобы не копировать.
Кейсы (все офлайн, через MockClient):
1. **AI вернул битый JSON** (MockClient отдаёт `'not a json'` в `content`) →
терминальный статус `failed`, `lastParseError` непустой, транзакций нет.
(Проверить заодно `ai_tolerant_json.dart`: что именно он прощает — markdown-обёртку
```` ```json ```` — а что нет.)
2. **AI вернул HTTP 500** → `failed` (или retry-поведение, если оно есть — пиннить фактическое).
3. **`autoApplyEnabled = false`** при полностью проходящем чек-листе (правило + сумма
в теле + дефолтный счёт) → `inbox`, транзакций нет. Сейчас toggle проверен только
на уровне `decide()`, но не сквозь pipeline.
4. **Правило `ignore`** на мерчанта → статус `ignored`, AI **не вызывался** (`onCall`-флаг).
5. **Дедупликация:** два `insertIncoming` с одинаковыми `packageName`+`body` →
второе сообщение не порождает второй обработки/транзакции (проверить фактический
контракт `insertIncoming`: возвращает существующее? вставляет со статусом `duplicate`?
— пиннить реальное поведение).
6. **Дневной лимит токенов исчерпан** (`setAiDailyTokenLimit(10)` + `addTokenUsage(20)`)
→ AI не вызывается, сообщение уходит в ожидаемый статус (по коду — regex-only путь;
уточнить по `parsing_pipeline.dart` и зафиксировать).
### 4b. `ParsingSettingsController` (unit, in-memory БД)
**Новый тест:** `test/features/notification_parsing/application/parsing_settings_controller_test.dart`
1. Дефолты: `enabled=true`, `autoApply=true`, `aiConsent=false`, модель = `kDefaultAiModel`.
2. `addTokenUsage` аккумулирует; `tokensUsedToday` в state обновляется.
3. `isDailyLimitReached`: false без лимита; true при `used >= limit`.
4. Смена дня: ключ счётчика содержит дату (`ai_token_usage_YYYY-MM-DD`).
`DateTime.now()` не инжектится — честно протестировать «вчерашний счётчик не
читается» можно, записав преференс с вчерашним ключом напрямую через
`settingsDao.setPreference('ai_token_usage_<вчера>', '999')` и проверив, что
`build()` вернул 0. (Опционально: рефакторинг `_tokenUsageKey` на инжектируемые
часы — отдельным решением.)
5. Персистентность: значения переживают пересоздание контейнера над той же БД.
---
## Этап 5. Accounts / Categories / UserSeeder
### 5a. `AccountRepositoryImpl` (in-memory БД)
**Новый тест:** `test/features/accounts/repository/account_repository_test.dart`
1. CRUD round-trip: create → findById с полями (currency, iconCode, colorValue, initialBalance).
2. **`setDefault`: инвариант единственного дефолта.** Два счёта, `setDefault(a1)`,
затем `setDefault(a2)` → `isDefault` только у a2. Это опора account resolver'а
(`globalDefault`, score 40) и gate-проверки `accountTrusted` — самый ценный кейс этапа.
3. `setDefault(null, userId)` снимает дефолт со всех.
4. `archive`: архивный счёт исчезает из основного watch-потока (или помечается — по
фактическому контракту DAO), но находится по id.
5. Изоляция пользователей: счета другого `userId` не видны.
### 5b. `CategoryRepositoryImpl`
**Новый тест:** `test/features/categories/repository/category_repository_test.dart`
1. CRUD round-trip, включая `parentId` (подкатегория) и его обнуление.
2. Фильтрация по `type` (expense/income) — то, на что завязана форма транзакции.
3. `archive` + изоляция пользователей (аналогично счетам).
### 5c. `UserSeeder` (реальный, in-memory БД)
**Новый тест:** `test/features/user/user_seeder_test.dart`
Сейчас сидер всюду фейкается; реальный код не исполняется ни одним тестом, при этом
от него зависит первый запуск приложения.
1. `seedForNewUser(userId)` → созданы дефолтные счета и категории (количества > 0;
точные наборы не пиннить — они будут меняться), все с правильным `userId`.
2. Один счёт помечен `isDefault` (если это контракт сидера — проверить по коду).
3. Демо-транзакции созданы и ссылаются на посеянные счета/категории (FK-цепочка цела).
Тест оформить так, чтобы при удалении демо-сида (пункт 2 бэклога CLAUDE.md)
достаточно было удалить один блок ассертов.
4. Повторный вызов для того же пользователя: пиннить фактическое поведение
(дубли? идемпотентность?) — это поведение при «втором онбординге».
### 5d. Контроллеры accounts/categories — только если в них есть логика
Если `AccountsController`/`CategoriesController` лишь проксируют репозиторий —
отдельные тесты не нужны (паттерн уже покрыт `users_controller_test`). Тестировать
только если есть валидация/оркестрация (проверить по коду перед написанием).
---
## Этап 6. Мелкие улучшения существующих тестов
1. **`onboarding_screen_test.dart`:** заменить литералы `'Welcome'`, `'Continue'`,
`'Your name'` на строки из `AppLocalizations.delegate.load(const Locale('en'))` —
по образцу `inbox_card_test.dart:139`. Иначе правка ARB валит тесты с невнятной
ошибкой.
2. **Общие фейки:** `FakeTransactionRepository` существует в трёх вариантах
(transactions_controller, inbox_controller, форма). Вынести один полный в
`test/support/fakes.dart`, остальные удалить. Туда же — `_UnusedXxxRepo`-заглушки.
3. **`widget_test.dart`:** добавить ассерт, что без активного пользователя показан
`OnboardingScreen` (проверка redirect-логики роутера почти бесплатно).
4. **`usersStreamProvider`-тест** (`users_controller_test.dart:332`): сейчас фейковый
`watchAll()` возвращает одноразовый `Stream.value`, и реактивность не проверяется.
Заменить в фейке на `StreamController.broadcast` с ре-эмитом после `create` —
тогда тест начнёт проверять то, что декларирует.
5. **`test/features/notification_parsing/support.dart`** — см. Этап 4 (общая обвязка
воркер-тестов).
---
## Команды
```bash
flutter test # весь оффлайн-набор (должен быть зелёным всегда)
flutter test test/features/home/month_summary_test.dart # один файл
flutter test --tags integration --dart-define=OPENROUTER_API_KEY=sk-or-... # сетевые
dart run build_runner build --delete-conflicting-outputs # если меняли @riverpod-код
```
## Definition of done (на каждый этап)
- `flutter analyze` чистый, `flutter test` зелёный без сети и без API-ключей.
- Новые тесты следуют конвенциям из раздела выше (фейки без mockito, l10n без литералов).
- Тесты, пиннящие «спорное» текущее поведение (переводы в month_summary, усечение
в parseAmountToMinor, повторный сид), помечены комментарием `// Пиннит текущее
поведение: ...` — чтобы при осознанном изменении поведения их меняли, а не «чинили».
+1 -1
View File
@@ -41,7 +41,7 @@ AI (Phase 2), переводы (Phase 3) и калибровка (Phase 4) —
- ✅ [rule_suggester.dart](../lib/src/features/notification_parsing/data/parser/rule_suggester.dart) + [merchant_normalizer.dart](../lib/src/features/notification_parsing/data/parser/merchant_normalizer.dart) — наблюдение кандидатов, предложение «merchant → category».
- ✅ [confidence_scorer.dart](../lib/src/features/notification_parsing/data/parser/confidence_scorer.dart) — 5 per-field оценок + sanity-checks (capping, `category ≤ merchant`, `looksLikeNonTransaction`).
- ✅ [decision_gate.dart](../lib/src/features/notification_parsing/data/parser/decision_gate.dart) — gate §8.7: правило + sanity + `min(amount,account,type) ≥ строгость` → auto-apply, иначе Inbox; крупная сумма → всегда Inbox.
- ✅ [dedup.dart](../lib/src/features/notification_parsing/data/parser/dedup.dart) — `dedupHash(packageName, body)` (FNV-1a, без времени).
- ✅ [dedup.dart](../lib/src/features/notification_parsing/data/parser/dedup.dart) — `dedupHash(packageName, body)` (FNV-1a, без времени) + окно `kDedupWindow` (±3 мин по `receivedAt`) при вставке: тот же текст вне окна — новое сообщение (§15).
- ✅ [draft_codec.dart](../lib/src/features/notification_parsing/data/parser/draft_codec.dart) — сериализация draft+предложения в `raw_messages.draftJson`.
- ✅ [parsing_worker.dart](../lib/src/features/notification_parsing/application/parsing_worker.dart) — Riverpod-stream воркер над `pending`, идемпотентный; non-transaction → `ignored`, иначе Inbox.
+21 -5
View File
@@ -206,16 +206,26 @@
"parsingSettingsTitle": "Notification parsing",
"parsingSettingsSubtitle": "Settings",
"parsingEnableLabel": "Recognize notifications",
"parsingStrictnessLabel": "Auto-add strictness",
"parsingStrictnessSoft": "Soft",
"parsingStrictnessNormal": "Normal",
"parsingStrictnessStrict": "Strict",
"parsingStrictnessHint": "The threshold applies to amount, account and type — not to the merchant.",
"parsingAutoApplyLabel": "Auto-add transactions",
"parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.",
"inboxWhyNotAuto": "Why not automatic: {reasons}",
"@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } },
"gateCheckRuleMatched": "no rule for this merchant",
"gateCheckAmountVerifiedInBody": "amount not found in the message text",
"gateCheckCurrencyKnown": "currency not recognized",
"gateCheckTypeMatchesRule": "transaction type differs from the rule",
"gateCheckAccountResolved": "account not resolved",
"gateCheckAccountTrusted": "ambiguous account",
"gateCheckAmountUnderCap": "amount is too large",
"parsingRulesTile": "Parsing rules",
"parsingRulesCreatedCount": "Rules created: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)",
"parsingAiComingSoon": "AI recognition is coming in a future version.",
"parsingNotifAccessTitle": "Notification access",
"parsingNotifAccessGranted": "Granted — notifications are being read",
"parsingNotifAccessDenied": "Not granted — tap to open settings",
"parsingNotifAccessOpenSettings": "Open settings",
"parsingDebugSectionTitle": "Debug",
"parsingDebugInjectTile": "Inject a test notification",
@@ -249,6 +259,7 @@
"parsingDetailAttempt": "Attempt {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Source",
"parsingDetailFailedChecks": "Why not automatic",
"parsingDetailAmountScore": "Amount",
"parsingDetailAccountScore": "Account",
"parsingDetailTypeScore": "Type",
@@ -394,6 +405,11 @@
"sourceAppsAddManual": "Add manually",
"sourceAppsPackageLabel": "Package name",
"sourceAppsNameLabel": "Display name (optional)",
"sourceAppsPickFromInstalled": "Choose from installed apps",
"sourceAppsPickerTitle": "Installed apps",
"sourceAppsSearchHint": "Search",
"sourceAppsPickerEmpty": "No apps found",
"sourceAppsAlreadyAdded": "Already added",
"appBindingsTitle": "Account bindings",
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
+105 -15
View File
@@ -908,35 +908,65 @@ abstract class AppLocalizations {
/// **'Распознавать уведомления'**
String get parsingEnableLabel;
/// No description provided for @parsingStrictnessLabel.
/// No description provided for @parsingAutoApplyLabel.
///
/// In ru, this message translates to:
/// **'Скорость авто-добавления'**
String get parsingStrictnessLabel;
/// **'Авто-добавление транзакций'**
String get parsingAutoApplyLabel;
/// No description provided for @parsingStrictnessSoft.
/// No description provided for @parsingAutoApplyHint.
///
/// In ru, this message translates to:
/// **'Мягко'**
String get parsingStrictnessSoft;
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
String get parsingAutoApplyHint;
/// No description provided for @parsingStrictnessNormal.
/// No description provided for @inboxWhyNotAuto.
///
/// In ru, this message translates to:
/// **'Нормально'**
String get parsingStrictnessNormal;
/// **'Почему не автоматически: {reasons}'**
String inboxWhyNotAuto(String reasons);
/// No description provided for @parsingStrictnessStrict.
/// No description provided for @gateCheckRuleMatched.
///
/// In ru, this message translates to:
/// **'Строго'**
String get parsingStrictnessStrict;
/// **'нет правила для мерчанта'**
String get gateCheckRuleMatched;
/// No description provided for @parsingStrictnessHint.
/// No description provided for @gateCheckAmountVerifiedInBody.
///
/// In ru, this message translates to:
/// **'Порог применяется к сумме, счёту и типу — не к мерчанту.'**
String get parsingStrictnessHint;
/// **'сумма не найдена в тексте сообщения'**
String get gateCheckAmountVerifiedInBody;
/// No description provided for @gateCheckCurrencyKnown.
///
/// In ru, this message translates to:
/// **'валюта не распознана'**
String get gateCheckCurrencyKnown;
/// No description provided for @gateCheckTypeMatchesRule.
///
/// In ru, this message translates to:
/// **'тип операции отличается от правила'**
String get gateCheckTypeMatchesRule;
/// No description provided for @gateCheckAccountResolved.
///
/// In ru, this message translates to:
/// **'счёт не определён'**
String get gateCheckAccountResolved;
/// No description provided for @gateCheckAccountTrusted.
///
/// In ru, this message translates to:
/// **'счёт неоднозначен'**
String get gateCheckAccountTrusted;
/// No description provided for @gateCheckAmountUnderCap.
///
/// In ru, this message translates to:
/// **'слишком крупная сумма'**
String get gateCheckAmountUnderCap;
/// No description provided for @parsingRulesTile.
///
@@ -962,6 +992,30 @@ abstract class AppLocalizations {
/// **'ИИ-распознавание появится в следующей версии.'**
String get parsingAiComingSoon;
/// No description provided for @parsingNotifAccessTitle.
///
/// In ru, this message translates to:
/// **'Доступ к уведомлениям'**
String get parsingNotifAccessTitle;
/// No description provided for @parsingNotifAccessGranted.
///
/// In ru, this message translates to:
/// **'Выдан — уведомления читаются'**
String get parsingNotifAccessGranted;
/// No description provided for @parsingNotifAccessDenied.
///
/// In ru, this message translates to:
/// **'Не выдан — нажмите, чтобы открыть настройки'**
String get parsingNotifAccessDenied;
/// No description provided for @parsingNotifAccessOpenSettings.
///
/// In ru, this message translates to:
/// **'Открыть настройки'**
String get parsingNotifAccessOpenSettings;
/// No description provided for @parsingDebugSectionTitle.
///
/// In ru, this message translates to:
@@ -1136,6 +1190,12 @@ abstract class AppLocalizations {
/// **'Источник'**
String get parsingDetailSource;
/// No description provided for @parsingDetailFailedChecks.
///
/// In ru, this message translates to:
/// **'Почему не автоматически'**
String get parsingDetailFailedChecks;
/// No description provided for @parsingDetailAmountScore.
///
/// In ru, this message translates to:
@@ -1820,6 +1880,36 @@ abstract class AppLocalizations {
/// **'Отображаемое имя (необязательно)'**
String get sourceAppsNameLabel;
/// No description provided for @sourceAppsPickFromInstalled.
///
/// In ru, this message translates to:
/// **'Выбрать из установленных'**
String get sourceAppsPickFromInstalled;
/// No description provided for @sourceAppsPickerTitle.
///
/// In ru, this message translates to:
/// **'Установленные приложения'**
String get sourceAppsPickerTitle;
/// No description provided for @sourceAppsSearchHint.
///
/// In ru, this message translates to:
/// **'Поиск'**
String get sourceAppsSearchHint;
/// No description provided for @sourceAppsPickerEmpty.
///
/// In ru, this message translates to:
/// **'Приложения не найдены'**
String get sourceAppsPickerEmpty;
/// No description provided for @sourceAppsAlreadyAdded.
///
/// In ru, this message translates to:
/// **'Уже добавлено'**
String get sourceAppsAlreadyAdded;
/// No description provided for @appBindingsTitle.
///
/// In ru, this message translates to:
+56 -6
View File
@@ -469,20 +469,39 @@ class AppLocalizationsEn extends AppLocalizations {
String get parsingEnableLabel => 'Recognize notifications';
@override
String get parsingStrictnessLabel => 'Auto-add strictness';
String get parsingAutoApplyLabel => 'Auto-add transactions';
@override
String get parsingStrictnessSoft => 'Soft';
String get parsingAutoApplyHint =>
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.';
@override
String get parsingStrictnessNormal => 'Normal';
String inboxWhyNotAuto(String reasons) {
return 'Why not automatic: $reasons';
}
@override
String get parsingStrictnessStrict => 'Strict';
String get gateCheckRuleMatched => 'no rule for this merchant';
@override
String get parsingStrictnessHint =>
'The threshold applies to amount, account and type — not to the merchant.';
String get gateCheckAmountVerifiedInBody =>
'amount not found in the message text';
@override
String get gateCheckCurrencyKnown => 'currency not recognized';
@override
String get gateCheckTypeMatchesRule =>
'transaction type differs from the rule';
@override
String get gateCheckAccountResolved => 'account not resolved';
@override
String get gateCheckAccountTrusted => 'ambiguous account';
@override
String get gateCheckAmountUnderCap => 'amount is too large';
@override
String get parsingRulesTile => 'Parsing rules';
@@ -499,6 +518,19 @@ class AppLocalizationsEn extends AppLocalizations {
String get parsingAiComingSoon =>
'AI recognition is coming in a future version.';
@override
String get parsingNotifAccessTitle => 'Notification access';
@override
String get parsingNotifAccessGranted =>
'Granted — notifications are being read';
@override
String get parsingNotifAccessDenied => 'Not granted — tap to open settings';
@override
String get parsingNotifAccessOpenSettings => 'Open settings';
@override
String get parsingDebugSectionTitle => 'Debug';
@@ -589,6 +621,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get parsingDetailSource => 'Source';
@override
String get parsingDetailFailedChecks => 'Why not automatic';
@override
String get parsingDetailAmountScore => 'Amount';
@@ -952,6 +987,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sourceAppsNameLabel => 'Display name (optional)';
@override
String get sourceAppsPickFromInstalled => 'Choose from installed apps';
@override
String get sourceAppsPickerTitle => 'Installed apps';
@override
String get sourceAppsSearchHint => 'Search';
@override
String get sourceAppsPickerEmpty => 'No apps found';
@override
String get sourceAppsAlreadyAdded => 'Already added';
@override
String get appBindingsTitle => 'Account bindings';
+55 -6
View File
@@ -481,20 +481,38 @@ class AppLocalizationsRu extends AppLocalizations {
String get parsingEnableLabel => 'Распознавать уведомления';
@override
String get parsingStrictnessLabel => 'Скорость авто-добавления';
String get parsingAutoApplyLabel => 'Авто-добавление транзакций';
@override
String get parsingStrictnessSoft => 'Мягко';
String get parsingAutoApplyHint =>
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
@override
String get parsingStrictnessNormal => 'Нормально';
String inboxWhyNotAuto(String reasons) {
return 'Почему не автоматически: $reasons';
}
@override
String get parsingStrictnessStrict => 'Строго';
String get gateCheckRuleMatched => 'нет правила для мерчанта';
@override
String get parsingStrictnessHint =>
'Порог применяется к сумме, счёту и типу — не к мерчанту.';
String get gateCheckAmountVerifiedInBody =>
'сумма не найдена в тексте сообщения';
@override
String get gateCheckCurrencyKnown => 'валюта не распознана';
@override
String get gateCheckTypeMatchesRule => 'тип операции отличается от правила';
@override
String get gateCheckAccountResolved => 'счёт не определён';
@override
String get gateCheckAccountTrusted => 'счёт неоднозначен';
@override
String get gateCheckAmountUnderCap => 'слишком крупная сумма';
@override
String get parsingRulesTile => 'Правила парсинга';
@@ -511,6 +529,19 @@ class AppLocalizationsRu extends AppLocalizations {
String get parsingAiComingSoon =>
'ИИ-распознавание появится в следующей версии.';
@override
String get parsingNotifAccessTitle => 'Доступ к уведомлениям';
@override
String get parsingNotifAccessGranted => 'Выдан — уведомления читаются';
@override
String get parsingNotifAccessDenied =>
'Не выдан — нажмите, чтобы открыть настройки';
@override
String get parsingNotifAccessOpenSettings => 'Открыть настройки';
@override
String get parsingDebugSectionTitle => 'Отладка';
@@ -601,6 +632,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get parsingDetailSource => 'Источник';
@override
String get parsingDetailFailedChecks => 'Почему не автоматически';
@override
String get parsingDetailAmountScore => 'Сумма';
@@ -965,6 +999,21 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get sourceAppsNameLabel => 'Отображаемое имя (необязательно)';
@override
String get sourceAppsPickFromInstalled => 'Выбрать из установленных';
@override
String get sourceAppsPickerTitle => 'Установленные приложения';
@override
String get sourceAppsSearchHint => 'Поиск';
@override
String get sourceAppsPickerEmpty => 'Приложения не найдены';
@override
String get sourceAppsAlreadyAdded => 'Уже добавлено';
@override
String get appBindingsTitle => 'Привязки счетов';
+21 -5
View File
@@ -206,16 +206,26 @@
"parsingSettingsTitle": "Парсинг уведомлений",
"parsingSettingsSubtitle": "Настройки",
"parsingEnableLabel": "Распознавать уведомления",
"parsingStrictnessLabel": "Скорость авто-добавления",
"parsingStrictnessSoft": "Мягко",
"parsingStrictnessNormal": "Нормально",
"parsingStrictnessStrict": "Строго",
"parsingStrictnessHint": "Порог применяется к сумме, счёту и типу — не к мерчанту.",
"parsingAutoApplyLabel": "Авто-добавление транзакций",
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
"inboxWhyNotAuto": "Почему не автоматически: {reasons}",
"@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } },
"gateCheckRuleMatched": "нет правила для мерчанта",
"gateCheckAmountVerifiedInBody": "сумма не найдена в тексте сообщения",
"gateCheckCurrencyKnown": "валюта не распознана",
"gateCheckTypeMatchesRule": "тип операции отличается от правила",
"gateCheckAccountResolved": "счёт не определён",
"gateCheckAccountTrusted": "счёт неоднозначен",
"gateCheckAmountUnderCap": "слишком крупная сумма",
"parsingRulesTile": "Правила парсинга",
"parsingRulesCreatedCount": "Правил создано: {count}",
"@parsingRulesCreatedCount": { "placeholders": { "count": { "type": "int" } } },
"parsingAiSectionTitle": "AI (OpenRouter)",
"parsingAiComingSoon": "ИИ-распознавание появится в следующей версии.",
"parsingNotifAccessTitle": "Доступ к уведомлениям",
"parsingNotifAccessGranted": "Выдан — уведомления читаются",
"parsingNotifAccessDenied": "Не выдан — нажмите, чтобы открыть настройки",
"parsingNotifAccessOpenSettings": "Открыть настройки",
"parsingDebugSectionTitle": "Отладка",
"parsingDebugInjectTile": "Вставить тестовое уведомление",
@@ -249,6 +259,7 @@
"parsingDetailAttempt": "Попытка {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Источник",
"parsingDetailFailedChecks": "Почему не автоматически",
"parsingDetailAmountScore": "Сумма",
"parsingDetailAccountScore": "Счёт",
"parsingDetailTypeScore": "Тип",
@@ -394,6 +405,11 @@
"sourceAppsAddManual": "Добавить вручную",
"sourceAppsPackageLabel": "Имя пакета",
"sourceAppsNameLabel": "Отображаемое имя (необязательно)",
"sourceAppsPickFromInstalled": "Выбрать из установленных",
"sourceAppsPickerTitle": "Установленные приложения",
"sourceAppsSearchHint": "Поиск",
"sourceAppsPickerEmpty": "Приложения не найдены",
"sourceAppsAlreadyAdded": "Уже добавлено",
"appBindingsTitle": "Привязки счетов",
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
+6 -1
View File
@@ -68,7 +68,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 7;
int get schemaVersion => 8;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -126,6 +126,11 @@ class AppDatabase extends _$AppDatabase {
accountBindingsTable, accountBindingsTable.isDefault);
await m.createTable(sourceAppsTable);
}
if (from < 8) {
// v7 → v8: тип операции фиксируется в правиле (для gate-проверки
// typeMatchesRule).
await m.addColumn(parseRulesTable, parseRulesTable.txType);
}
},
);
@@ -63,6 +63,7 @@ class InboxController extends _$InboxController {
kind: ParseRuleKind.merchantToCategory,
matchMode: matchMode,
pattern: pattern,
txType: draft.type,
merchantCanonical: merchantCanonical,
categoryId: categoryId,
);
@@ -0,0 +1,24 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'notification_parsing_providers.dart';
part 'notification_access_controller.g.dart';
/// Выдан ли доступ «Чтение уведомлений» (Android). На прочих платформах — false.
@riverpod
Future<bool> notificationAccessStatus(Ref ref) =>
ref.watch(notificationListenerChannelProvider).isPermissionGranted();
/// Действия над разрешением: открыть системные настройки и обновить статус.
@riverpod
class NotificationAccessController extends _$NotificationAccessController {
@override
void build() {}
/// Открывает системный экран «Доступ к уведомлениям».
Future<void> openSettings() =>
ref.read(notificationListenerChannelProvider).openSettings();
/// Перечитывает статус разрешения (например, после возврата из настроек).
void refresh() => ref.invalidate(notificationAccessStatusProvider);
}
@@ -0,0 +1,88 @@
import 'package:flutter/widgets.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'notification_parsing_providers.dart';
part 'notification_ingest_worker.g.dart';
/// Ingestion-драйвер нативных уведомлений (Android).
///
/// Мост между нативным [NotificationListenerChannel] и Dart-пайплайном:
/// синхронизирует allowlist в native, дренит нативную очередь и кладёт каждое
/// уведомление в `raw_messages` через `insertIncoming` (идемпотентно по
/// dedupHash). Дальше его подхватывает [ParsingWorker] — этот воркер за разбор
/// не отвечает, только за «как сообщения попадают в очередь».
///
/// Дренит при: старте, тике EventChannel (новое уведомление пока приложение
/// открыто) и возврате в foreground (`AppLifecycleState.resumed`) — на случай
/// уведомлений, пойманных пока движок был выключен.
///
/// Провайдер `keepAlive` — активируется чтением из `AppScaffold` (только Android).
@Riverpod(keepAlive: true)
class NotificationIngestWorker extends _$NotificationIngestWorker {
bool _draining = false;
bool _rerun = false;
@override
void build(String userId) {
// Синхронизируем allowlist мониторимых пакетов в native.
final pkgSub = ref.listen(
enabledSourcePackagesProvider(userId),
(_, next) {
final set = next.value;
if (set != null) {
ref
.read(notificationListenerChannelProvider)
.setMonitoredPackages(set);
}
},
fireImmediately: true,
);
ref.onDispose(pkgSub.close);
// Тик из EventChannel «появилось новое уведомление» → дрейн.
final tickSub = ref
.read(notificationListenerChannelProvider)
.ticks()
.listen((_) => _drain(userId));
ref.onDispose(tickSub.cancel);
// Пере-дрейн при возврате приложения в foreground.
final lifecycle = AppLifecycleListener(
onResume: () => _drain(userId),
);
ref.onDispose(lifecycle.dispose);
// Стартовый дрейн: забрать всё, что пришло пока приложение было закрыто.
_drain(userId);
}
Future<void> _drain(String userId) async {
// Если дрейн уже идёт — отметим, что нужен повторный проход: уведомление,
// пришедшее после drainPending, иначе дождётся лишь следующего тика.
if (_draining) {
_rerun = true;
return;
}
_draining = true;
try {
final channel = ref.read(notificationListenerChannelProvider);
final repo = ref.read(rawMessagesRepositoryProvider);
do {
_rerun = false;
final items = await channel.drainPending();
for (final item in items) {
await repo.insertIncoming(
userId: userId,
packageName: item.packageName,
title: item.title,
body: item.body,
receivedAt: item.receivedAt,
);
}
} while (_rerun);
} finally {
_draining = false;
}
}
}
@@ -1,6 +1,7 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/providers/database_provider.dart';
import '../data/native/notification_listener_channel.dart';
import '../data/repositories/account_bindings_repository_impl.dart';
import '../data/repositories/parse_rules_repository_impl.dart';
import '../data/repositories/raw_messages_repository_impl.dart';
@@ -38,7 +39,22 @@ AccountBindingsRepository accountBindingsRepository(Ref ref) =>
SourceAppsRepository sourceAppsRepository(Ref ref) =>
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
/// Множество включённых packageName пользователя — вход allowlist-фильтра.
/// Множество включённых packageName пользователя. Drift-стрим: эмитит каждое
/// изменение allowlist, чтобы native-синк в [NotificationIngestWorker] не
/// протухал до перезапуска. Подписка на стрим требует прямого слушателя
/// (`ref.listen`) — `read(.future)` без него не сработает; pipeline поэтому
/// берёт снапшот напрямую из репозитория.
@riverpod
Future<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId);
Stream<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
ref.watch(sourceAppsRepositoryProvider).watchEnabledPackages(userId);
/// Платформенный канал нативного слушателя уведомлений (Android).
@Riverpod(keepAlive: true)
NotificationListenerChannel notificationListenerChannel(Ref ref) =>
NotificationListenerChannel();
/// Список установленных запускаемых приложений (для экрана выбора источников).
/// autoDispose: грузится при открытии пикера, освобождается после закрытия.
@riverpod
Future<List<InstalledApp>> installedApps(Ref ref) =>
ref.watch(notificationListenerChannelProvider).getInstalledApps();
@@ -48,9 +48,13 @@ class ParsingPipeline {
if (!settings.enabled) return; // фича выключена — оставляем pending.
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
// ДО AI, чтобы не тратить токены на посторонние пакеты.
final enabled =
await _ref.read(enabledSourcePackagesProvider(userId).future);
// ДО AI, чтобы не тратить токены на посторонние пакеты. Снапшот берём из
// репозитория напрямую (`.first` Drift-стрима): свежий на каждый вызов;
// stream-провайдер без прямого слушателя не подписался бы на стрим.
final enabled = await _ref
.read(sourceAppsRepositoryProvider)
.watchEnabledPackages(userId)
.first;
if (!enabled.contains(msg.packageName)) {
await _ref
.read(rawMessagesRepositoryProvider)
@@ -266,7 +270,7 @@ class ParsingPipeline {
}
}
// 6. Confidence.
// 6. Confidence — только для подсветки «?» в Inbox, gate не использует.
final scores = scoreDraft(
draft: draft,
message: msg,
@@ -276,24 +280,23 @@ class ParsingPipeline {
categoryCandidate: candidate,
);
// 8. Gate.
final decision = decide(
// 8. Gate: чек-лист именованных проверок (decision_gate.dart).
final gate = decide(
autoApplyEnabled: settings.autoApplyEnabled,
merchantRule: rule,
sanityPassed: true,
scores: scores,
strictness: settings.strictness,
amountMinor: draft.amount,
accountResolved: draft.accountId != null,
accountTrusted: resolution.trusted,
draft: draft,
message: msg,
resolution: resolution,
);
if (decision == GateDecision.autoApply) {
if (gate.decision == GateDecision.autoApply) {
await _autoApply(userId, msg, draft, rule!, scores, resolution.bindingId);
} else {
await repo.updateAfterParse(
id: msg.id,
status: RawMessageStatus.inbox,
draftJson: encodeDraftBundle(draft, suggestion),
draftJson:
encodeDraftBundle(draft, suggestion, failedChecks: gate.failed),
confidenceAmount: scores.amount,
confidenceAccount: scores.account,
confidenceType: scores.type,
@@ -10,7 +10,7 @@ part 'parsing_settings_controller.g.dart';
class ParsingSettings {
const ParsingSettings({
required this.enabled,
required this.strictness,
required this.autoApplyEnabled,
required this.aiConsentGiven,
required this.aiModel,
required this.aiDailyTokenLimit,
@@ -20,8 +20,9 @@ class ParsingSettings {
/// Распознавать уведомления (мастер-тумблер всей фичи).
final bool enabled;
/// Порог строгости авто-добавления для прочих полей (§8.7): 75 / 85 / 95.
final int strictness;
/// Авто-добавление транзакций без подтверждения, когда пройден чек-лист
/// gate-проверок (см. `AutoApplyCheck` в decision_gate.dart).
final bool autoApplyEnabled;
/// Дано ли согласие на отправку текста уведомлений в AI (§7/§12.6).
/// Без него AI не вызывается — работает только regex.
@@ -41,7 +42,7 @@ class ParsingSettings {
ParsingSettings copyWith({
bool? enabled,
int? strictness,
bool? autoApplyEnabled,
bool? aiConsentGiven,
String? aiModel,
int? aiDailyTokenLimit,
@@ -50,7 +51,7 @@ class ParsingSettings {
}) =>
ParsingSettings(
enabled: enabled ?? this.enabled,
strictness: strictness ?? this.strictness,
autoApplyEnabled: autoApplyEnabled ?? this.autoApplyEnabled,
aiConsentGiven: aiConsentGiven ?? this.aiConsentGiven,
aiModel: aiModel ?? this.aiModel,
aiDailyTokenLimit:
@@ -60,7 +61,9 @@ class ParsingSettings {
}
const _kEnabled = 'parsing_enabled';
const _kStrictness = 'parsing_auto_apply_strictness';
// Старый ключ 'parsing_auto_apply_strictness' (75/85/95) больше не читается:
// числовой гейт заменён чек-листом, фича до замены не срабатывала ни разу.
const _kAutoApply = 'parsing_auto_apply_enabled';
const _kAiConsent = 'ai_consent';
const _kAiModel = 'ai_model';
const _kAiDailyLimit = 'ai_daily_token_limit';
@@ -69,7 +72,7 @@ const kDefaultAiModel = 'deepseek/deepseek-v4-flash';
const _defaultSettings = ParsingSettings(
enabled: true,
strictness: 85,
autoApplyEnabled: true,
aiConsentGiven: false,
aiModel: kDefaultAiModel,
aiDailyTokenLimit: null,
@@ -91,7 +94,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
Future<ParsingSettings> build() async {
final dao = ref.watch(appDatabaseProvider).settingsDao;
final enabledStr = await dao.getPreference(_kEnabled);
final strictnessStr = await dao.getPreference(_kStrictness);
final autoApplyStr = await dao.getPreference(_kAutoApply);
final consentStr = await dao.getPreference(_kAiConsent);
final modelStr = await dao.getPreference(_kAiModel);
final limitStr = await dao.getPreference(_kAiDailyLimit);
@@ -100,8 +103,9 @@ class ParsingSettingsController extends _$ParsingSettingsController {
return ParsingSettings(
enabled:
enabledStr == null ? _defaultSettings.enabled : enabledStr == 'true',
strictness:
int.tryParse(strictnessStr ?? '') ?? _defaultSettings.strictness,
autoApplyEnabled: autoApplyStr == null
? _defaultSettings.autoApplyEnabled
: autoApplyStr == 'true',
aiConsentGiven: consentStr == 'true',
aiModel: (modelStr != null && modelStr.isNotEmpty)
? modelStr
@@ -120,10 +124,10 @@ class ParsingSettingsController extends _$ParsingSettingsController {
state = AsyncData(current.copyWith(enabled: value));
}
Future<void> setStrictness(int value) async {
await _set(_kStrictness, '$value');
Future<void> setAutoApplyEnabled(bool value) async {
await _set(_kAutoApply, '$value');
final current = state.value ?? _defaultSettings;
state = AsyncData(current.copyWith(strictness: value));
state = AsyncData(current.copyWith(autoApplyEnabled: value));
}
Future<void> setAiConsent(bool value) async {
@@ -82,12 +82,17 @@ class RawMessagesDao extends DatabaseAccessor<AppDatabase>
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
.get();
/// Поиск по dedupHash для идемпотентной вставки.
/// Поиск по dedupHash для идемпотентной вставки (§15): совпавший хэш
/// считается дублем только в окне `receivedAt ∈ [from, to]` — иначе
/// регулярные одинаковые уведомления (подписка, ежедневный кофе)
/// схлопывались бы навсегда.
Future<RawMessagesTableData?> findByDedupHash(
String userId, String dedupHash) =>
String userId, String dedupHash, DateTime from, DateTime to) =>
(select(rawMessagesTable)
..where((t) =>
t.userId.equals(userId) & t.dedupHash.equals(dedupHash))
t.userId.equals(userId) &
t.dedupHash.equals(dedupHash) &
t.receivedAt.isBetweenValues(from, to))
..limit(1))
.getSingleOrNull();
@@ -16,13 +16,13 @@ class SourceAppsDao extends DatabaseAccessor<AppDatabase>
..orderBy([(t) => OrderingTerm(expression: t.displayName)]))
.watch();
/// Включённые packageName — для allowlist-фильтра.
Future<List<String>> enabledPackages(String userId) async {
final rows = await (select(sourceAppsTable)
..where((t) => t.userId.equals(userId) & t.enabled.equals(true)))
.get();
return rows.map((r) => r.packageName).toList();
}
/// Включённые packageName — для allowlist-фильтра. Стрим: эмитит при любом
/// изменении таблицы, чтобы allowlist не протухал до перезапуска.
Stream<List<String>> watchEnabledPackages(String userId) =>
(select(sourceAppsTable)
..where((t) => t.userId.equals(userId) & t.enabled.equals(true)))
.watch()
.map((rows) => rows.map((r) => r.packageName).toList());
Future<SourceAppsTableData?> findByPackageName(
String userId,
@@ -1,4 +1,5 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/converters/enum_converters.dart';
import '../../../../../core/database/tables/users_table.dart';
import '../../../../../core/database/tables/accounts_table.dart';
import '../../../../../core/database/tables/categories_table.dart';
@@ -32,6 +33,12 @@ class ParseRulesTable extends Table {
IntColumn get weight => integer().withDefault(const Constant(1))();
DateTimeColumn get lastMatchAt => dateTime().nullable()();
/// Тип операции, зафиксированный при создании правила (merchantToCategory).
/// Gate сверяет с ним тип из AI-draft; null (легаси-правила) = проверка
/// пропускается.
TextColumn get txType =>
text().map(const TransactionTypeConverter()).nullable()();
// Action fields (заполняются в зависимости от kind):
TextColumn get merchantCanonical => text().nullable()();
TextColumn get categoryId => text()
@@ -6,7 +6,9 @@ import '../converters.dart';
/// Сырые входящие уведомления от банковских приложений.
///
/// Точка входа pipeline парсинга. Идемпотентность обеспечивается
/// полем [dedupHash] = hash(packageName + body).
/// полем [dedupHash] = hash(packageName + body) в паре с проверкой
/// `receivedAt` в пределах дедуп-окна (§15) — хэш сам по себе не уникален:
/// одинаковые регулярные уведомления в разные дни являются разными записями.
class RawMessagesTable extends Table {
@override
String get tableName => 'raw_messages';
@@ -13,6 +13,7 @@ extension ParseRuleMapper on ParseRulesTableData {
matchCount: matchCount,
weight: weight,
lastMatchAt: lastMatchAt,
txType: txType,
merchantCanonical: merchantCanonical,
categoryId: categoryId,
accountId: accountId,
@@ -0,0 +1,111 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
/// Установленное на устройстве приложение (для выбора источника уведомлений).
class InstalledApp {
const InstalledApp({
required this.packageName,
required this.appName,
this.icon,
});
final String packageName;
final String appName;
/// Иконка приложения в PNG (если нативный слой смог её отрисовать).
final Uint8List? icon;
}
/// Одно пойманное нативным слоем уведомление.
class NativeNotification {
const NativeNotification({
required this.packageName,
this.title,
required this.body,
required this.receivedAt,
});
final String packageName;
final String? title;
final String body;
final DateTime receivedAt;
}
/// Обёртка платформенных каналов нативного слушателя уведомлений (Android).
///
/// Это платформенная реализация источника данных, поэтому живёт в data-слое
/// (как Drift). Очередь на нативной стороне — источник истины: [drainPending]
/// забирает и очищает её, [ticks] лишь сигнализирует «появилось новое, пора
/// дренировать». На не-Android платформах (и в `flutter test`) всё no-op,
/// чтобы код не падал без нативного канала.
class NotificationListenerChannel {
static const MethodChannel _method =
MethodChannel('com.sanders.budget/notifications');
static const EventChannel _events =
EventChannel('com.sanders.budget/notifications/events');
bool get _supported => Platform.isAndroid;
/// Выдан ли пользователем доступ «Чтение уведомлений».
Future<bool> isPermissionGranted() async {
if (!_supported) return false;
final granted = await _method.invokeMethod<bool>('isPermissionGranted');
return granted ?? false;
}
/// Открывает системный экран «Доступ к уведомлениям».
Future<void> openSettings() async {
if (!_supported) return;
await _method.invokeMethod<void>('openSettings');
}
/// Синхронизирует allowlist мониторимых пакетов на нативную сторону.
Future<void> setMonitoredPackages(Set<String> packages) async {
if (!_supported) return;
await _method.invokeMethod<void>(
'setMonitoredPackages',
packages.toList(growable: false),
);
}
/// Забирает и очищает нативную очередь пойманных уведомлений.
Future<List<NativeNotification>> drainPending() async {
if (!_supported) return const [];
final raw = await _method.invokeListMethod<dynamic>('drainPending');
if (raw == null) return const [];
return raw.map((e) {
final map = (e as Map).cast<dynamic, dynamic>();
final title = map['title'] as String?;
return NativeNotification(
packageName: (map['packageName'] as String?) ?? '',
title: (title != null && title.isEmpty) ? null : title,
body: (map['body'] as String?) ?? '',
receivedAt: DateTime.fromMillisecondsSinceEpoch(
(map['receivedAt'] as num?)?.toInt() ?? 0,
),
);
}).where((n) => n.packageName.isNotEmpty && n.body.isNotEmpty).toList();
}
/// Список запускаемых приложений устройства (для выбора источника вручную).
Future<List<InstalledApp>> getInstalledApps() async {
if (!_supported) return const [];
final raw = await _method.invokeListMethod<dynamic>('getInstalledApps');
if (raw == null) return const [];
return raw.map((e) {
final map = (e as Map).cast<dynamic, dynamic>();
return InstalledApp(
packageName: (map['packageName'] as String?) ?? '',
appName: (map['appName'] as String?) ?? '',
icon: map['icon'] as Uint8List?,
);
}).where((a) => a.packageName.isNotEmpty).toList();
}
/// Поток сигналов-тиков «появилось новое уведомление, продренируй очередь».
Stream<void> ticks() {
if (!_supported) return const Stream.empty();
return _events.receiveBroadcastStream();
}
}
@@ -5,6 +5,9 @@ import '../../domain/entities/rule_candidate.dart';
import '../../domain/enums.dart';
/// Пять per-field оценок уверенности (0..100), сохраняются в `raw_messages`.
///
/// В gate НЕ участвуют: авто-применение решает чек-лист [AutoApplyCheck]
/// в `decision_gate.dart`. Оценки нужны только для подсветки «?» в Inbox.
class FieldScores {
const FieldScores({
required this.amount,
@@ -19,19 +22,9 @@ class FieldScores {
final int type;
final int merchant;
final int category;
/// Минимум по «прочим полям» — это и есть вход в gate (§8.7).
///
/// Счёт исключён из gate-минимума: судьбу счёта решает `accountTrusted`
/// в [decide] (см. §B), а не числовой score. Account-score остаётся для
/// подсветки «?» в Inbox.
int get otherFieldsMin => [amount, type].reduce((a, b) => a < b ? a : b);
}
/// Считает 5 per-field оценок (§8.18.6).
///
/// Confidence играет вспомогательную роль: судьбу мерчанта решает наличие
/// правила (§5), а эти оценки гейтят прочие поля и дают подсветку «?».
/// Считает 5 per-field оценок (§8.18.6) — корм для бэджа «?» в Inbox.
///
/// Источник draft влияет на amount/type: regex выдаёт максимум (100), AI —
/// меньше (§8.1/§8.3), т.к. возможны галлюцинации.
@@ -49,7 +42,7 @@ FieldScores scoreDraft({
int amount;
int type;
if (draft.source == ParseSource.ai) {
amount = _amountAppearsInBody(draft.amount, message.body) ? 60 : 10;
amount = amountAppearsInBody(draft.amount, message.body) ? 60 : 10;
type = 70;
} else {
amount = 100;
@@ -120,14 +113,16 @@ int _scoreCategory(
int _capAt(int value, int cap) => value > cap ? cap : value;
/// Грубая проверка §8.1 для AI: встречается ли вытащенная сумма в теле.
/// Сверяем целую часть (рубли) — десятичные банк может опускать/округлять.
/// Детерминированная верификация суммы: встречается ли вытащенная сумма в
/// теле сообщения. Используется gate-проверкой `amountVerifiedInBody` и
/// скорингом §8.1. Сверяем целую часть (рубли) — десятичные банк может
/// опускать/округлять.
///
/// Сравниваем **числа целиком**, а не подстроку: иначе сумма 50 ложно
/// «находилась» бы в номере карты *5012 или в любой группе цифр, содержащей
/// «50». Вытаскиваем из тела числовые токены (с разделителями тысяч/копеек),
/// отбрасываем дробную часть и сравниваем как целые.
bool _amountAppearsInBody(int amountMinor, String body) {
bool amountAppearsInBody(int amountMinor, String body) {
final major = amountMinor ~/ 100;
if (major <= 0) return false;
for (final m in RegExp(r'\d[\d\s.,]*\d|\d').allMatches(body)) {
@@ -1,40 +1,84 @@
import '../../domain/entities/parse_draft.dart';
import '../../domain/entities/parse_rule.dart';
import '../../domain/entities/raw_message.dart';
import 'account_resolver.dart';
import 'confidence_scorer.dart';
/// Решение gate: молча в ленту или в Inbox.
enum GateDecision { autoApply, inbox }
/// Именованные проверки авто-применения (§8.7). Auto-apply — конъюнкция всех:
/// никакого числового скоринга, каждая проверка детерминирована и объяснима
/// пользователю («почему не автоматически»).
enum AutoApplyCheck {
/// Есть пользовательское правило для мерчанта.
ruleMatched,
/// Извлечённая сумма дословно найдена в теле сообщения.
amountVerifiedInBody,
/// Валюта распознана.
currencyKnown,
/// Тип операции совпадает с зафиксированным при создании правила
/// (null у легаси-правил — проверка пропускается).
typeMatchesRule,
/// Счёт разрешён (binding / правило / дефолт).
accountResolved,
/// Счёту можно доверять для авто-применения (не ambiguous multi-binding).
accountTrusted,
/// Сумма ниже потолка — защита от галлюцинаций (§15).
amountUnderCap,
}
/// Результат gate: решение + какие проверки не прошли (для лога/Inbox).
class GateResult {
const GateResult({required this.decision, required this.failed});
final GateDecision decision;
/// Непройденные проверки. Пустое множество при [GateDecision.autoApply]
/// и при выключенном тумблере (проверки не оценивались).
final Set<AutoApplyCheck> failed;
}
/// Очень крупная сумма (₽) — всегда Inbox, защита от галлюцинаций (§15).
/// Минорные единицы: 100 000 ₽ = 10 000 000 копеек.
const int _hugeAmountMinor = 100000 * 100;
/// Decision gate (§8.7).
///
/// Молча в ленту только если есть подтверждённое правило для мерчанта,
/// sanity пройден, счёт разрешён и доверенный, и min(сумма, тип) ≥ порога
/// строгости. Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту.
///
/// [accountResolved] — счёт вообще нашёлся; [accountTrusted] — ему можно
/// доверять для авто-применения (§B): неоднозначные multi-binding (#5) дают
/// false и уходят в Inbox, осознанные дефолты (#4/#6) — true.
GateDecision decide({
/// Decision gate (§8.7): молча в ленту только если авто-добавление включено
/// и пройдены ВСЕ проверки [AutoApplyCheck]. Проверки не short-circuit —
/// собираем полный список непройденных, чтобы показать причину в Inbox/логе.
GateResult decide({
required bool autoApplyEnabled,
required ParseRule? merchantRule,
required bool sanityPassed,
required FieldScores scores,
required int strictness,
required int amountMinor,
required bool accountResolved,
required bool accountTrusted,
required ParseDraft draft,
required RawMessage message,
required AccountResolution resolution,
}) {
if (amountMinor > _hugeAmountMinor) return GateDecision.inbox;
// Без разрешённого/доверенного счёта нечем создать транзакцию молча.
if (!accountResolved || !accountTrusted) return GateDecision.inbox;
if (merchantRule != null &&
sanityPassed &&
scores.otherFieldsMin >= strictness) {
return GateDecision.autoApply;
if (!autoApplyEnabled) {
return const GateResult(decision: GateDecision.inbox, failed: {});
}
return GateDecision.inbox;
final failed = <AutoApplyCheck>{
if (merchantRule == null) AutoApplyCheck.ruleMatched,
if (!amountAppearsInBody(draft.amount, message.body))
AutoApplyCheck.amountVerifiedInBody,
if (draft.currency.isEmpty) AutoApplyCheck.currencyKnown,
if (merchantRule != null &&
merchantRule.txType != null &&
merchantRule.txType != draft.type)
AutoApplyCheck.typeMatchesRule,
if (draft.accountId == null) AutoApplyCheck.accountResolved,
if (!resolution.trusted) AutoApplyCheck.accountTrusted,
if (draft.amount > _hugeAmountMinor) AutoApplyCheck.amountUnderCap,
};
return GateResult(
decision: failed.isEmpty ? GateDecision.autoApply : GateDecision.inbox,
failed: failed,
);
}
@@ -1,3 +1,10 @@
/// Окно дедупликации (§15): совпавший [computeDedupHash] считается дублем
/// только если `receivedAt` обоих сообщений различается не более чем на это
/// окно. `receivedAt` берётся из `sbn.postTime`, поэтому у переотправленного
/// дубля (ребут, повторный дрейн очереди) время совпадает с оригиналом, а у
/// настоящей новой операции с тем же текстом — отличается на часы/дни.
const Duration kDedupWindow = Duration(minutes: 3);
/// Детерминированный хэш для дедупликации уведомлений (§15).
///
/// `String.hashCode` в Dart рандомизируется на каждый запуск изолята, поэтому
@@ -5,7 +12,10 @@
/// стабильный FNV-1a (64-bit) и кодируем в hex. Без внешних зависимостей.
///
/// Хэш считается по `packageName + '\n' + body` — **без времени**, чтобы
/// повторная доставка того же сообщения схлопывалась в одну запись.
/// повторная доставка того же сообщения схлопывалась в одну запись. Сам по
/// себе хэш дублем не считается: вставка дополнительно проверяет
/// `receivedAt` в пределах [kDedupWindow], иначе регулярные одинаковые
/// операции (подписка, ежедневный кофе) терялись бы навсегда.
String computeDedupHash(String packageName, String body) {
final input = '$packageName\n$body';
return _fnv1a64(input);
@@ -4,6 +4,7 @@ import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/parse_draft.dart';
import '../../domain/entities/rule_suggestion.dart';
import '../../domain/enums.dart';
import 'decision_gate.dart';
/// Сериализация связки `ParseDraft` + `RuleSuggestion` в строку для
/// `RawMessage.draftJson`. Проект не использует json_serializable —
@@ -12,16 +13,30 @@ import '../../domain/enums.dart';
/// Связка кешируется при отправке сообщения в Inbox, чтобы экран мог
/// восстановить предложение без повторного парсинга.
class DraftBundle {
const DraftBundle({required this.draft, this.suggestion});
const DraftBundle({
required this.draft,
this.suggestion,
this.failedChecks = const {},
});
final ParseDraft draft;
final RuleSuggestion? suggestion;
/// Gate-проверки, не пройденные при отправке в Inbox — для строки
/// «почему не автоматически» в карточке/журнале.
final Set<AutoApplyCheck> failedChecks;
}
String encodeDraftBundle(ParseDraft draft, RuleSuggestion? suggestion) {
String encodeDraftBundle(
ParseDraft draft,
RuleSuggestion? suggestion, {
Set<AutoApplyCheck> failedChecks = const {},
}) {
return jsonEncode(<String, dynamic>{
'draft': _draftToJson(draft),
if (suggestion != null) 'suggestion': _suggestionToJson(suggestion),
if (failedChecks.isNotEmpty)
'failedChecks': failedChecks.map((c) => c.name).toList(),
});
}
@@ -34,9 +49,18 @@ DraftBundle? decodeDraftBundle(String? json) {
return DraftBundle(
draft: _draftFromJson(draftMap),
suggestion: suggMap == null ? null : _suggestionFromJson(suggMap),
failedChecks: _failedChecksFromJson(map['failedChecks']),
);
}
/// Неизвестные имена проверок (например, после удаления enum-значения в новой
/// версии) молча пропускаем — это кеш, а не источник истины.
Set<AutoApplyCheck> _failedChecksFromJson(Object? raw) {
if (raw is! List) return const {};
final byName = {for (final c in AutoApplyCheck.values) c.name: c};
return raw.whereType<String>().map((n) => byName[n]).nonNulls.toSet();
}
Map<String, dynamic> _draftToJson(ParseDraft d) => {
'rawMessageId': d.rawMessageId,
'type': d.type.name,
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../../../../core/database/app_database.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/parse_rule.dart';
import '../../domain/enums.dart';
import '../../domain/repositories/parse_rules_repository.dart';
@@ -36,6 +37,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
required MatchMode matchMode,
required String pattern,
int priority = 0,
TransactionType? txType,
String? merchantCanonical,
String? categoryId,
String? accountId,
@@ -49,6 +51,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
pattern: pattern,
matchMode: Value(matchMode),
priority: Value(priority),
txType: Value(txType),
merchantCanonical: Value(merchantCanonical),
categoryId: Value(categoryId),
accountId: Value(accountId),
@@ -66,6 +69,7 @@ class ParseRulesRepositoryImpl implements ParseRulesRepository {
matchMode: Value(rule.matchMode),
pattern: Value(rule.pattern),
priority: Value(rule.priority),
txType: Value(rule.txType),
merchantCanonical: Value(rule.merchantCanonical),
categoryId: Value(rule.categoryId),
accountId: Value(rule.accountId),
@@ -40,8 +40,15 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
(await _dao.findById(id))?.toDomain();
@override
Future<RawMessage?> findByDedupHash(String userId, String dedupHash) async =>
(await _dao.findByDedupHash(userId, dedupHash))?.toDomain();
Future<RawMessage?> findByDedupHash(
String userId, String dedupHash, DateTime receivedAt) async =>
(await _dao.findByDedupHash(
userId,
dedupHash,
receivedAt.subtract(kDedupWindow),
receivedAt.add(kDedupWindow),
))
?.toDomain();
@override
Future<List<RawMessage>> recentByUser(String userId, DateTime since) async =>
@@ -58,8 +65,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
required DateTime receivedAt,
}) async {
final hash = computeDedupHash(packageName, body);
final existing = await _dao.findByDedupHash(userId, hash);
if (existing != null) return existing.toDomain();
// §15: дубль = тот же хэш И receivedAt в пределах ±kDedupWindow. Тот же
// текст вне окна — новая реальная операция (подписка, регулярный платёж).
final existing = await findByDedupHash(userId, hash, receivedAt);
if (existing != null) return existing;
final id = const Uuid().v4();
await _dao.insert(
@@ -17,8 +17,8 @@ class SourceAppsRepositoryImpl implements SourceAppsRepository {
.map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Future<Set<String>> enabledPackages(String userId) async =>
(await _dao.enabledPackages(userId)).toSet();
Stream<Set<String>> watchEnabledPackages(String userId) =>
_dao.watchEnabledPackages(userId).map((rows) => rows.toSet());
@override
Future<SourceApp> add({
@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../enums.dart';
part 'parse_rule.freezed.dart';
@@ -27,6 +28,10 @@ abstract class ParseRule with _$ParseRule {
@Default(1) int weight,
DateTime? lastMatchAt,
/// Тип операции, зафиксированный при создании правила (merchantToCategory):
/// gate сверяет с ним тип AI-draft. null (легаси) — проверка пропускается.
TransactionType? txType,
// Action fields (nullable, depend on kind):
String? merchantCanonical,
String? categoryId,
@@ -1,3 +1,4 @@
import '../../../../core/database/converters/enum_converters.dart';
import '../entities/parse_rule.dart';
import '../enums.dart';
@@ -20,6 +21,7 @@ abstract interface class ParseRulesRepository {
required MatchMode matchMode,
required String pattern,
int priority,
TransactionType? txType,
String? merchantCanonical,
String? categoryId,
String? accountId,
@@ -24,13 +24,19 @@ abstract interface class RawMessagesRepository {
Future<RawMessage?> findById(String id);
Future<RawMessage?> findByDedupHash(String userId, String dedupHash);
/// Поиск дубля: совпадение [dedupHash] **и** `receivedAt` в пределах
/// дедуп-окна вокруг [receivedAt] (§15). Совпадение только по хэшу дублем
/// не считается — то же уведомление завтра является новой операцией.
Future<RawMessage?> findByDedupHash(
String userId, String dedupHash, DateTime receivedAt);
/// Сообщения за период (для live-превью правил).
Future<List<RawMessage>> recentByUser(String userId, DateTime since);
/// Идемпотентная вставка нового уведомления. Если сообщение с тем же
/// [dedupHash] уже есть — возвращает существующее, не создавая дубль.
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
/// (регулярный платёж) вставляется как новое сообщение.
Future<RawMessage> insertIncoming({
required String userId,
required String packageName,
@@ -6,7 +6,8 @@ abstract interface class SourceAppsRepository {
Stream<List<SourceApp>> watchByUser(String userId);
/// Множество включённых packageName — для allowlist-фильтра в пайплайне.
Future<Set<String>> enabledPackages(String userId);
/// Стрим, чтобы фильтр и native-синк реагировали на изменения allowlist.
Stream<Set<String>> watchEnabledPackages(String userId);
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
/// Если строка с таким packageName уже есть — возвращает существующую.
@@ -13,6 +13,7 @@ import '../../data/parser/draft_codec.dart';
import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../widgets/confidence_badge.dart';
import '../widgets/gate_check_labels.dart';
/// Максимум реальных AI-попыток до статуса `failed` (см. ParsingWorker §7).
const _maxParseAttempts = 5;
@@ -355,6 +356,13 @@ class _DetailPanel extends StatelessWidget {
),
if (draft != null)
_kv(context, l10n.parsingDetailSource, draft.source.name),
if (bundle != null && bundle!.failedChecks.isNotEmpty)
_kv(
context,
l10n.parsingDetailFailedChecks,
bundle!.failedChecks
.map((c) => gateCheckLabel(context, c))
.join(', ')),
]),
_section(context, l10n.parsingDetailMessage, [
_kv(context, l10n.parsingDetailTitle, message.title),
@@ -7,12 +7,13 @@ import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/notification_access_controller.dart';
import '../../application/parsing_settings_controller.dart';
import '../../application/rules_controller.dart';
import '../../domain/entities/parse_rule.dart';
/// Экран настроек парсинга (§12.5). Phase 1: тумблер фичи + строгость
/// авто-добавления + вход в правила. AI-секция — заглушка под Phase 2.
/// Экран настроек парсинга (§12.5): тумблер фичи + тумблер авто-добавления
/// (чек-лист gate-проверок) + вход в правила + AI-секция.
class ParsingSettingsScreen extends ConsumerWidget {
const ParsingSettingsScreen({super.key});
@@ -53,17 +54,27 @@ class ParsingSettingsScreen extends ConsumerWidget {
),
],
),
if (defaultTargetPlatform == TargetPlatform.android) ...[
const SizedBox(height: 16),
const _NotificationAccessCard(),
],
const SizedBox(height: 16),
Text(l10n.parsingStrictnessLabel,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 8),
_StrictnessSelector(
value: settings?.strictness ?? 85,
onChanged: (v) => controller.setStrictness(v),
_Card(
children: [
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
title: Text(l10n.parsingAutoApplyLabel,
style: TextStyle(fontSize: 14, color: p.ink)),
subtitle: Text(l10n.parsingAutoApplyHint,
style: TextStyle(fontSize: 12, color: p.ink2)),
value: settings?.autoApplyEnabled ?? true,
activeThumbColor: p.accent,
onChanged: settings == null
? null
: (v) => controller.setAutoApplyEnabled(v),
),
],
),
const SizedBox(height: 6),
Text(l10n.parsingStrictnessHint,
style: TextStyle(fontSize: 12, color: p.ink2)),
const SizedBox(height: 16),
_Card(
children: [
@@ -148,24 +159,74 @@ class ParsingSettingsScreen extends ConsumerWidget {
}
}
class _StrictnessSelector extends StatelessWidget {
const _StrictnessSelector({required this.value, required this.onChanged});
/// Карточка доступа «Чтение уведомлений» (Android). Показывает статус и ведёт
/// в системные настройки; при возврате в приложение статус перечитывается.
class _NotificationAccessCard extends ConsumerStatefulWidget {
const _NotificationAccessCard();
final int value;
final ValueChanged<int> onChanged;
@override
ConsumerState<_NotificationAccessCard> createState() =>
_NotificationAccessCardState();
}
class _NotificationAccessCardState
extends ConsumerState<_NotificationAccessCard> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(notificationAccessControllerProvider.notifier).refresh();
}
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
return SegmentedButton<int>(
segments: [
ButtonSegment(value: 75, label: Text(l10n.parsingStrictnessSoft)),
ButtonSegment(value: 85, label: Text(l10n.parsingStrictnessNormal)),
ButtonSegment(value: 95, label: Text(l10n.parsingStrictnessStrict)),
final granted = ref.watch(notificationAccessStatusProvider).value ?? false;
return _Card(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(
granted ? Icons.notifications_active_outlined : Icons.notifications_off_outlined,
color: granted ? p.positive : p.ink2,
),
title: Text(l10n.parsingNotifAccessTitle,
style: TextStyle(fontSize: 14, color: p.ink)),
subtitle: Text(
granted
? l10n.parsingNotifAccessGranted
: l10n.parsingNotifAccessDenied,
style: TextStyle(fontSize: 12, color: p.ink2),
),
trailing: granted
? null
: TextButton(
onPressed: () => ref
.read(notificationAccessControllerProvider.notifier)
.openSettings(),
child: Text(l10n.parsingNotifAccessOpenSettings),
),
onTap: granted
? null
: () => ref
.read(notificationAccessControllerProvider.notifier)
.openSettings(),
),
],
selected: {value},
showSelectedIcon: false,
onSelectionChanged: (s) => onChanged(s.first),
);
}
}
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -6,6 +7,7 @@ import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/notification_parsing_providers.dart';
import '../../application/source_apps_controller.dart';
import '../../data/source_apps/source_apps_catalog.dart';
import '../../domain/entities/source_app.dart';
@@ -51,6 +53,32 @@ class SourceAppsScreen extends ConsumerWidget {
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
if (defaultTargetPlatform == TargetPlatform.android) ...[
_Card(
children: [
InkWell(
onTap: () => _pickFromInstalled(context, ref, userId),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Icon(Icons.phone_android_outlined,
size: 20, color: p.accent),
const SizedBox(width: 12),
Expanded(
child: Text(l10n.sourceAppsPickFromInstalled,
style: TextStyle(fontSize: 14, color: p.ink)),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
),
),
],
),
const SizedBox(height: 20),
],
Text(l10n.sourceAppsAddedSection,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 8),
@@ -140,6 +168,157 @@ class SourceAppsScreen extends ConsumerWidget {
);
}
}
Future<void> _pickFromInstalled(
BuildContext context,
WidgetRef ref,
String userId,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: context.palette.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => _InstalledAppsPickerSheet(userId: userId),
);
}
}
/// Лист выбора установленного приложения: поиск + список с иконками.
/// Уже добавленные пакеты помечаются и не добавляются повторно.
class _InstalledAppsPickerSheet extends ConsumerStatefulWidget {
const _InstalledAppsPickerSheet({required this.userId});
final String userId;
@override
ConsumerState<_InstalledAppsPickerSheet> createState() =>
_InstalledAppsPickerSheetState();
}
class _InstalledAppsPickerSheetState
extends ConsumerState<_InstalledAppsPickerSheet> {
String _query = '';
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final installed = ref.watch(installedAppsProvider);
final added = {
for (final a in ref.watch(sourceAppsListProvider(widget.userId)).value ??
const <SourceApp>[])
a.packageName,
};
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Column(
children: [
const SizedBox(height: 12),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: p.line,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text(l10n.sourceAppsPickerTitle,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: p.ink)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 20),
hintText: l10n.sourceAppsSearchHint,
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (v) => setState(() => _query = v.trim().toLowerCase()),
),
),
const SizedBox(height: 8),
Expanded(
child: installed.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Text(l10n.sourceAppsPickerEmpty,
style: TextStyle(fontSize: 14, color: p.ink2)),
),
data: (apps) {
final filtered = _query.isEmpty
? apps
: apps
.where((a) =>
a.appName.toLowerCase().contains(_query) ||
a.packageName.toLowerCase().contains(_query))
.toList();
if (filtered.isEmpty) {
return Center(
child: Text(l10n.sourceAppsPickerEmpty,
style: TextStyle(fontSize: 14, color: p.ink2)),
);
}
return ListView.builder(
controller: scrollController,
itemCount: filtered.length,
itemBuilder: (context, i) {
final app = filtered[i];
final isAdded = added.contains(app.packageName);
return ListTile(
leading: app.icon != null
? Image.memory(app.icon!,
width: 36, height: 36, gaplessPlayback: true)
: Icon(Icons.apps_outlined, color: p.ink2),
title: Text(
app.appName.isEmpty ? app.packageName : app.appName,
style: TextStyle(fontSize: 14, color: p.ink)),
subtitle: Text(app.packageName,
style: TextStyle(fontSize: 11, color: p.ink2)),
trailing: isAdded
? Text(l10n.sourceAppsAlreadyAdded,
style:
TextStyle(fontSize: 12, color: p.positive))
: Icon(Icons.add_circle_outline, color: p.accent),
onTap: isAdded
? null
: () async {
await ref
.read(sourceAppsControllerProvider.notifier)
.add(
userId: widget.userId,
packageName: app.packageName,
displayName: app.appName.isEmpty
? null
: app.appName,
);
if (context.mounted) Navigator.of(context).pop();
},
);
},
);
},
),
),
],
);
},
);
}
}
class _AddedAppTile extends ConsumerWidget {
@@ -0,0 +1,40 @@
import 'package:flutter/widgets.dart';
import '../../../../app/l10n/l10n.dart';
import '../../data/parser/decision_gate.dart';
/// Локализованное название непройденной gate-проверки —
/// для строки «почему не автоматически» в Inbox и журнале парсинга.
String gateCheckLabel(BuildContext context, AutoApplyCheck check) {
final l10n = context.l10n;
switch (check) {
case AutoApplyCheck.ruleMatched:
return l10n.gateCheckRuleMatched;
case AutoApplyCheck.amountVerifiedInBody:
return l10n.gateCheckAmountVerifiedInBody;
case AutoApplyCheck.currencyKnown:
return l10n.gateCheckCurrencyKnown;
case AutoApplyCheck.typeMatchesRule:
return l10n.gateCheckTypeMatchesRule;
case AutoApplyCheck.accountResolved:
return l10n.gateCheckAccountResolved;
case AutoApplyCheck.accountTrusted:
return l10n.gateCheckAccountTrusted;
case AutoApplyCheck.amountUnderCap:
return l10n.gateCheckAmountUnderCap;
}
}
/// Текст «Почему не автоматически: …» для Inbox-карточки.
///
/// Показываем только когда правило для мерчанта НАШЛОСЬ, но другие проверки
/// не прошли — пользователь ждал авто-применения и должен увидеть причину.
/// Без правила Inbox — ожидаемое место, причины не нужны.
String? whyNotAutoText(BuildContext context, Set<AutoApplyCheck> failed) {
if (failed.isEmpty || failed.contains(AutoApplyCheck.ruleMatched)) {
return null;
}
final reasons =
failed.map((c) => gateCheckLabel(context, c)).join(', ');
return context.l10n.inboxWhyNotAuto(reasons);
}
@@ -14,6 +14,7 @@ import '../../domain/entities/raw_message.dart';
import '../../domain/enums.dart';
import '../screens/rule_editor_screen.dart';
import 'confidence_badge.dart';
import 'gate_check_labels.dart';
/// Карточка Inbox (§12.2): мерчант, сумма, подсветка слабых полей и три
/// действия — «Создать правило», «Подтвердить разово», «Игнорировать».
@@ -148,6 +149,13 @@ class _RecognizedBody extends ConsumerWidget {
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (whyNotAutoText(context, bundle.failedChecks) != null) ...[
const SizedBox(height: 6),
Text(
whyNotAutoText(context, bundle.failedChecks)!,
style: TextStyle(fontSize: 12, color: p.ink2, height: 1.3),
),
],
const SizedBox(height: 12),
_CreateRuleButton(
label: categoryName != null
+6
View File
@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/notification_parsing/application/notification_ingest_worker.dart';
import '../../features/notification_parsing/application/parsing_worker.dart';
import '../../features/user/application/active_user_controller.dart';
import 'app_bottom_nav.dart';
@@ -20,6 +22,10 @@ class AppScaffold extends ConsumerWidget {
final userId = ref.watch(activeUserControllerProvider).value?.id;
if (userId != null) {
ref.watch(parsingWorkerProvider(userId));
// Нативный слушатель уведомлений — только Android.
if (defaultTargetPlatform == TargetPlatform.android) {
ref.watch(notificationIngestWorkerProvider(userId));
}
}
return Scaffold(
+3 -2
View File
@@ -42,8 +42,8 @@ void main() {
),
);
// 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7
// (иначе onUpgrade 5→7 попытается создать их повторно).
// 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7/v8
// (иначе onUpgrade 5→8 попытается создать их повторно).
await dbV6.customStatement(
'ALTER TABLE transactions DROP COLUMN obligation');
await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse');
@@ -52,6 +52,7 @@ void main() {
await dbV6.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV6.customStatement('DROP TABLE source_apps');
await dbV6.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV6.customStatement('PRAGMA user_version = 5');
await dbV6.close();
+4 -2
View File
@@ -40,10 +40,11 @@ void main() {
),
);
// 2. «Откатываем» до v6.
// 2. «Откатываем» до v6 (включая артефакт v8 — parse_rules.tx_type).
await dbV7.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV7.customStatement('DROP TABLE source_apps');
await dbV7.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV7.customStatement('PRAGMA user_version = 6');
await dbV7.close();
@@ -76,7 +77,8 @@ void main() {
packageName: 'ru.sberbankmobile',
),
);
final enabled = await dbMigrated.sourceAppsDao.enabledPackages(userId);
final enabled =
await dbMigrated.sourceAppsDao.watchEnabledPackages(userId).first;
expect(enabled, contains('ru.sberbankmobile'));
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
/// Тест миграции v7 → v8 (parse_rules.tx_type для gate-проверки
/// typeMatchesRule).
///
/// Поднимаем актуальную (v8) схему, «откатываем» до v7 (убираем tx_type,
/// ставим user_version = 7). Повторное открытие запускает onUpgrade(7 → 8),
/// который должен восстановить колонку.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_v8_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 7 → 8 добавляет parse_rules.tx_type', () async {
const userId = 'user-1';
// 1. Актуальная схема (v8) + FK-цепочка.
final dbV8 = AppDatabase.forTesting(NativeDatabase(file));
await dbV8.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
// 2. «Откатываем» до v7.
await dbV8.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV8.customStatement('PRAGMA user_version = 7');
await dbV8.close();
// 3. Повторное открытие → onUpgrade(7 → 8).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4. Колонка снова доступна: правило с txType проходит round-trip,
// легаси-правило без txType читается с null.
await dbMigrated.parseRulesDao.insert(
ParseRulesTableCompanion.insert(
id: 'r-typed',
userId: userId,
kind: ParseRuleKind.merchantToCategory,
pattern: 'LENTA',
txType: const Value(TransactionType.expense),
),
);
await dbMigrated.parseRulesDao.insert(
ParseRulesTableCompanion.insert(
id: 'r-legacy',
userId: userId,
kind: ParseRuleKind.merchantToCategory,
pattern: 'OZON',
),
);
final typed = await dbMigrated.parseRulesDao.findById('r-typed');
expect(typed!.txType, TransactionType.expense);
final legacy = await dbMigrated.parseRulesDao.findById('r-legacy');
expect(legacy!.txType, isNull);
});
}
@@ -119,6 +119,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
required MatchMode matchMode,
required String pattern,
int priority = 0,
TransactionType? txType,
String? merchantCanonical,
String? categoryId,
String? accountId,
@@ -127,6 +128,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
'kind': kind,
'matchMode': matchMode,
'pattern': pattern,
'txType': txType,
'merchantCanonical': merchantCanonical,
'categoryId': categoryId,
});
@@ -136,6 +138,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
kind: kind,
matchMode: matchMode,
pattern: pattern,
txType: txType,
merchantCanonical: merchantCanonical,
categoryId: categoryId,
accountId: accountId,
@@ -265,6 +268,8 @@ void main() {
expect(rulesRepo.created, hasLength(1));
expect(rulesRepo.created.single['kind'], ParseRuleKind.merchantToCategory);
expect(rulesRepo.created.single['categoryId'], 'cat1');
// Тип операции фиксируется в правиле — gate-проверка typeMatchesRule.
expect(rulesRepo.created.single['txType'], TransactionType.expense);
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
expect(rawRepo.linked, contains(('msg1', 'tx1')));
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/core/providers/database_provider.dart';
import 'package:new_budget/src/features/accounts/application/account_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
@@ -185,12 +186,11 @@ void main() {
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'LENTA',
txType: TransactionType.expense,
categoryId: 'cat-1',
);
// AI draft amount score = 60; снижаем строгость, чтобы пройти gate.
await container
.read(parsingSettingsControllerProvider.notifier)
.setStrictness(50);
// Gate-чек-лист: правило есть, сумма 1500 находится в теле, валюта RUB,
// тип совпадает с правилом, счёт — глобальный дефолт (trusted).
_activateWorker(container);
@@ -211,4 +211,44 @@ void main() {
expect(txns.first.accountId, _accountId);
expect(txns.first.categoryId, 'cat-1');
});
test('allowlist обновляется без перезапуска: добавили банк → парсится',
() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith((ref) async =>
_fakeAiParser(merchantRaw: 'LENTA', amount: 1500)),
]);
repo = container.read(rawMessagesRepositoryProvider);
await enableAi(container);
_activateWorker(container);
// 1. Банк ещё не в allowlist → ignored.
final first = await repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: 'Payment of 1500 RUB at LENTA',
receivedAt: DateTime(2026, 5, 31, 12),
);
expect((await _waitTerminal(repo, first.id)).status,
RawMessageStatus.ignored);
// 2. Пользователь добавляет банк — контейнер живёт, без «перезапуска».
await container
.read(sourceAppsRepositoryProvider)
.add(userId: _userId, packageName: _bank);
// 3. Следующее сообщение уже парсится (правила нет → Inbox, не ignored).
final second = await repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: 'Payment of 1500 RUB at LENTA, thanks',
receivedAt: DateTime(2026, 5, 31, 13),
);
expect(
(await _waitTerminal(repo, second.id)).status, RawMessageStatus.inbox);
});
}
@@ -0,0 +1,75 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/dedup.dart';
import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart';
/// Дедупликация вставки `raw_messages` (§15): дубль = тот же dedupHash
/// **и** receivedAt в пределах ±[kDedupWindow]. Тот же текст вне окна —
/// новая реальная операция (подписка, ежедневный кофе), терять её нельзя.
const _userId = 'u1';
const _bank = 'ru.sberbankmobile';
const _body = 'Покупка 100 ₽, Кофейня';
void main() {
late AppDatabase db;
late RawMessagesRepositoryImpl repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
repo = RawMessagesRepositoryImpl(RawMessagesDao(db));
});
tearDown(() => db.close());
Future<dynamic> insertAt(DateTime receivedAt) => repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: _body,
receivedAt: receivedAt,
);
test('повторная доставка в пределах окна схлопывается в одну запись',
() async {
final t = DateTime(2026, 6, 10, 12);
final first = await insertAt(t);
final redelivered = await insertAt(t); // ребут/повторный дрейн: то же postTime
final jittered = await insertAt(t.add(const Duration(seconds: 30)));
expect(redelivered.id, first.id);
expect(jittered.id, first.id);
expect((await repo.recentByUser(_userId, DateTime(2026))).length, 1);
});
test('тот же текст вне окна — новое сообщение (регулярный платёж)',
() async {
final t = DateTime(2026, 6, 10, 12);
final today = await insertAt(t);
final justOutside = await insertAt(
t.add(kDedupWindow + const Duration(seconds: 1)),
);
final tomorrow = await insertAt(t.add(const Duration(days: 1)));
expect(justOutside.id, isNot(today.id));
expect(tomorrow.id, isNot(today.id));
expect((await repo.recentByUser(_userId, DateTime(2026))).length, 3);
});
test('findByDedupHash учитывает окно по receivedAt', () async {
final t = DateTime(2026, 6, 10, 12);
await insertAt(t);
final hash = computeDedupHash(_bank, _body);
expect(await repo.findByDedupHash(_userId, hash, t), isNotNull);
expect(
await repo.findByDedupHash(
_userId, hash, t.add(const Duration(hours: 5))),
isNull,
);
});
}
@@ -63,7 +63,6 @@ void main() {
);
expect(s.merchant, 100);
expect(s.category, 100);
expect(s.otherFieldsMin, 100);
});
test('short merchant name is capped at 30', () {
@@ -1,124 +1,211 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/confidence_scorer.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
ParseRule _rule() => ParseRule(
ParseRule _rule({TransactionType? txType = TransactionType.expense}) =>
ParseRule(
id: 'r1',
userId: 'u1',
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'PYATEROCHKA',
txType: txType,
categoryId: 'c1',
createdAt: DateTime(2026, 1, 1),
);
const _strongScores = FieldScores(
amount: 100,
account: 100,
type: 100,
merchant: 100,
category: 100,
RawMessage _message({String body = 'PYATEROCHKA Покупка 1 240 ₽'}) =>
RawMessage(
id: 'm1',
userId: 'u1',
packageName: 'com.bank',
body: body,
receivedAt: DateTime(2026, 1, 2),
dedupHash: 'h1',
status: RawMessageStatus.pending,
createdAt: DateTime(2026, 1, 2),
);
ParseDraft _draft({
int amount = 124000,
TransactionType type = TransactionType.expense,
String currency = 'RUB',
String? accountId = 'a1',
}) =>
ParseDraft(
rawMessageId: 'm1',
type: type,
amount: amount,
currency: currency,
merchantRaw: 'PYATEROCHKA',
accountId: accountId,
source: ParseSource.ai,
);
const _trusted = AccountResolution(
accountId: 'a1',
score: 100,
trusted: true,
source: AccountSource.bindingCard,
);
const _untrusted = AccountResolution(
accountId: 'a1',
score: 45,
trusted: false,
source: AccountSource.ambiguous,
);
void main() {
group('decide', () {
test('rule + sanity + scores ≥ strictness + trusted → autoApply', () {
final d = decide(
test('rule + amount in body + trusted account → autoApply', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: true,
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(d, GateDecision.autoApply);
expect(r.decision, GateDecision.autoApply);
expect(r.failed, isEmpty);
});
test('no rule → inbox', () {
final d = decide(
test('toggle off → inbox without evaluating checks', () {
final r = decide(
autoApplyEnabled: false,
merchantRule: _rule(),
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, isEmpty);
});
test('no rule → inbox with ruleMatched failed', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: null,
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: true,
draft: _draft(),
message: _message(),
resolution: _trusted,
);
expect(d, GateDecision.inbox);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.ruleMatched});
});
test('scores below strictness → inbox', () {
final d = decide(
test('amount not found in body → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
sanityPassed: true,
scores: const FieldScores(
amount: 45,
account: 100,
type: 100,
merchant: 100,
category: 100,
),
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: true,
draft: _draft(amount: 99900),
message: _message(),
resolution: _trusted,
);
expect(d, GateDecision.inbox);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.amountVerifiedInBody});
});
test('empty currency → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
draft: _draft(currency: ''),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.currencyKnown});
});
test('draft type differs from rule txType → inbox', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(txType: TransactionType.expense),
draft: _draft(type: TransactionType.income),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.typeMatchesRule});
});
test('legacy rule without txType → type check skipped, autoApply', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(txType: null),
draft: _draft(type: TransactionType.income),
message: _message(),
resolution: _trusted,
);
expect(r.decision, GateDecision.autoApply);
});
test('no account → inbox even with rule', () {
final d = decide(
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: false,
accountTrusted: false,
draft: _draft(accountId: null),
message: _message(),
resolution: const AccountResolution(
accountId: null,
score: 15,
trusted: false,
source: AccountSource.none,
),
);
expect(r.decision, GateDecision.inbox);
expect(
r.failed,
{AutoApplyCheck.accountResolved, AutoApplyCheck.accountTrusted},
);
expect(d, GateDecision.inbox);
});
test('untrusted account (ambiguous multi-binding) → inbox', () {
final d = decide(
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: false,
draft: _draft(),
message: _message(),
resolution: _untrusted,
);
expect(d, GateDecision.inbox);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.accountTrusted});
});
test('huge amount → inbox even with rule', () {
final d = decide(
const amount = 100001 * 100;
final r = decide(
autoApplyEnabled: true,
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 100001 * 100,
accountResolved: true,
accountTrusted: true,
draft: _draft(amount: amount),
message: _message(body: 'PYATEROCHKA Покупка 100 001 ₽'),
resolution: _trusted,
);
expect(d, GateDecision.inbox);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {AutoApplyCheck.amountUnderCap});
});
test('sanity failed → inbox', () {
final d = decide(
merchantRule: _rule(),
sanityPassed: false,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: true,
test('collects all failed checks, not just the first', () {
final r = decide(
autoApplyEnabled: true,
merchantRule: null,
draft: _draft(amount: 99900, currency: ''),
message: _message(),
resolution: _untrusted,
);
expect(d, GateDecision.inbox);
expect(r.decision, GateDecision.inbox);
expect(r.failed, {
AutoApplyCheck.ruleMatched,
AutoApplyCheck.amountVerifiedInBody,
AutoApplyCheck.currencyKnown,
AutoApplyCheck.accountTrusted,
});
});
});
}