Notification parsing: - Drop the account_bindings table/DAO/repo/entity/controller/screen; account resolution now goes senderToAccount rule -> source_apps.defaultAccountId (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration. - Add defaultAccountId to source_apps; per-app settings consolidated into source_app_detail_screen (/settings/parsing/apps/:pkg). - Inbox auto-learns an app default on first Confirm/CreateRule; account picker on the card instead of a disabled button; parse_error_labels extracted. Analytics: - Replace placeholder screen with fl_chart cards (chart_card, chart_theme, month_stepper, month_math domain helper); slim down habit_analysis_screen. Android: add launcher icon (adaptive foreground + colors.xml) and app_name. Tests: migration_v2, analytics (screen/month_math), AI retry, inbox visibility; update resolver/gate/inbox suites for the new resolution path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
59 KiB
Парсинг push-уведомлений банков → транзакции
⚠️ Устарело в части счетов (2026-07): таблица
account_bindings(карта/телефон → счёт) удалена в schemaVersion 2. Счёт разрешается лестницей: правилоsenderToAccount→source_apps.defaultAccountId(авто-обучается первым Confirm в Inbox) → глобальный дефолт (не trusted → Inbox). См.data/parser/account_resolver.dartи CLAUDE.md.
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback.
Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз. При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
1. Цели и принципы
- Один тап на мерчанта. Незнакомый мерчант попадает в Inbox с готовым предложением «Создать правило «Пятёрочка → Продукты»». Один тап — и мерчант больше не спрашивается. Есть альтернативы «Подтвердить разово» (без правила) и «Игнорировать».
- Правило обучается с первого раза. Не ждём второй встречи — мэппинг становится правилом сразу при подтверждении. Дальше этот мерчант идёт в ленту молча.
- Тихий ассистент после обучения. Как только у мерчанта есть правило — транзакция появляется в ленте Home без диалогов. Inbox/бэдж возникают только для новых, ещё не подтверждённых мерчантов или когда слабы прочие поля (сумма/счёт/тип).
- Никаких процентов в UI. Пользователю показываем не «confidence 87%», а подсветку слабых полей («?»). Confidence используется внутри (gate по прочим полям) и в экране «Точность».
- Правила видимы и редактируемы. Полный список правил — в Settings; каждое можно править, выключить, удалить.
- Минимальные изменения схемы. Переиспользуем существующее поле
transactions.noteпод мерчанта;extraInfoстановится полем пользовательского комментария. - MVP-ориентированно. Без iOS, без cloud-sync правил, без сложных универсальных банк-шаблонов.
2. Источники данных
| Источник | Статус |
|---|---|
| Notification Listener Service (push от банковских приложений) | ✅ единственный источник в MVP |
| SMS (READ_SMS) | ❌ отложено |
| Share Extension | ❌ отложено |
Notification listener фильтрует входящие по packageName — встроенный allow-list популярных банковских приложений (Tinkoff, Sber, Alfa, VTB, Ozon Bank, Pochta Bank, Raiffeisen, …) + возможность добавлять свои пакеты в Settings.
Из уведомления извлекаем: packageName, title, body, receivedAt.
⚠️ Технический риск-фундамент. Надёжность всей фичи держится на
NotificationListenerService: OEM-киллеры (Xiaomi/Huawei/Samsung) убивают сервис, доступ слетает после ребута, нет хорошо поддерживаемого Flutter-плагина. Это требует platform channel + persistent/foreground service + перехватBOOT_COMPLETED. Перед Phase 1 — отдельный технический спайк (см. § 17, Phase 0).
3. Архитектура
Слои по существующей конвенции (presentation → application → domain ← data).
lib/src/features/notification_parsing/
domain/
entities/
raw_message.dart
parse_draft.dart
parse_rule.dart
rule_candidate.dart
rule_suggestion.dart # предложение для Inbox (merchant canonical + категория)
account_binding.dart
bank_template.dart
repositories/
raw_messages_repository.dart
parse_rules_repository.dart
rule_candidates_repository.dart
account_bindings_repository.dart
data/
drift/
tables/raw_messages_table.dart
tables/parse_rules_table.dart
tables/rule_candidates_table.dart
tables/account_bindings_table.dart
tables/transfer_pairing_blocklist_table.dart
daos/raw_messages_dao.dart
daos/parse_rules_dao.dart
daos/rule_candidates_dao.dart
daos/account_bindings_dao.dart
bank_templates/
bank_templates_catalog.dart # built-in regex шаблоны
parser/
regex_parser.dart # этап 1 pipeline
ai_parser.dart # этап 2 pipeline (OpenRouter)
rule_lookup.dart # есть ли подтверждённое правило для мерчанта
rule_suggester.dart # формирует предложение правила для Inbox (из candidates/AI)
confidence_scorer.dart # детерминированные правила (прочие поля + калибровка)
transfer_pairing.dart
notification/
notification_listener_service.dart # platform channel → Android
openrouter/
openrouter_client.dart
application/
notification_parsing_controller.dart # @riverpod, оркестрация pipeline
inbox_controller.dart
rules_controller.dart
presentation/
screens/
inbox_screen.dart
rules_list_screen.dart
rule_editor_screen.dart
parsing_settings_screen.dart
widgets/
inbox_card.dart # карточка с «Создать правило / Подтвердить разово / Игнорировать»
rule_card.dart
pair_suggestion_card.dart
confidence_badge.dart # «?» подсветка для слабых полей
4. Модель данных
4.1 Изменения в существующих таблицах
Файл: lib/src/core/database/tables/transactions_table.dart.
transactions:
note→ переименовать вmerchant(string, nullable). UI продолжает показывать его в той же позиции, где раньше показывался note.extraInfo→ роль «пользовательский комментарий». Поле уже существует (string, nullable, max 500). Отдельную колонку под комментарий не заводим — переиспользуемextraInfo. В UI редактора лейбл меняем на «Комментарий».rawMessageId(string, nullable) — FK наraw_messages. Источник, из которого появилась транзакция. Null для ручных.autoApplied(bool, default false) — отличает auto-applied (правило сработало молча) от подтверждённых вручную.appliedByRuleId(string, nullable) — FK наparse_rules. Какое правило применило транзакцию (для калибровки и кнопки «правило сработало неправильно»).
Confidence НЕ хранится в
transactions. 5 per-field оценок живут наraw_messages(см. § 4.2), чтобы не раздувать горячую таблицу полями, нужными меньшинству строк. Этого достаточно для калибровки: она происходит при правке транзакции, когдаraw_messageещё доступен поrawMessageId.Поля
feeнет. Принято допущение: при переводе приход равен расходу (см. § 10.3). Комиссию отдельным полем не моделируем.
4.2 Новые таблицы
raw_messages — сырое входящее сообщение, идемпотентность, переобработка и хранение confidence.
| Поле | Тип |
|---|---|
| id | String (uuid) |
| userId | String FK |
| packageName | String |
| title | String? |
| body | String |
| receivedAt | DateTime |
| dedupHash | String — hash(packageName, body), для дедупликации (см. § 15) |
| status | Enum(pending, parsing, parsed, parsed_partial, pending_ai, inbox, applied, ignored, failed) |
| parseAttemptCount | int |
| lastParseError | String? |
| draftJson | String? — кешированный draft + предложение правила на случай переоткрытия Inbox |
| confidenceAmount | int? (0..100) |
| confidenceAccount | int? (0..100) |
| confidenceType | int? (0..100) |
| confidenceMerchant | int? (0..100) |
| confidenceCategory | int? (0..100) |
| transactionId | String? FK — куда привязано (если applied) |
| createdAt | DateTime |
parse_rules — все типы пользовательских правил. Создаются только явным действием пользователя (кнопка «Создать правило» в Inbox или редактор). Активны сразу при создании.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| kind | Enum(merchantToCategory, senderToAccount, ignore) |
| matchMode | Enum(contains, exact, regex) |
| pattern | String |
| priority | int (0 — обычный, можно поднять вручную) |
| matchCount | int — сколько раз правило применилось |
| weight | int — счётчик доверия для конфликтов/калибровки и отката (см. § 9.1). НЕ порог активации |
| lastMatchAt | DateTime? |
| Поля действия (nullable, зависят от kind): | |
| merchantCanonical | String? |
| categoryId | String? FK |
| accountId | String? FK |
| enabled | bool default true |
| createdAt | DateTime |
rule_candidates — источник предложений для Inbox. Накапливает наблюдённые связки rawValue → resolvedValue, чтобы при встрече незнакомого мерчанта подставить лучший canonical и наиболее вероятную категорию в кнопку «Создать правило». Промоушена «по второй встрече больше нет — правило рождается только явным тапом пользователя. Кандидаты лишь улучшают качество предложения и переживают перезапуск процесса (мобильный lifecycle).
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| kind | Enum(как в parse_rules) |
| rawValue | String — что встретили (merchant_raw / packageName+last4) |
| resolvedValue | String — наиболее вероятное разрешение (categoryId / accountId / merchantCanonical) |
| seenCount | int — сколько раз наблюдали этот rawValue |
| firstSeenAt | DateTime |
| lastSeenAt | DateTime |
Обновляется при каждом парсинге (наблюдение) и при «Подтвердить разово» (пользователь подтвердил транзакцию, но правило не создал — усиливаем гипотезу для будущего предложения). При создании правила соответствующий кандидат удаляется.
account_bindings — частный случай правил для счетов, отдельной таблицей для скорости лукапа.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| packageName | String? |
| bankKey | String? — нормализованный ключ банка из шаблонов |
| cardLast4 | String? |
| phone | String? |
| accountId | String FK |
| matchCount | int |
| createdAt | DateTime |
Уникальный индекс по (userId, packageName, cardLast4) для разрешения коллизий.
transfer_pairing_blocklist — пары, которые пользователь явно «разъединил» (см. § 10.4), чтобы не склеивать впредь.
| Поле | Тип |
|---|---|
| id | String |
| userId | String FK |
| signature | String — нормализованная сигнатура пары (accountFrom+accountTo+amount-bucket или packageName-пара) |
| createdAt | DateTime |
bank_templates — built-in регулярки. Хранятся в коде, не в БД (см. § 6). В БД попадают только пользовательские шаблоны (Advanced settings) — для них при необходимости заводится отдельная таблица в этой же фиче.
4.3 Schema migration
schemaVersion сейчас 4 — поднимаем на следующее значение (конкретный номер не важен; ниже шаги для блока if (from < N) в onUpgrade в lib/src/core/database/app_database.dart):
ALTER TABLE transactions RENAME COLUMN note TO merchantALTER TABLE transactions ADD COLUMN raw_message_id TEXTALTER TABLE transactions ADD COLUMN auto_applied INTEGER NOT NULL DEFAULT 0ALTER TABLE transactions ADD COLUMN applied_by_rule_id TEXTCREATE TABLE raw_messages …(включая 5 confidence-колонок)CREATE TABLE parse_rules …CREATE TABLE rule_candidates …CREATE TABLE account_bindings …CREATE TABLE transfer_pairing_blocklist …
extraInfoуже существует — миграция его не трогает, меняется только лейбл в UI. Confidence-полей иfeeвtransactionsне добавляем.
5. Pipeline парсинга
[Android] Notification posted
│
▼
NotificationListenerService (Dart) ────► raw_messages (status=pending)
│
▼
ParsingWorker (Riverpod stream over raw_messages.pending)
│
▼
1. regex_parser.parse(body)
├ template matched ──► structured draft (no AI cost)
└ no template ──► continue
│
▼
2. ai_parser.parse(body) # OpenRouter
├ network OK ──► AI draft
└ offline ──► raw_messages.status = pending_ai (retry by worker)
│
▼
3. account_bindings.resolve(draft) → accountId
│
▼
4. rule_lookup.find(merchant_raw) # ← ГЛАВНЫЙ gate
├ найдено активное правило ──► merchant + category из правила (known merchant)
└ не найдено ──► rule_suggester.suggest(draft) → предложение «merchant → category»
│
▼
5. transfer_pairing.tryPair(draft) → возможно перевод
│
▼
6. confidence_scorer.score(draft) → 5 per-field scores → пишем в raw_messages
│
▼
7. sanity_checks.cap(scores)
│
▼
8. Decision gate (см. § 8.7):
├ правило найдено AND sanity OK AND min(amount,account,type) ≥ порог строгости
│ ──► auto-apply (молча в ленту), transactions.appliedByRuleId = rule.id
└ иначе ──► Inbox (с предзаполненным предложением правила; подсветка слабых полей)
Worker — Riverpod-stream notifier, слушает raw_messages.watchPending(), обрабатывает по одному. Идемпотентен: можно перезапускать парсинг сообщения, статус возвращается в pending, draft и confidence перезаписываются.
Суть нового gate. Решение «молча в ленту или в Inbox» определяется наличием подтверждённого правила для мерчанта, а НЕ порогом confidence по merchant/category. Confidence по прочим полям (сумма/счёт/тип) и sanity-checks могут отправить даже знакомого мерчанта в Inbox — но это страховка от мусора, а не основной механизм.
6. Bank templates (regex first)
Не привязываемся к конкретным банкам — пишем универсальные паттерны под распространённые форматы. Шаблоны хранятся в bank_templates_catalog.dart как список:
const BankTemplate(
key: 'generic_purchase_ru',
pattern: r'(?:Покупка|Оплата|Списание)\s+(\d+[\.,]?\d*)\s*(?:₽|руб|RUB).*?(?:Карта|карты)\s*\*?(\d{4})',
extract: {
'amount': r'\$1',
'cardLast4': r'\$2',
},
type: TxType.expense,
);
Стартовый набор покрывает:
- покупка/списание с карты с явной суммой и last4
- зачисление/перевод поступление
- перевод по СБП (получатель — телефон или ФИО)
- комиссия
- возврат
Если ни один паттерн не совпал → этап 2 (AI fallback). Это и есть «regex first»: для большинства SMS известных банков ИИ вообще не зовётся.
Шаблоны и ключевые слова сейчас RU-only (форматы РФ-банков). Локализация шаблонов — за рамками MVP, помечено как задел.
Свои шаблоны. Settings → Advanced → «Шаблоны парсинга» — список с тумблерами «вкл/выкл» + кнопка «+ Свой шаблон». Пользователь может выключить неработающий шаблон или добавить свой.
7. AI fallback (OpenRouter)
Вызов делается только если regex не справился. Запрос:
POST https://openrouter.ai/api/v1/chat/completions
{
"model": <user's pick or default>,
"messages": [
{ "role": "system", "content": "<schema-instruction>" },
{ "role": "user", "content": "<body>" }
],
"response_format": { "type": "json_schema", "json_schema": {...} }
}
Структура ответа (строгий JSON Schema):
{
"type": "expense | income | transfer | ignored",
"amount": 1240.00,
"currency": "RUB",
"cardLast4": "1234",
"merchantRaw": "WBSPB*MOSCOW",
"counterpartyName": null,
"counterpartyPhone": null,
"dateTime": "2026-05-28T18:32:00+03:00",
"kind": "purchase | refund | transfer_out | transfer_in | fee | balance | other",
"categorySuggestion": "Продукты"
}
categorySuggestionИИ заполняет «по очевидности» (Пятёрочка → Продукты). Это только подсказка для предложения правила в Inbox; она не применяется автоматически. Пользователь видит её в кнопке «Создать правило» и может поменять категорию перед сохранением.
ИИ не просит confidence — мы его не используем. Уверенность по прочим полям считаем сами в § 8.
Не все модели OpenRouter поддерживают
response_format: json_schema. Дешёвые модели часто его игнорируют. Поэтомуai_parser:
- передаёт схему через
response_format, и дублирует требование структуры в system-промпте;- при разборе ответа делает tolerant-parse (вытаскивает JSON из произвольного текста), валидирует по схеме;
- при невалидном ответе →
status = parsed_partial(в Inbox с сырым текстом), не падает.
Конфигурация в Settings:
- API key (flutter_secure_storage)
- Default model — выбор из списка (примеры дешёвых на момент написания: Gemini Flash, Haiku, GPT-4o mini; список подтягиваем динамически из OpenRouter)
- Daily token budget (опционально, default — нет лимита, в Advanced)
- Статус: последний успешный вызов / последняя ошибка
Privacy-consent (обязательно). Тела банковских уведомлений уходят на сторонние модели (Google/OpenAI/…). До первого AI-вызова — явный экран согласия в onboarding (§ 12.6): «Текст уведомлений будет отправляться выбранной AI-модели для распознавания. Regex-режим работает без отправки данных наружу». Кнопка «Только regex» отключает AI полностью.
Offline / API недоступен: raw_message.status = pending_ai. Worker ретраит при появлении сети (используем connectivity_plus для слушателя). После 5 неудач — status = failed, попадает в Inbox с сырым текстом и кнопкой «попробовать снова».
8. Confidence scoring
Confidence теперь играет вспомогательную роль: оно НЕ решает судьбу мерчанта/категории (это решает наличие правила, § 5). Оно нужно для:
- gate по прочим полям — сумма/счёт/тип (даже знакомый мерчант идёт в Inbox, если эти поля сомнительны);
- подсветки «?» на слабых полях в Inbox;
- калибровки (экран «Точность», § 8.8).
Считаем 5 независимых per-field оценок (int 0..100), сохраняем в raw_messages. Везде шкала 0..100.
8.1 Amount
| Условие | Score |
|---|---|
| regex template выдал единственную сумму | 100 |
| regex template, но в SMS ещё цифры — взяли первую после ключевого слова | 85 |
| regex template, несколько сумм одинакового веса | 70 |
| AI вытащил, в SMS есть однозначное число | 60 |
| AI вытащил, число в SMS неоднозначно | 40 |
| AI вытащил, в SMS вообще нет такой цифры (галлюцинация) | 10 |
8.2 Account
| Условие | Score |
|---|---|
binding (packageName, cardLast4) → accountId совпал |
100 |
binding (bankKey, cardLast4) совпал |
90 |
| packageName совпал, у юзера один счёт для этого банка | 75 |
| packageName совпал, несколько счетов, выбран по эвристике (последний использованный) | 45 |
| binding не нашёлся | 15 |
8.3 Type
| Условие | Score |
|---|---|
| bank template явно указал тип | 100 |
| AI выдал тип, в SMS однозначные ключевые слова | 90 |
| AI выдал тип по знаку суммы / контексту | 70 |
| AI угадал | 45 |
| Transfer pairing завершён успешно с high-conf обеих сторон | 95 |
8.4 Merchant (для подсветки и калибровки, НЕ для gate)
| Условие | Score |
|---|---|
Сработало активное parse_rule (merchantToCategory) |
100 |
merchant_raw встречался ≥3 раза в истории (кандидат с seenCount≥3) |
80 |
merchant_raw встречался 1–2 раза |
60 |
| Новый, прошёл sanity-check | 40 |
| Новый, не прошёл sanity-check | 15 |
8.5 Category (для подсветки и калибровки, НЕ для gate)
| Условие | Score |
|---|---|
parse_rule явно мэппит merchant → category |
100 |
| Кандидат: тот же merchant_raw → та же category, наблюдалось ≥3 раза | 85 |
| Наблюдалось 1–2 раза | 65 |
| Новый merchant, AI назвал «очевидную» категорию (Пятёрочка → Продукты) | 45 |
| Новый merchant + короткое имя + AI угадал | 25 |
И жёсткое правило: category ≤ merchant. Категория не может быть увереннее, чем мерчант.
8.6 Sanity checks (capping rules)
Выполняются после base-scoring, обнуляют уверенность независимо от того, что сказал ИИ. Если sanity-check валит сумму/счёт/тип знакомого мерчанта ниже порога строгости — он уходит в Inbox, несмотря на наличие правила:
if len(merchant_raw) < 5: cap merchant ≤ 30
if /^[\d\s\*\+\-]+$/.match(merchant_raw): cap merchant ≤ 20
if amount.currency == null: cap amount ≤ 40
if amount > 100 × median user spend (90d): cap amount ≤ 50
if body.length < 20: cap all ≤ 50
if body contains "баланс" but not transaction
keywords: → status=ignored
8.7 Gate
Главный решатель — наличие правила. Confidence гейтит только прочие поля.
final rule = ruleLookup.find(draft.merchantRaw); // null если незнакомый мерчант
final otherFields = [amount, account, type].reduce(min); // int 0..100
final strictness = settings.autoApplyStrictness; // 75 / 85 / 95
if (rule != null && sanityPassed && otherFields >= strictness) {
autoApply(appliedByRuleId: rule.id); // молча в ленту
} else if (rule != null) {
toInbox(prefillRule: rule, weakFields: ...); // знакомый мерчант, но поля слабы
} else {
toInbox(suggestion: ruleSuggester.suggest(draft)); // незнакомый мерчант → предложить правило
}
Порог строгости (Скорость авто-добавления в Settings, § 12.5) применяется к сумме/счёту/типу, а не к мерчанту: мягко 75 / нормально 85 / строго 95.
8.8 Калибровка
Каждое исправление auto-applied транзакции логируется (источник — raw_messages по rawMessageId, confidence уже там; правило — по appliedByRuleId):
applied_score_min, field_corrected, merchant_raw, applied_by_rule_id, was_correct_per_field
В Settings → «Точность» (см. F5) показываем:
АВТО ПРИНЯТО ЗА 30 ДНЕЙ: 229
Из них 14 исправлены — точность 94%
ПО УВЕРЕННОСТИ (прочие поля):
95–100: N транзакций, X% ошибок ✓
85–95: N транзакций, X% ошибок ⚠
ГДЕ ОШИБАЕТСЯ: Мерчант · Категория · Счёт · Сумма · Тип
Если в диапазоне 85–95 ошибок > 10% — автоматически поднимаем порог строгости до 90 (баннер «Строгость повышена из-за неточностей»). Это влияет только на gate прочих полей, не на правила.
9. Правила (rules)
9.1 Подтверждение в один тап (явное обучение)
Правило рождается только действием пользователя в Inbox (или вручную в редакторе). Никакого авто-промоушена по числу встреч.
В Inbox для незнакомого мерчанта показываем 3 действия:
«Создать правило «merchant → category»» (primary, с карандашом для правки)
→ создаём transaction
→ создаём parse_rule(merchant_raw → merchant, category[, accountId]), enabled=true, weight=1
→ удаляем соответствующий rule_candidate
→ ВСЕ последующие сообщения этого мерчанта auto-apply молча
«Подтвердить разово»
→ создаём только transaction (rawMessageId связан)
→ правило НЕ создаём
→ upsert rule_candidate(merchant_raw → category), seenCount++ ← улучшит будущее предложение
→ следующее сообщение этого мерчанта снова придёт в Inbox
«Игнорировать»
→ raw_message.status = ignored
→ предлагаем «Создать правило-исключение «…» → пропускать» (kind=ignore)
Наблюдение (для качества предложений). На каждом парсинге, ещё до показа в Inbox, rule_suggester обновляет rule_candidates: нормализует merchant_raw → canonical, копит seenCount, запоминает наиболее частую категорию от ИИ. Так предложение «Создать правило» с каждой встречей становится точнее, но само по себе правилом не становится.
Lifecycle weight (теперь — счётчик доверия, не порог активации):
- Правило активно с момента создания (
enabled=true),weightна активацию не влияет. weightрастёт при успешных применениях (для разрешения конфликтов § 9.4 и для калибровки).- При откате («правило сработало неправильно», § 9.5)
weight -= 1; приweight <= 0или явном выключении правилоenabled=false(спит, не применяется, но хранится для истории) — пользователь может удалить.
9.2 Экран Settings → Правила парсинга
Список с фильтр-чипами: [Все] [Мерчанты] [Счета] [Игнор].
Карточка правила:
🛒 WBSPB ⚙ regex
→ Wildberries · Покупки
23 совпадения · вчера
Свайп влево — выключить, тап — редактор.
9.3 Редактор правила (progressive disclosure)
Один экран для всех matchMode. По умолчанию contains. Открывается как при создании из Inbox (по карандашу), так и при редактировании существующего.
Если SMS содержит [_____________]
○ Содержит ○ Точно ○ Regex ← переключатель
То это:
Мерчант [ Wildberries ▾ ]
Категория [ Покупки · 🛒 ▾ ]
Счёт [ — не менять — ▾ ]
▼ Дополнительно
Приоритет: [обычный / +1 / +2]
Только от: [packageName ▾ ]
── Совпадает с (последние 30):
✓ −1240₽ WBSPB*MOSCOW 18 мая
✓ −890₽ WBSPB SPB 12 мая
…
Live-превью: подгружаем raw_messages за последние 30 дней и показываем матчи. Снижает риск сломанной regex.
9.4 Приоритет при конфликте
Если на одно сообщение подошли несколько правил:
- Более специфичное (длиннее
pattern). - С большим
matchCount. - С более высоким
priority(если пользователь поднял).
Никакого «merge actions» — побеждает одно правило целиком.
9.5 Откат правила из транзакции
В деталях транзакции, если она applied правилом (appliedByRuleId != null): кнопка «Это правило сработало неправильно» → weight -= 1 (см. § 9.1), enabled=false при weight ≤ 0.
10. Переводы между своими счетами
Модель — одна запись transaction(type=transfer, transferToAccountId=...) (вариант из CLAUDE.md).
Состояние
month_summaryуже корректно. В features/home/presentation/month_summary.dart переводы для конкретного счёта уже учитываются (исходящий счётexpense += amount, входящийincome += amount).case transfer: break;остался только в ветке «Все счета» — и это намеренно: внутренние перемещения не должны влиять на общий итог. Поэтому отдельной правки агрегации не требуется (пункт «What's left #5» из CLAUDE.md фактически закрыт). От фичи переводов нужно лишь:
- Pairing → одна запись. При склейке двух SMS создаём одну транзакцию
type=transfer+ линкуем обаraw_messages.transactionIdк ней.
10.1 Алгоритм детектирования
Когда парсер выдал draft expense или income И счёт распознан как мой:
1. Если в SMS извлечён получатель/отправитель И binding указывает на мой счёт:
→ это перевод с явным указанием контрагента
→ ищем зеркало (incoming с тем же amount) за ±10 минут
2. Иначе — fallback по эвристике:
→ ищем противоположный draft за ±10 минут
→ оба счёта — мои (account_bindings → accountId)
3. Found?
явное указание → склейка, transaction(type=transfer)
только эвристика → обе строки в Inbox с плашкой «Объединить?»
not found → см. § 10.1a про задержку
Суммы. Принято допущение: при переводе приход равен расходу (комиссию отдельно не моделируем, поля fee нет). Поэтому совпадение ищем по точному равенству amount; tolerance не нужен.
Окно (±10 мин) — константа в transfer_pairing.dart, можно вынести в Advanced.
10.1a Задержка только при подозрении на перевод
Чтобы не ломать «мгновенность» ленты, задержку на ожидание pairing включаем только при подозрении на перевод:
draft = expense/income, счёт — мой
если ЕСТЬ признак перевода (binding контрагента / извлечён получатель-свой-счёт):
→ ждём окно pairing (±10 мин), retry, потом завершаем как expense/income (gate по § 8.7)
иначе (обычная покупка/оплата, контрагент — внешний):
→ НЕ ждём, gate решает сразу
Итог: обычный расход проходит через gate мгновенно (правило → лента / нет правила → Inbox); задержку платит только то, что реально похоже на внутренний перевод.
10.2 Manual transfer + SMS
Когда пользователь создал перевод в app, и через минуту приходит SMS от банка:
При обработке нового raw_message:
- Распарсили draft с amount/account.
- Проверяем
transactions WHERE userId=... AND amount = draft.amount AND createdAt > now()-10min AND rawMessageId IS NULL. - Нашли → линкуем
raw_message.transactionId, не создаём новую запись. - Не нашли — обычный поток.
10.3 Расхождение сумм
Не моделируется. Принято допущение: приход = расход (см. § 10.1). Поля fee нет; если в реальности суммы разойдутся (комиссия) — точное совпадение не сработает, и обе стороны просто останутся отдельными записями (expense + income), что приемлемо для MVP.
10.4 «Разъединить»
В деталях transfer-транзакции — список двух raw_messages + кнопка «Разъединить». Создаёт две независимые транзакции expense + income, и пишет сигнатуру пары в transfer_pairing_blocklist, чтобы не склеивать впредь.
11. Первый запуск (нет глобального cold start)
Глобального strict-режима на 7 дней / 30 подтверждений больше нет. Гейт теперь естественно per-merchant: пока у мерчанта нет правила — он идёт в Inbox; как только пользователь подтвердил правило — мерчант идёт в ленту молча.
Поэтому первые дни Inbox естественно наполнен (правил ещё нет), и по мере того как пользователь подтверждает мерчантов в один тап, доля авто-добавлений растёт сама. Никакого отдельного «таймера обучения» не нужно.
Что остаётся:
- Onboarding-подсказка (не блокирующая): при первом запуске фичи показываем notice «Подтвердите каждый магазин один раз — дальше всё попадёт в ленту автоматически».
- Settings → toggle «Распознавать уведомления» (вкл/выкл всей фичи).
- Строка «Cold start завершён …» из ранних макетов удаляется (или заменяется на счётчик «правил создано: N»).
12. UI экраны
12.1 Бэдж на Home
┌──────────────────────────────┐
│ Май 2026 ✉3 ⚙ │ ← ✉ появляется при count > 0
└──────────────────────────────┘
Тап на ✉ → InboxScreen. Источник count — inboxControllerProvider, считает raw_messages WHERE status='inbox'.
12.2 Inbox screen («Из уведомлений»)
Карточки с тремя действиями. Подзаголовок экрана: «Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически».
┌────────────────────────────────────────┐
│ Из уведомлений (2) ⋮ │
├────────────────────────────────────────┤
│ Пятёрочка −1 240 ₽ │
│ Продукты · Карта │
│ ▾ Покупка 1240 ₽ · Пятёрочка · *3456 │
│ ┌────────────────────────────────────┐ │
│ │ Создать правило «Пятёрочка→Продукты»│✎│ ← primary, карандаш = открыть редактор
│ └────────────────────────────────────┘ │
│ ✓ Подтвердить разово Игнорировать │
├────────────────────────────────────────┤
│ WBSPB −3 500 ₽ │
│ ? Покупки │ ← «?» = слабое прочее поле (сумма/счёт/тип)
│ ▾ ECMSIT45 13:01 WBSPB*MOSCOW 3500 RUB │
│ ┌────────────────────────────────────┐ │
│ │ Создать правило «WBSPB → Покупки» │✎│
│ └────────────────────────────────────┘ │
│ ✓ Подтвердить разово Игнорировать │
├────────────────────────────────────────┤
│ Не транзакция │
│ ▾ Доставлен заказ по карте *7788 … │
│ ┌────────────────────────────────────┐ │
│ │ Создать правило-исключение │ │
│ │ «Доставлен…» → пропускать │ │
│ └────────────────────────────────────┘ │
├────────────────────────────────────────┤
│ ╭ Похоже на перевод ──────────────╮ │
│ │ −10000 Тинькофф / +10000 Сбер │ │
│ │ [Объединить] [Нет] │ │
│ ╰──────────────────────────────────╯ │
├────────────────────────────────────────┤
│ [Учесть все транзакции] [Скрыть разово]│
└────────────────────────────────────────┘
- «Создать правило» — primary-действие (§ 9.1). Карандаш ✎ открывает редактор (§ 9.3) для правки merchant/категории/счёта перед сохранением.
- «Подтвердить разово» — создаёт транзакцию без правила.
- «Игнорировать» —
status=ignored, опционально правило-исключение. - «Учесть все транзакции» — массово «подтвердить разово» все распознанные карточки.
- «Скрыть разово» — убрать из Inbox, не создавая транзакций (остаются в архиве raw_messages).
- Подсветка «?» читает confidence прочих полей из
raw_messages.
«⋮» в AppBar → «Показать игнорированные», «Архив raw_messages».
12.3 Rules list screen
┌──────────────────────────────────┐
│ ← Правила парсинга + ⋮ │
├──────────────────────────────────┤
│ [Все 14] [Мерчанты 9] [Счета 4] │
│ [Игнор 1] │
├──────────────────────────────────┤
│ 🛒 WBSPB │
│ → Wildberries · Покупки │
│ 23 совпадения · вчера │
├──────────────────────────────────┤
│ 💳 Тинькофф · *1234 │
│ → счёт «Тинькофф Black» │
│ 108 · сегодня │
└──────────────────────────────────┘
12.4 Rule editor screen
Как в § 9.3.
12.5 Parsing settings screen
Парсинг уведомлений
─────────────────────────
[●] Распознавать уведомления ──○
[ ] Доступ к уведомлениям Разрешено
[ ] OpenRouter API key ••••••••
[ ] Default model Gemini Flash ▾
[ ] Скорость авто-добавления Нормально ▾ ← порог по сумме/счёту/типу
[ ] Отправка наружу Разрешена
▼ Дополнительно
Шаблоны парсинга (12 включено) →
Точность →
Token usage today: 0 / unlimited
Сегодня: 23 распознано · 18 авто · 5 в Inbox
Правил создано: 14
Слайдер «Скорость авто-добавления» (Мягко/Нормально/Строго) задаёт порог строгости прочих полей (§ 8.7), не мерчанта. Строка «Cold start» удалена (§ 11).
12.6 Onboarding для фичи
Один экран:
- Объяснение «что это» + «подтвердите каждый магазин один раз»
- Кнопка «Разрешить доступ к уведомлениям» (→ системный диалог
BIND_NOTIFICATION_LISTENER_SERVICE) - Privacy-consent для AI + поле «OpenRouter API key» (с кнопкой «Пропустить — только regex»). Явный текст: данные уведомлений уходят выбранной модели.
- Notice «Пока у магазина нет правила — транзакция ждёт подтверждения в Inbox»
Можно открыть из главных Settings или поверх Home при первом запуске после обновления.
13. Permissions
BIND_NOTIFICATION_LISTENER_SERVICE— основная (через системные настройки, не runtime).POST_NOTIFICATIONS— не нужна (системных уведомлений не шлём, только бэдж в приложении).INTERNET— обычная.FOREGROUND_SERVICE— если придётся держать listener живым (зависит от реализации, см. § 15).
Если доступ к notification listener отозван — Settings показывает баннер «Доступ потерян, парсинг отключён» с кнопкой «открыть настройки Android».
14. Privacy и стоимость
- API key хранится в
flutter_secure_storage. Не пишется в логи. - Явное согласие на отправку данных в AI до первого вызова (§ 7, § 12.6). Без согласия — regex-only.
- Regex first → большая часть SMS вообще не уходит наружу.
- В Settings → Advanced → «Token usage» показываем расход за день/месяц (приходит в response
usageот OpenRouter). - Опциональный daily-limit. При превышении — fallback в regex-only до конца дня.
- Список «отправленных в ИИ сообщений» (по requestId) можно показать в Advanced для аудита.
15. Краевые случаи и риски
| Кейс | Решение |
|---|---|
| Уведомление обновляется (банк меняет «обрабатывается» → «выполнено») | Использовать Notification.tag / id; обновлять существующий raw_message, не создавать новый |
| Дубль уведомлений (push приходит и снова при перезагрузке) | Идемпотентность: dedupHash = hash(packageName, body) (точный хэш, без времени) + проверка по временно́му окну запросом: при вставке ищем raw_messages WHERE dedupHash=? AND receivedAt within ±N сек; найдено → upsert, иначе insert |
| Notification listener убит Android (низкий приоритет) | Foreground service или WorkManager с периодической проверкой; ловим перезапуск через BOOT_COMPLETED (см. Phase 0 спайк) |
| Push без полного текста («У вас новая операция») | Status parsed_partial → Inbox с пометкой «недостаточно данных», пользователь дополняет вручную |
| Push не от банка, но packageName в allowlist (баланс / маркетинг) | Sanity-check на ключевые слова; промо-тексты → status=ignored; пользователь может закрепить правилом-исключением |
| Маркетинг / рекламные пуши банка | Authoring: правила kind=ignore из Inbox в один тап. Без авто-обучения — пользователь подтверждает исключение явно |
| Несколько активных юзеров (multi-user) | Все таблицы scope'нуты по userId. Listener привязан к active user (из app_preferences.active_user_id) |
| Очень крупная сумма (защита от галлюцинации ИИ) | Sanity check § 8.6 + дополнительно: amount > 100k ₽ → всегда Inbox независимо от правила |
16. Изменения существующего кода
features/home/presentation/screens/home_screen.dart— добавить badge✉Nв AppBar; провайдерinboxPendingCountProvider.features/home/presentation/month_summary.dart— правок по агрегации переводов не требуется (см. § 10); трогаем только если меняется модель.features/transactions/— полеnote→merchantв форме и UI; лейблextraInfo→ «Комментарий»; в деталях — кнопка «правило сработало неправильно» приappliedByRuleId != null;MoneyTextостаётся как есть.core/database/tables/transactions_table.dart— переименование колонкиnote→merchant+ новые поляraw_message_id,auto_applied,applied_by_rule_id.core/database/app_database.dart— поднятьschemaVersion, добавить блок миграции (§ 4.3).app/router/app_routes.dart+app_router.dart— новые routes:/inbox,/settings/parsing,/settings/parsing/rules,/settings/parsing/rules/:id.features/profile/— добавить вход в «Настройки парсинга».l10n/app_*.arb— все строки фичи (Inbox, правила, settings).
17. Этапы (MVP → расширение)
Phase 0 — Технический спайк (до всего остального).
- Notification listener на Android: platform channel, persistent/foreground service, поведение после ребута и под OEM-киллерами. Проверить на реальных устройствах. Это самый рискованный кусок — без него фича не работает.
Phase 1 — Capture + Inbox + правила в один тап (ядро флоу, без ИИ).
- Notification listener + raw_messages (идемпотентность по dedupHash)
- Bank templates (regex) — минимальный набор
rule_lookup+parse_rules+rule_candidates(для предложений) +rule_suggester- Inbox screen: карточки с «Создать правило / Подтвердить разово / Игнорировать», live-предложение
- Decision gate по наличию правила; confidence прочих полей + sanity-checks (подсветка «?»)
- Rules list/editor screens
- account_bindings
- Schema migration (note→merchant, extraInfo как комментарий, новые поля/таблицы)
Правила перенесены в Phase 1 — это теперь ядро механизма (gate), а не надстройка.
Phase 2 — AI fallback.
- OpenRouter client + API key + privacy-consent onboarding
- ai_parser в pipeline (с tolerant-parse fallback для моделей без json_schema) +
categorySuggestion - Token usage UI
- Offline queue + retry
Phase 3 — Transfers.
- transfer_pairing (точное совпадение сумм, задержка только при подозрении)
- «Объединить» plate в Inbox
- «Разъединить» в деталях + transfer_pairing_blocklist
Phase 4 — Калибровка.
- Логирование исправлений (по
appliedByRuleId) - Экран «Точность» (F5)
- Авто-подстройка порога строгости прочих полей
18. Открытые вопросы (отложены)
- iOS-поддержка — out of scope MVP.
- Cloud-sync правил между устройствами — после введения backend / sync layer.
- Конвертация валют в переводах (RUB → USD account) — пока выводим в Inbox с двумя суммами вручную.
- Комиссия при переводе — сейчас допущение «приход = расход»; моделирование разницы отложено (поля
feeнет). - Локализация regex-шаблонов — сейчас RU-only.
- Multi-account heuristics для СБП между своими счетами — telephone-based binding покрывает основной кейс.
- Глубокая интеграция с категориями (auto-creation новых) — пока используем существующие категории пользователя; «новая категория» = ручное действие в редакторе.
- Удалить демо-транзакции из
UserSeeder— уже значится в CLAUDE.md, но связано с этой фичей: после внедрения парсинга seed-демки больше не нужен.
19. Решения, зафиксированные в этой спеке
| Развилка | Решение |
|---|---|
| Источник данных | Notification Listener (Android) |
| Главный gate auto-apply | Наличие подтверждённого правила для мерчанта (не порог confidence) |
| Обучение правил | Явное, в один тап на мерчанта в Inbox. Авто-промоушена по 2-й встрече нет |
Роль rule_candidates |
Источник предложений в Inbox (canonical merchant + категория); промоушен только явный |
| Cold start | Глобального strict-режима нет — гейт естественно per-merchant |
| Роль confidence | Gate прочих полей (сумма/счёт/тип), подсветка «?», калибровка |
| Хранение перевода | Одна запись transferToAccountId |
| Сущность мерчанта | Строка в transactions.merchant (rename из note), без отдельной таблицы |
| Пользовательский комментарий | Переиспользуем существующее transactions.extraInfo (лейбл «Комментарий») |
| Хранение confidence | На raw_messages (5 int-колонок), НЕ в transactions |
| Комиссия при переводе | Не моделируем; допущение «приход = расход», поля fee нет |
| Pairing-задержка auto-apply | Ждём окно только при подозрении на перевод; обычный расход — сразу через gate |
| Privacy | Regex first, AI fallback только когда regex не справился + явный consent |
| Offline | Очередь + auto-retry при восстановлении сети |
| Бэдж/пуши | Только бэдж в приложении, никаких системных push |
| Bank templates | Универсальные паттерны, не привязаны к конкретным банкам |
| Строгость | Слайдер влияет на прочие поля, не на мерчанта |