Files
OnBudget/docs/notification_parsing.md
T
SandersandClaude Opus 4.8 4f99b80169 Replace account_bindings with per-app default account; build analytics charts
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>
2026-07-18 00:09:12 +03:00

59 KiB
Raw Blame History

Парсинг push-уведомлений банков → транзакции

⚠️ Устарело в части счетов (2026-07): таблица account_bindings (карта/телефон → счёт) удалена в schemaVersion 2. Счёт разрешается лестницей: правило senderToAccountsource_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):

  1. ALTER TABLE transactions RENAME COLUMN note TO merchant
  2. ALTER TABLE transactions ADD COLUMN raw_message_id TEXT
  3. ALTER TABLE transactions ADD COLUMN auto_applied INTEGER NOT NULL DEFAULT 0
  4. ALTER TABLE transactions ADD COLUMN applied_by_rule_id TEXT
  5. CREATE TABLE raw_messages … (включая 5 confidence-колонок)
  6. CREATE TABLE parse_rules …
  7. CREATE TABLE rule_candidates …
  8. CREATE TABLE account_bindings …
  9. 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:

  1. передаёт схему через response_format, и дублирует требование структуры в system-промпте;
  2. при разборе ответа делает tolerant-parse (вытаскивает JSON из произвольного текста), валидирует по схеме;
  3. при невалидном ответе → 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). Оно нужно для:

  1. gate по прочим полям — сумма/счёт/тип (даже знакомый мерчант идёт в Inbox, если эти поля сомнительны);
  2. подсветки «?» на слабых полях в Inbox;
  3. калибровки (экран «Точность», § 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 Приоритет при конфликте

Если на одно сообщение подошли несколько правил:

  1. Более специфичное (длиннее pattern).
  2. С большим matchCount.
  3. С более высоким 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:

  1. Распарсили draft с amount/account.
  2. Проверяем transactions WHERE userId=... AND amount = draft.amount AND createdAt > now()-10min AND rawMessageId IS NULL.
  3. Нашли → линкуем raw_message.transactionId, не создаём новую запись.
  4. Не нашли — обычный поток.

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 для фичи

Один экран:

  1. Объяснение «что это» + «подтвердите каждый магазин один раз»
  2. Кнопка «Разрешить доступ к уведомлениям» (→ системный диалог BIND_NOTIFICATION_LISTENER_SERVICE)
  3. Privacy-consent для AI + поле «OpenRouter API key» (с кнопкой «Пропустить — только regex»). Явный текст: данные уведомлений уходят выбранной модели.
  4. 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/ — поле notemerchant в форме и UI; лейбл extraInfo → «Комментарий»; в деталях — кнопка «правило сработало неправильно» при appliedByRuleId != null; MoneyText остаётся как есть.
  • core/database/tables/transactions_table.dart — переименование колонки notemerchant + новые поля 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 Универсальные паттерны, не привязаны к конкретным банкам
Строгость Слайдер влияет на прочие поля, не на мерчанта