Add db for sms

This commit is contained in:
2026-05-29 13:39:09 +03:00
parent f010bc08d8
commit e90554fd7a
33 changed files with 1279 additions and 219 deletions
+216 -146
View File
@@ -1,16 +1,20 @@
# Парсинг push-уведомлений банков → транзакции
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback. Auto-apply при высокой уверенности, остальное — в Inbox.
Спецификация фичи: автоматическое создание транзакций из системных push-уведомлений банковских приложений. Платформа — Android. Парсинг — regex first + OpenRouter как fallback.
**Главный принцип флоу: пользователь подтверждает каждого мерчанта один раз.** При первой встрече с мерчантом приложение предлагает в один тап создать правило «мерчант → категория». После этого все последующие похожие сообщения этого мерчанта подтверждаются автоматически. Никакого «молчаливого» накопления — правило рождается явным действием пользователя.
---
## 1. Цели и принципы
- **Тихий ассистент.** Если всё распознано уверенно — транзакция появляется в ленте Home без диалогов. Бэдж/Inbox возникают только когда нужно внимание.
- **Никаких процентов в UI.** Пользователю показываем не «confidence 87%», а подсветку слабых полей.
- **Правила учатся молча.** Пользователь правит драфт — система создаёт/усиливает правило сама. Полный список правил доступен в Settings для контроля.
- **Один тап на мерчанта.** Незнакомый мерчант попадает в Inbox с готовым предложением «Создать правило «Пятёрочка → Продукты»». Один тап — и мерчант больше не спрашивается. Есть альтернативы «Подтвердить разово» (без правила) и «Игнорировать».
- **Правило обучается с первого раза.** Не ждём второй встречи — мэппинг становится правилом сразу при подтверждении. Дальше этот мерчант идёт в ленту молча.
- **Тихий ассистент после обучения.** Как только у мерчанта есть правило — транзакция появляется в ленте Home без диалогов. Inbox/бэдж возникают только для новых, ещё не подтверждённых мерчантов или когда слабы прочие поля (сумма/счёт/тип).
- **Никаких процентов в UI.** Пользователю показываем не «confidence 87%», а подсветку слабых полей («?»). Confidence используется внутри (gate по прочим полям) и в экране «Точность».
- **Правила видимы и редактируемы.** Полный список правил — в Settings; каждое можно править, выключить, удалить.
- **Минимальные изменения схемы.** Переиспользуем существующее поле `transactions.note` под мерчанта; `extraInfo` становится полем пользовательского комментария.
- **MVP-ориентированно.** Без iOS, без cloud-sync правил, без сложных универсальных банк-шаблонов — сначала Inbox-first строгий режим, потом расслабляемся.
- **MVP-ориентированно.** Без iOS, без cloud-sync правил, без сложных универсальных банк-шаблонов.
## 2. Источники данных
@@ -38,11 +42,13 @@ lib/src/features/notification_parsing/
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/
@@ -53,20 +59,23 @@ lib/src/features/notification_parsing/
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 шаблоны
bank_templates_catalog.dart # built-in regex шаблоны
parser/
regex_parser.dart # этап 1 pipeline
ai_parser.dart # этап 2 pipeline (OpenRouter)
confidence_scorer.dart # детерминированные правила
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
notification_listener_service.dart # platform channel → Android
openrouter/
openrouter_client.dart
application/
notification_parsing_controller.dart # @riverpod, оркестрация pipeline
notification_parsing_controller.dart # @riverpod, оркестрация pipeline
inbox_controller.dart
rules_controller.dart
presentation/
@@ -76,10 +85,10 @@ lib/src/features/notification_parsing/
rule_editor_screen.dart
parsing_settings_screen.dart
widgets/
inbox_card.dart
inbox_card.dart # карточка с «Создать правило / Подтвердить разово / Игнорировать»
rule_card.dart
pair_suggestion_card.dart
confidence_badge.dart # «?» подсветка для слабых полей
confidence_badge.dart # «?» подсветка для слабых полей
```
## 4. Модель данных
@@ -92,9 +101,10 @@ lib/src/features/notification_parsing/
- `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 от ручных.
- `autoApplied` (bool, default false) — отличает auto-applied (правило сработало молча) от подтверждённых вручную.
- `appliedByRuleId` (string, nullable) — FK на `parse_rules`. Какое правило применило транзакцию (для калибровки и кнопки «правило сработало неправильно»).
> **Confidence НЕ хранится в `transactions`.** 5 per-field оценок живут на `raw_messages` (см. § 4.2), чтобы не раздувать горячую таблицу полями, нужными меньшинству строк. Этого достаточно для калибровки: она происходит при правке драфта, когда `raw_message` ещё доступен по `rawMessageId`.
> **Confidence НЕ хранится в `transactions`.** 5 per-field оценок живут на `raw_messages` (см. § 4.2), чтобы не раздувать горячую таблицу полями, нужными меньшинству строк. Этого достаточно для калибровки: она происходит при правке транзакции, когда `raw_message` ещё доступен по `rawMessageId`.
>
> **Поля `fee` нет.** Принято допущение: при переводе приход равен расходу (см. § 10.3). Комиссию отдельным полем не моделируем.
@@ -114,7 +124,7 @@ lib/src/features/notification_parsing/
| status | Enum(`pending`, `parsing`, `parsed`, `parsed_partial`, `pending_ai`, `inbox`, `applied`, `ignored`, `failed`) |
| parseAttemptCount | int |
| lastParseError | String? |
| draftJson | String? — кешированный draft на случай переоткрытия Inbox |
| draftJson | String? — кешированный draft + предложение правила на случай переоткрытия Inbox |
| confidenceAmount | int? (0..100) |
| confidenceAccount | int? (0..100) |
| confidenceType | int? (0..100) |
@@ -123,7 +133,7 @@ lib/src/features/notification_parsing/
| transactionId | String? FK — куда привязано (если applied) |
| createdAt | DateTime |
**`parse_rules`** — все типы пользовательских правил.
**`parse_rules`** — все типы пользовательских правил. **Создаются только явным действием пользователя** (кнопка «Создать правило» в Inbox или редактор). Активны сразу при создании.
| Поле | Тип |
|---|---|
@@ -132,9 +142,9 @@ lib/src/features/notification_parsing/
| kind | Enum(`merchantToCategory`, `senderToAccount`, `ignore`) |
| matchMode | Enum(`contains`, `exact`, `regex`) |
| pattern | String |
| priority | int (0 — авто, можно поднять вручную) |
| weight | int — счётчик «насколько правило проверено» (см. § 9.1 lifecycle) |
| matchCount | int |
| priority | int (0 — обычный, можно поднять вручную) |
| matchCount | int — сколько раз правило применилось |
| weight | int — счётчик доверия для конфликтов/калибровки и отката (см. § 9.1). НЕ порог активации |
| lastMatchAt | DateTime? |
| **Поля действия** (nullable, зависят от kind): | |
| merchantCanonical | String? |
@@ -143,7 +153,7 @@ lib/src/features/notification_parsing/
| enabled | bool default true |
| createdAt | DateTime |
**`rule_candidates`** — связки, встреченные один раз; при второй такой же связке превращаются в `parse_rule`. Отдельная таблица (не in-memory), т.к. на мобильном lifecycle процесс часто убивают.
**`rule_candidates`** — **источник предложений для Inbox.** Накапливает наблюдённые связки `rawValue → resolvedValue`, чтобы при встрече незнакомого мерчанта подставить лучший canonical и наиболее вероятную категорию в кнопку «Создать правило». **Промоушена «по второй встрече больше нет** — правило рождается только явным тапом пользователя. Кандидаты лишь улучшают качество предложения и переживают перезапуск процесса (мобильный lifecycle).
| Поле | Тип |
|---|---|
@@ -151,11 +161,13 @@ lib/src/features/notification_parsing/
| userId | String FK |
| kind | Enum(как в parse_rules) |
| rawValue | String — что встретили (merchant_raw / packageName+last4) |
| resolvedValue | String — во что разрешили (categoryId / accountId / merchantCanonical) |
| seenCount | int |
| resolvedValue | String — наиболее вероятное разрешение (categoryId / accountId / merchantCanonical) |
| seenCount | int — сколько раз наблюдали этот rawValue |
| firstSeenAt | DateTime |
| lastSeenAt | DateTime |
> Обновляется при каждом парсинге (наблюдение) и при «Подтвердить разово» (пользователь подтвердил транзакцию, но правило не создал — усиливаем гипотезу для будущего предложения). При создании правила соответствующий кандидат удаляется.
**`account_bindings`** — частный случай правил для счетов, отдельной таблицей для скорости лукапа.
| Поле | Тип |
@@ -190,11 +202,12 @@ lib/src/features/notification_parsing/
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. `CREATE TABLE raw_messages …` (включая 5 confidence-колонок)
5. `CREATE TABLE parse_rules …`
6. `CREATE TABLE rule_candidates …`
7. `CREATE TABLE account_bindings …`
8. `CREATE TABLE transfer_pairing_blocklist …`
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` не добавляем.
@@ -223,7 +236,9 @@ ParsingWorker (Riverpod stream over raw_messages.pending)
3. account_bindings.resolve(draft) → accountId
4. parse_rules.apply(draft) → merchant, category
4. rule_lookup.find(merchant_raw) # ← ГЛАВНЫЙ gate
├ найдено активное правило ──► merchant + category из правила (known merchant)
└ не найдено ──► rule_suggester.suggest(draft) → предложение «merchant → category»
5. transfer_pairing.tryPair(draft) → возможно перевод
@@ -236,13 +251,15 @@ ParsingWorker (Riverpod stream over raw_messages.pending)
8. Decision gate (см. § 8.7):
min(scores) ≥ 85 AND grace period passed ──► auto-apply
├ 60 ≤ min < 85 ──► Inbox (highlight weak fields)
min < 60 ──► Inbox (review all)
правило найдено 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` как список:
@@ -300,11 +317,14 @@ POST https://openrouter.ai/api/v1/chat/completions
"counterpartyName": null,
"counterpartyPhone": null,
"dateTime": "2026-05-28T18:32:00+03:00",
"kind": "purchase | refund | transfer_out | transfer_in | fee | balance | other"
"kind": "purchase | refund | transfer_out | transfer_in | fee | balance | other",
"categorySuggestion": "Продукты"
}
```
ИИ **не** просит confidence — мы его не используем. Уверенность считаем сами в § 8.
> `categorySuggestion` ИИ заполняет «по очевидности» (Пятёрочка → Продукты). Это **только подсказка** для предложения правила в Inbox; она не применяется автоматически. Пользователь видит её в кнопке «Создать правило» и может поменять категорию перед сохранением.
ИИ **не** просит confidence — мы его не используем. Уверенность по прочим полям считаем сами в § 8.
> **Не все модели OpenRouter поддерживают `response_format: json_schema`.** Дешёвые модели часто его игнорируют. Поэтому `ai_parser`:
> 1. передаёт схему через `response_format`, **и** дублирует требование структуры в system-промпте;
@@ -323,7 +343,12 @@ POST https://openrouter.ai/api/v1/chat/completions
## 8. Confidence scoring
Считаем 5 независимых per-field оценок (int 0..100), сохраняем в `raw_messages`. Gate решает по `min`. Везде шкала **0..100** (не доли).
Confidence теперь играет **вспомогательную роль**: оно НЕ решает судьбу мерчанта/категории (это решает наличие правила, § 5). Оно нужно для:
1. **gate по прочим полям** — сумма/счёт/тип (даже знакомый мерчант идёт в Inbox, если эти поля сомнительны);
2. **подсветки «?»** на слабых полях в Inbox;
3. **калибровки** (экран «Точность», § 8.8).
Считаем 5 независимых per-field оценок (int 0..100), сохраняем в `raw_messages`. Везде шкала **0..100**.
### 8.1 Amount
@@ -356,24 +381,23 @@ POST https://openrouter.ai/api/v1/chat/completions
| AI угадал | 45 |
| Transfer pairing завершён успешно с high-conf обеих сторон | 95 |
### 8.4 Merchant
### 8.4 Merchant (для подсветки и калибровки, НЕ для gate)
| Условие | Score |
|---|---|
| Сработало `parse_rule` (merchantToCategory) с weight≥3 | 100 |
| `merchant_raw` совпал с правилом ровно (weight=2) | 85 |
| `merchant_raw` встречался ≥3 раза в истории | 80 |
| Сработало активное `parse_rule` (merchantToCategory) | 100 |
| `merchant_raw` встречался ≥3 раза в истории (кандидат с seenCount≥3) | 80 |
| `merchant_raw` встречался 1–2 раза | 60 |
| Новый, прошёл sanity-check | 40 |
| Новый, не прошёл sanity-check | 15 |
### 8.5 Category
### 8.5 Category (для подсветки и калибровки, НЕ для gate)
| Условие | Score |
|---|---|
| `parse_rule` явно мэппит merchant → category | 100 |
| Тот же merchant_raw → та же category, подтверждено ≥3 раза | 85 |
| Подтверждено 12 раза | 65 |
| Кандидат: тот же merchant_raw → та же category, наблюдалось ≥3 раза | 85 |
| Наблюдалось 12 раза | 65 |
| Новый merchant, AI назвал «очевидную» категорию (Пятёрочка → Продукты) | 45 |
| Новый merchant + короткое имя + AI угадал | 25 |
@@ -381,7 +405,7 @@ POST https://openrouter.ai/api/v1/chat/completions
### 8.6 Sanity checks (capping rules)
Выполняются после base-scoring, **обнуляют** уверенность независимо от того, что сказал ИИ:
Выполняются после base-scoring, **обнуляют** уверенность независимо от того, что сказал ИИ. Если sanity-check валит сумму/счёт/тип знакомого мерчанта ниже порога строгости — он уходит в Inbox, несмотря на наличие правила:
```
if len(merchant_raw) < 5: cap merchant ≤ 30
@@ -391,65 +415,83 @@ 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
if merchant is new AND category was guessed: cap category ≤ 50
```
### 8.7 Gate
Главный решатель — наличие правила. Confidence гейтит только прочие поля.
```dart
final score = [amount, account, type, merchant, category].reduce(min); // int 0..100
final inGracePeriod = !graceCompleted; // см. § 11
if (score >= 85 && !inGracePeriod) autoApply();
else if (score >= 60) toInbox(weakFields: ...);
else toInbox(reviewAll: true);
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)); // незнакомый мерчант → предложить правило
}
```
Порог `85` — дефолт. Слайдер «строгость» в Advanced: мягко 75 / нормально 85 / строго 95.
Порог строгости (`Скорость авто-добавления` в Settings, § 12.5) применяется к **сумме/счёту/типу**, а не к мерчанту: мягко 75 / нормально 85 / строго 95.
### 8.8 Калибровка
Каждое исправление auto-applied транзакции логируется (источник — `raw_messages` по `rawMessageId`, confidence уже там):
Каждое исправление auto-applied транзакции логируется (источник — `raw_messages` по `rawMessageId`, confidence уже там; правило — по `appliedByRuleId`):
```
applied_score_min, field_corrected, merchant_raw, was_correct_per_field
applied_score_min, field_corrected, merchant_raw, applied_by_rule_id, was_correct_per_field
```
В Settings → Advanced → «Точность» показываем по диапазонам:
В Settings → «Точность» (см. F5) показываем:
```
95100: N transactions, X% ошибок ✓
8595: N transactions, X% ошибок ⚠
АВТО ПРИНЯТО ЗА 30 ДНЕЙ: 229
Из них 14 исправлены — точность 94%
ПО УВЕРЕННОСТИ (прочие поля):
95–100: N транзакций, X% ошибок ✓
85–95: N транзакций, X% ошибок ⚠
ГДЕ ОШИБАЕТСЯ: Мерчант · Категория · Счёт · Сумма · Тип
```
Если в диапазоне 85–95 ошибок > 10% — автоматически поднимаем порог auto-apply до 90 (показываем пользователю баннер «строгость повышена из-за неточностей»).
Если в диапазоне 85–95 ошибок > 10% — автоматически поднимаем порог строгости до 90 (баннер «Строгость повышена из-за неточностей»). Это влияет только на gate прочих полей, не на правила.
## 9. Правила (rules)
### 9.1 Молчаливое обучение
### 9.1 Подтверждение в один тап (явное обучение)
При каждом подтверждении/правке драфта (в Inbox или из ленты):
Правило рождается **только действием пользователя в Inbox** (или вручную в редакторе). Никакого авто-промоушена по числу встреч.
```
on user confirms draft:
for each (raw_value, resolved_value) in [(merchant_raw, merchant), (merchant, category), (packageName+last4, accountId)]:
rule = findExistingRule(raw_value)
if rule:
rule.weight += 1
rule.matchCount += 1
rule.lastMatchAt = now()
elif candidate = findCandidate(raw_value) with same resolved_value:
createRule(raw_value, resolved_value, weight=2) // вторая встреча → правило
deleteCandidate(candidate)
else:
upsertCandidate(raw_value, resolved_value) // первая встреча — кандидат в таблице rule_candidates
В 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)
```
**Lifecycle `weight` (явно):**
- `weight >= 2` — правило **активно**, применяется.
- `weight == 1` — правило **спящее** (не применяется, но хранится; например после одной правки понизилось 2→1).
- `weight <= 0` — правило **удаляется**.
**Наблюдение (для качества предложений).** На каждом парсинге, ещё до показа в Inbox, `rule_suggester` обновляет `rule_candidates`: нормализует `merchant_raw` → canonical, копит `seenCount`, запоминает наиболее частую категорию от ИИ. Так предложение «Создать правило» с каждой встречей становится точнее, но **само по себе правилом не становится**.
При исправлении (пользователь дал другой mapping) — `rule.weight -= 1`.
**Lifecycle `weight` (теперь — счётчик доверия, не порог активации):**
- Правило **активно с момента создания** (`enabled=true`), `weight` на активацию не влияет.
- `weight` растёт при успешных применениях (для разрешения конфликтов § 9.4 и для калибровки).
- При откате («правило сработало неправильно», § 9.5) `weight -= 1`; при `weight <= 0` или явном выключении правило `enabled=false` (спит, не применяется, но хранится для истории) — пользователь может удалить.
### 9.2 Экран Settings → Правила парсинга
@@ -467,7 +509,7 @@ on user confirms draft:
### 9.3 Редактор правила (progressive disclosure)
Один экран для всех `matchMode`. По умолчанию `contains`.
Один экран для всех `matchMode`. По умолчанию `contains`. Открывается как при создании из Inbox (по карандашу), так и при редактировании существующего.
```
Если SMS содержит [_____________]
@@ -479,7 +521,7 @@ on user confirms draft:
Счёт [ — не менять — ▾ ]
▼ Дополнительно
Приоритет: [auto / +1 / +2]
Приоритет: [обычный / +1 / +2]
Только от: [packageName ▾ ]
── Совпадает с (последние 30):
@@ -501,7 +543,7 @@ Live-превью: подгружаем `raw_messages` за последние 3
### 9.5 Откат правила из транзакции
В деталях транзакции, если она applied правилом: кнопка «Это правило сработало неправильно» → понижает `weight` правила (см. lifecycle § 9.1), удаляет если ≤ 0.
В деталях транзакции, если она applied правилом (`appliedByRuleId != null`): кнопка «Это правило сработало неправильно» → `weight -= 1` (см. § 9.1), `enabled=false` при `weight ≤ 0`.
## 10. Переводы между своими счетами
@@ -523,28 +565,28 @@ Live-превью: подгружаем `raw_messages` за последние 3
→ ищем противоположный draft за ±10 минут
→ оба счёта — мои (account_bindings → accountId)
3. Found?
high confidence (явное указание) → склейка, status=applied
low confidence (только эвристика) → обе строки в Inbox с плашкой «Объединить?»
not found → см. § 10.1a про auto-apply
явное указание → склейка, transaction(type=transfer)
только эвристика → обе строки в Inbox с плашкой «Объединить?»
not found → см. § 10.1a про задержку
```
**Суммы.** Принято допущение: при переводе **приход равен расходу** (комиссию отдельно не моделируем, поля `fee` нет). Поэтому совпадение ищем по **точному** равенству `amount`; tolerance не нужен.
Окно (±10 мин) — константа в `transfer_pairing.dart`, можно вынести в Advanced.
### 10.1a Auto-apply одиночного расхода (решение по задержке)
### 10.1a Задержка только при подозрении на перевод
Чтобы не ломать «мгновенность» ленты, задержку на ожидание pairing включаем **только при подозрении на перевод**:
```
draft = expense/income, счёт — мой
если ЕСТЬ признак перевода (binding контрагента / извлечён получатель-свой-счёт):
→ ждём окно pairing (±10 мин), retry, потом завершаем как expense/income
→ ждём окно pairing (±10 мин), retry, потом завершаем как expense/income (gate по § 8.7)
иначе (обычная покупка/оплата, контрагент — внешний):
→ НЕ ждём, gate решает сразу (auto-apply / Inbox)
→ НЕ ждём, gate решает сразу
```
Итог: обычный расход попадает в ленту мгновенно; задержку платит только то, что реально похоже на внутренний перевод.
Итог: обычный расход проходит через gate мгновенно (правило → лента / нет правила → Inbox); задержку платит только то, что реально похоже на внутренний перевод.
### 10.2 Manual transfer + SMS
@@ -564,15 +606,16 @@ draft = expense/income, счёт — мой
В деталях transfer-транзакции — список двух raw_messages + кнопка «Разъединить». Создаёт две независимые транзакции expense + income, и пишет сигнатуру пары в `transfer_pairing_blocklist`, чтобы не склеивать впредь.
## 11. Cold start (первые 7 дней)
## 11. Первый запуск (нет глобального cold start)
**Strict mode:** `autoApplyEnabled = false`. Всё распарсенное идёт в Inbox, пользователь подтверждает каждое.
**Глобального strict-режима на 7 дней / 30 подтверждений больше нет.** Гейт теперь естественно per-merchant: пока у мерчанта нет правила — он идёт в Inbox; как только пользователь подтвердил правило — мерчант идёт в ленту молча.
Включение auto-apply, когда выполнено **любое** из:
- прошло 7 дней с первого распарсенного сообщения, ИЛИ
- ≥ 30 подтверждённых драфтов (пользователь прошёл достаточно для обучения правил).
Поэтому первые дни Inbox естественно наполнен (правил ещё нет), и по мере того как пользователь подтверждает мерчантов в один тап, доля авто-добавлений растёт сама. Никакого отдельного «таймера обучения» не нужно.
При включении показываем баннер: «Автодобавление активировано — теперь уверенные транзакции попадают в ленту автоматически». В Settings — toggle, чтобы пользователь мог сам ускорить или, наоборот, отключить.
Что остаётся:
- **Onboarding-подсказка** (не блокирующая): при первом запуске фичи показываем notice «Подтвердите каждый магазин один раз — дальше всё попадёт в ленту автоматически».
- **Settings → toggle** «Распознавать уведомления» (вкл/выкл всей фичи).
- Строка «Cold start завершён …» из ранних макетов **удаляется** (или заменяется на счётчик «правил создано: N»).
## 12. UI экраны
@@ -586,31 +629,52 @@ draft = expense/income, счёт — мой
Тап на ✉ → InboxScreen. Источник count — `inboxControllerProvider`, считает `raw_messages WHERE status='inbox'`.
### 12.2 Inbox screen
### 12.2 Inbox screen («Из уведомлений»)
Компактные карточки. Свайп вправо — подтвердить, влево — игнорировать, тап — редактор транзакции.
Карточки с тремя действиями. Подзаголовок экрана: **«Правило обучится с первого раза: следующие похожие сообщения подтвердятся автоматически».**
```
┌──────────────────────────────┐
Inbox (3)
├──────────────────────────────┤
1 240 ₽ Пятёрочка │
│ Продукты · Тинькофф Black
18 мая, 14:32
──────────────────────────────
3 500 ₽ WBSPB │
? Покупки · Тинькофф Black │ ← «?» = score < 70 на этом поле
▾ показать SMS
├──────────────────────────────┤
╭ Похоже на перевод ────╮
│ −10000 Тинькофф │ │
│ +10000 Сбер │
│ [Объединить] [Нет] │
╰───────────────────────╯
──────────────────────────────┘
┌────────────────────────────────────────
Из уведомлений (2) ⋮ │
├────────────────────────────────────────
│ Пятёрочка 1 240 ₽
│ Продукты · Карта
▾ Покупка 1240 ₽ · Пятёрочка · *3456
│ ┌────────────────────────────────────┐ │
│ Создать правило «Пятёрочка→Продукты»│✎│ ← primary, карандаш = открыть редактор
└────────────────────────────────────┘ │
✓ Подтвердить разово Игнорировать
├────────────────────────────────────────
WBSPB 3 500 ₽
? Покупки │ ← «?» = слабое прочее поле (сумма/счёт/тип)
▾ ECMSIT45 13:01 WBSPB*MOSCOW 3500 RUB
┌────────────────────────────────────┐
│ Создать правило «WBSPB → Покупки» │✎
│ └────────────────────────────────────┘
│ ✓ Подтвердить разово Игнорировать │
├────────────────────────────────────────┤
│ Не транзакция │
│ ▾ Доставлен заказ по карте *7788 … │
│ ┌────────────────────────────────────┐ │
│ │ Создать правило-исключение │ │
│ │ «Доставлен…» → пропускать │ │
│ └────────────────────────────────────┘ │
├────────────────────────────────────────┤
│ ╭ Похоже на перевод ──────────────╮ │
│ │ −10000 Тинькофф / +10000 Сбер │ │
│ │ [Объединить] [Нет] │ │
│ ╰──────────────────────────────────╯ │
├────────────────────────────────────────┤
│ [Учесть все транзакции] [Скрыть разово]│
└────────────────────────────────────────┘
```
> Подсветка слабых полей («?») читает confidence из `raw_messages`. Базовый scoring нужен уже на этом экране — см. порядок фаз в § 17 (scoring переносим в Phase 1, а не Phase 2).
- **«Создать правило»** — primary-действие (§ 9.1). Карандаш ✎ открывает редактор (§ 9.3) для правки merchant/категории/счёта перед сохранением.
- **«Подтвердить разово»** — создаёт транзакцию без правила.
- **«Игнорировать»** — `status=ignored`, опционально правило-исключение.
- **«Учесть все транзакции»** — массово «подтвердить разово» все распознанные карточки.
- **«Скрыть разово»** — убрать из Inbox, не создавая транзакций (остаются в архиве raw_messages).
- Подсветка «?» читает confidence прочих полей из `raw_messages`.
«⋮» в AppBar → «Показать игнорированные», «Архив raw_messages».
@@ -642,26 +706,31 @@ draft = expense/income, счёт — мой
```
Парсинг уведомлений
─────────────────────────
[●] Включить ──○
[ ] Доступ к уведомлениям Разрешено
[ ] OpenRouter API key ••••••••
[ ] Default model Gemini Flash ▾
[ ] Строгость auto-apply Нормально ▾
[●] Распознавать уведомления ──○
[ ] Доступ к уведомлениям Разрешено
[ ] OpenRouter API key ••••••••
[ ] Default model Gemini Flash ▾
[ ] Скорость авто-добавления Нормально ▾ ← порог по сумме/счёту/типу
[ ] Отправка наружу Разрешена
▼ Дополнительно
Шаблоны парсинга (12)
Точность →
Шаблоны парсинга (12 включено)
Точность
Token usage today: 0 / unlimited
Cold start: завершён 23 мая
Сегодня: 23 распознано · 18 авто · 5 в Inbox
Правил создано: 14
```
> Слайдер «Скорость авто-добавления» (Мягко/Нормально/Строго) задаёт порог строгости **прочих полей** (§ 8.7), не мерчанта. Строка «Cold start» удалена (§ 11).
### 12.6 Onboarding для фичи
Один экран:
1. Объяснение «что это»
1. Объяснение «что это» + «подтвердите каждый магазин один раз»
2. Кнопка «Разрешить доступ к уведомлениям» (→ системный диалог `BIND_NOTIFICATION_LISTENER_SERVICE`)
3. **Privacy-consent для AI** + поле «OpenRouter API key» (с кнопкой «Пропустить — только regex»). Явный текст: данные уведомлений уходят выбранной модели.
4. Notice «Первые 7 дней всё попадает в Inbox»
4. Notice «Пока у магазина нет правила — транзакция ждёт подтверждения в Inbox»
Можно открыть из главных Settings или поверх Home при первом запуске после обновления.
@@ -691,17 +760,17 @@ draft = expense/income, счёт — мой
| Дубль уведомлений (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`, регулярки blocklist |
| Маркетинг / рекламные пуши банка | Authoring: правила kind=ignore. Молчаливое обучение: после 2 «игнор» от того же пакета с похожим телом — создаётся правило ignore |
| 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 независимо от score |
| Очень крупная сумма (защита от галлюцинации ИИ) | 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` → «Комментарий»; `MoneyText` остаётся как есть.
- **`core/database/tables/transactions_table.dart`** — переименование колонки `note``merchant` + новые поля `raw_message_id`, `auto_applied`.
- **`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/`** — добавить вход в «Настройки парсинга».
@@ -712,35 +781,33 @@ draft = expense/income, счёт — мой
**Phase 0 — Технический спайк (до всего остального).**
- Notification listener на Android: platform channel, persistent/foreground service, поведение после ребута и под OEM-киллерами. Проверить на реальных устройствах. Это самый рискованный кусок — без него фича не работает.
**Phase 1 — Capture & manual review (без ИИ).**
**Phase 1 — Capture + Inbox + правила в один тап (ядро флоу, без ИИ).**
- Notification listener + raw_messages (идемпотентность по dedupHash)
- Bank templates (regex) — минимальный набор
- **Confidence scoring + sanity checks** (перенесено сюда: Inbox показывает «?» по слабым полям)
- Inbox screen с свайпами
- Confirm/edit → создание transaction
- `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 2Rules.**
- parse_rules + rule_candidates + молчаливое обучение
- account_bindings
- Rules list/editor screens
- Cold start (strict 7 дней)
> Правила перенесены в Phase 1это теперь ядро механизма (gate), а не надстройка.
**Phase 3 — AI fallback.**
**Phase 2 — AI fallback.**
- OpenRouter client + API key + privacy-consent onboarding
- ai_parser в pipeline (с tolerant-parse fallback для моделей без json_schema)
- ai_parser в pipeline (с tolerant-parse fallback для моделей без json_schema) + `categorySuggestion`
- Token usage UI
- Offline queue + retry
**Phase 4 — Transfers.**
**Phase 3 — Transfers.**
- transfer_pairing (точное совпадение сумм, задержка только при подозрении)
- «Объединить» plate в Inbox
- «Разъединить» в деталях + transfer_pairing_blocklist
**Phase 5 — Калибровка.**
- Логирование исправлений
- Stats screen
- Авто-подстройка порога
**Phase 4 — Калибровка.**
- Логирование исправлений (по `appliedByRuleId`)
- Экран «Точность» (F5)
- Авто-подстройка порога строгости прочих полей
## 18. Открытые вопросы (отложены)
@@ -758,16 +825,19 @@ draft = expense/income, счёт — мой
| Развилка | Решение |
|---|---|
| Источник данных | 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 | Ждём окно только при подозрении на перевод; обычный расход — мгновенно |
| Pairing-задержка auto-apply | Ждём окно только при подозрении на перевод; обычный расход — сразу через gate |
| Privacy | Regex first, AI fallback только когда regex не справился + явный consent |
| Offline | Очередь + auto-retry при восстановлении сети |
| Бэдж/пуши | Только бэдж в приложении, никаких системных push |
| Cold start | Strict 7 дней / 30 подтверждений |
| Bank templates | Универсальные паттерны, не привязаны к конкретным банкам |
| Confidence | Per-field (0..100), gate по `min`, sanity-checks обнуляют враньё ИИ |
| Rule learning | Молчаливое + видимый список; кандидаты в таблице; weight≥2 активно, ≤0 удаляется |
| Строгость | Слайдер влияет на прочие поля, не на мерчанта |
+45 -1
View File
@@ -13,6 +13,20 @@ import 'daos/accounts_dao.dart';
import 'daos/categories_dao.dart';
import 'daos/transactions_dao.dart';
// Notification-parsing enums + converters (needed by generated app_database.g.dart):
import '../../features/notification_parsing/domain/enums.dart';
import '../../features/notification_parsing/data/drift/converters.dart';
// Notification-parsing tables & DAOs:
import '../../features/notification_parsing/data/drift/tables/raw_messages_table.dart';
import '../../features/notification_parsing/data/drift/tables/parse_rules_table.dart';
import '../../features/notification_parsing/data/drift/tables/rule_candidates_table.dart';
import '../../features/notification_parsing/data/drift/tables/account_bindings_table.dart';
import '../../features/notification_parsing/data/drift/tables/transfer_pairing_blocklist_table.dart';
import '../../features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
import '../../features/notification_parsing/data/drift/daos/parse_rules_dao.dart';
import '../../features/notification_parsing/data/drift/daos/rule_candidates_dao.dart';
import '../../features/notification_parsing/data/drift/daos/account_bindings_dao.dart';
part 'app_database.g.dart';
@DriftDatabase(
@@ -23,6 +37,12 @@ part 'app_database.g.dart';
AccountsTable,
CategoriesTable,
TransactionsTable,
// Notification-parsing:
RawMessagesTable,
ParseRulesTable,
RuleCandidatesTable,
AccountBindingsTable,
TransferPairingBlocklistTable,
],
daos: [
UsersDao,
@@ -30,6 +50,11 @@ part 'app_database.g.dart';
AccountsDao,
CategoriesDao,
TransactionsDao,
// Notification-parsing:
RawMessagesDao,
ParseRulesDao,
RuleCandidatesDao,
AccountBindingsDao,
],
)
class AppDatabase extends _$AppDatabase {
@@ -39,7 +64,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 4;
int get schemaVersion => 5;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -64,6 +89,25 @@ class AppDatabase extends _$AppDatabase {
// v3 → v4: добавлено поле is_default в accounts.
await m.addColumn(accountsTable, accountsTable.isDefault);
}
if (from < 5) {
// v4 → v5: notification-parsing schema.
//
// Transactions: note → merchant + новые поля парсинга.
await m.renameColumn(
transactionsTable, 'note', transactionsTable.merchant);
await m.addColumn(
transactionsTable, transactionsTable.rawMessageId);
await m.addColumn(
transactionsTable, transactionsTable.autoApplied);
await m.addColumn(
transactionsTable, transactionsTable.appliedByRuleId);
// Новые таблицы:
await m.createTable(rawMessagesTable);
await m.createTable(parseRulesTable);
await m.createTable(ruleCandidatesTable);
await m.createTable(accountBindingsTable);
await m.createTable(transferPairingBlocklistTable);
}
},
);
@@ -28,14 +28,27 @@ class TransactionsTable extends Table {
IntColumn get amount => integer()();
DateTimeColumn get date => dateTime()();
TextColumn get note => text().withLength(max: 255).nullable()();
/// Дополнительная информация (ссылка, номер чека и т.п.).
/// Имя мерчанта / описание операции (переименовано из note в schema v5).
TextColumn get merchant => text().withLength(max: 255).nullable()();
/// Пользовательский комментарий.
TextColumn get extraInfo => text().withLength(max: 500).nullable()();
/// Для типа transfer: целевой счёт.
TextColumn get transferToAccountId => text().nullable()();
// --- Поля notification-parsing (добавлены в schema v5) ---
/// FK на raw_messages.id — источник транзакции (null для ручных).
TextColumn get rawMessageId => text().nullable()();
/// true если транзакция применена автоматически сработавшим правилом.
BoolColumn get autoApplied => boolean().withDefault(const Constant(false))();
/// FK на parse_rules.id — какое правило создало транзакцию.
TextColumn get appliedByRuleId => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
@@ -52,7 +52,7 @@ class TxRow extends StatelessWidget {
final cat = category;
iconColor = cat != null ? colorForCategory(cat) : p.ink2;
iconData = cat != null ? iconForCategory(cat) : Icons.more_horiz;
title = tx.note ?? '';
title = tx.merchant ?? '';
subtitle = '${cat?.name ?? ''} · ${_subtitleTime(tx.date)}';
amountToShow = tx.type == TransactionType.income
? tx.amount
@@ -0,0 +1,35 @@
import 'package:drift/drift.dart';
import '../../domain/enums.dart';
class RawMessageStatusConverter extends TypeConverter<RawMessageStatus, String> {
const RawMessageStatusConverter();
@override
RawMessageStatus fromSql(String fromDb) =>
RawMessageStatus.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(RawMessageStatus value) => value.name;
}
class ParseRuleKindConverter extends TypeConverter<ParseRuleKind, String> {
const ParseRuleKindConverter();
@override
ParseRuleKind fromSql(String fromDb) =>
ParseRuleKind.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(ParseRuleKind value) => value.name;
}
class MatchModeConverter extends TypeConverter<MatchMode, String> {
const MatchModeConverter();
@override
MatchMode fromSql(String fromDb) =>
MatchMode.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(MatchMode value) => value.name;
}
@@ -0,0 +1,91 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.dart';
import '../tables/account_bindings_table.dart';
part 'account_bindings_dao.g.dart';
@DriftAccessor(tables: [AccountBindingsTable])
class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
with _$AccountBindingsDaoMixin {
AccountBindingsDao(super.db);
// ── Streams ────────────────────────────────────────────────────────────────
/// Все привязки пользователя — для экрана настроек парсинга.
Stream<List<AccountBindingsTableData>> watchByUser(String userId) =>
(select(accountBindingsTable)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch();
// ── Lookups (шаг 3 pipeline: account_bindings.resolve) ────────────────────
/// Точное совпадение по packageName + cardLast4 (приоритет score=100).
Future<AccountBindingsTableData?> findByPackageAndCard(
String userId,
String packageName,
String cardLast4,
) =>
(select(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) &
t.packageName.equals(packageName) &
t.cardLast4.equals(cardLast4))
..limit(1))
.getSingleOrNull();
/// Совпадение по bankKey + cardLast4 (score=90).
Future<AccountBindingsTableData?> findByBankKeyAndCard(
String userId,
String bankKey,
String cardLast4,
) =>
(select(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) &
t.bankKey.equals(bankKey) &
t.cardLast4.equals(cardLast4))
..limit(1))
.getSingleOrNull();
/// Совпадение по номеру телефона — для СБП-переводов.
Future<AccountBindingsTableData?> findByPhone(
String userId,
String phone,
) =>
(select(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) & t.phone.equals(phone))
..limit(1))
.getSingleOrNull();
/// Все привязки по packageName (для эвристики «один счёт банка»).
Future<List<AccountBindingsTableData>> findByPackageName(
String userId,
String packageName,
) =>
(select(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) & t.packageName.equals(packageName)))
.get();
// ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(AccountBindingsTableCompanion companion) =>
into(accountBindingsTable).insert(companion);
Future<void> updateRow(AccountBindingsTableCompanion companion) =>
(update(accountBindingsTable)
..where((t) => t.id.equals(companion.id.value)))
.write(companion);
/// Инкремент matchCount при каждом успешном матче.
Future<void> incrementMatchCount(String id) => customUpdate(
'UPDATE account_bindings SET match_count = match_count + 1 WHERE id = ?',
variables: [Variable<String>(id)],
updates: {accountBindingsTable},
);
Future<int> deleteById(String id) =>
(delete(accountBindingsTable)..where((t) => t.id.equals(id))).go();
}
@@ -0,0 +1,78 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.dart';
import '../tables/parse_rules_table.dart';
part 'parse_rules_dao.g.dart';
@DriftAccessor(tables: [ParseRulesTable])
class ParseRulesDao extends DatabaseAccessor<AppDatabase>
with _$ParseRulesDaoMixin {
ParseRulesDao(super.db);
// ── Streams ────────────────────────────────────────────────────────────────
/// Все правила пользователя — для экрана «Правила парсинга».
Stream<List<ParseRulesTableData>> watchByUser(String userId) =>
(select(parseRulesTable)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch();
// ── Lookups ────────────────────────────────────────────────────────────────
Future<List<ParseRulesTableData>> getByUser(String userId) =>
(select(parseRulesTable)..where((t) => t.userId.equals(userId))).get();
/// Только активные правила — для pipeline (rule_lookup).
Future<List<ParseRulesTableData>> getEnabledByUser(String userId) =>
(select(parseRulesTable)
..where((t) =>
t.userId.equals(userId) & t.enabled.equals(true))
..orderBy([
(t) => OrderingTerm.desc(t.priority),
(t) => OrderingTerm.desc(t.matchCount),
]))
.get();
Future<ParseRulesTableData?> findById(String id) =>
(select(parseRulesTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
// ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(ParseRulesTableCompanion companion) =>
into(parseRulesTable).insert(companion);
Future<void> updateRow(ParseRulesTableCompanion companion) =>
(update(parseRulesTable)
..where((t) => t.id.equals(companion.id.value)))
.write(companion);
Future<void> setEnabled(String id, {required bool enabled}) =>
(update(parseRulesTable)..where((t) => t.id.equals(id))).write(
ParseRulesTableCompanion(enabled: Value(enabled)),
);
/// Инкремент matchCount + обновление lastMatchAt при срабатывании правила.
Future<void> incrementMatchCount(String id, DateTime now) => customUpdate(
'UPDATE parse_rules '
'SET match_count = match_count + 1, last_match_at = ? '
'WHERE id = ?',
variables: [Variable<DateTime>(now), Variable<String>(id)],
updates: {parseRulesTable},
);
/// Декремент weight при откате («правило сработало неправильно»).
/// При weight <= 0 автоматически деактивирует правило.
Future<void> decrementWeight(String id) => customUpdate(
'UPDATE parse_rules '
'SET weight = weight - 1, '
' enabled = CASE WHEN weight - 1 <= 0 THEN 0 ELSE enabled END '
'WHERE id = ?',
variables: [Variable<String>(id)],
updates: {parseRulesTable},
);
Future<int> deleteById(String id) =>
(delete(parseRulesTable)..where((t) => t.id.equals(id))).go();
}
@@ -0,0 +1,113 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.dart';
import '../tables/raw_messages_table.dart';
import '../../../domain/enums.dart';
part 'raw_messages_dao.g.dart';
@DriftAccessor(tables: [RawMessagesTable])
class RawMessagesDao extends DatabaseAccessor<AppDatabase>
with _$RawMessagesDaoMixin {
RawMessagesDao(super.db);
// ── Streams ────────────────────────────────────────────────────────────────
/// Поток необработанных сообщений — для ParsingWorker.
Stream<List<RawMessagesTableData>> watchPending(String userId) =>
(select(rawMessagesTable)
..where((t) =>
t.userId.equals(userId) &
t.status.equalsValue(RawMessageStatus.pending))
..orderBy([(t) => OrderingTerm.asc(t.receivedAt)]))
.watch();
/// Поток сообщений, ожидающих подтверждения в Inbox.
Stream<List<RawMessagesTableData>> watchInbox(String userId) =>
(select(rawMessagesTable)
..where((t) =>
t.userId.equals(userId) &
t.status.equalsValue(RawMessageStatus.inbox))
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
.watch();
/// Реактивный счётчик для бэджа на Home.
Stream<int> watchInboxCount(String userId) {
final query = customSelect(
'SELECT COUNT(*) AS c FROM raw_messages WHERE user_id = ? AND status = ?',
variables: [Variable<String>(userId), Variable<String>('inbox')],
readsFrom: {rawMessagesTable},
);
return query
.watch()
.map((rows) => rows.isEmpty ? 0 : (rows.first.data['c'] as int? ?? 0));
}
// ── Lookups ────────────────────────────────────────────────────────────────
Future<RawMessagesTableData?> findById(String id) =>
(select(rawMessagesTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
/// Поиск по dedupHash для идемпотентной вставки.
Future<RawMessagesTableData?> findByDedupHash(
String userId, String dedupHash) =>
(select(rawMessagesTable)
..where((t) =>
t.userId.equals(userId) & t.dedupHash.equals(dedupHash))
..limit(1))
.getSingleOrNull();
// ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(RawMessagesTableCompanion companion) =>
into(rawMessagesTable).insert(companion);
Future<bool> updateRow(RawMessagesTableCompanion companion) =>
update(rawMessagesTable).replace(companion);
/// Смена статуса одного сообщения.
Future<void> updateStatus(String id, RawMessageStatus status) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
RawMessagesTableCompanion(status: Value(status)),
);
/// Запись результатов парсинга (draft + confidence-оценки).
Future<void> updateAfterParse({
required String id,
required RawMessageStatus status,
String? draftJson,
int? confidenceAmount,
int? confidenceAccount,
int? confidenceType,
int? confidenceMerchant,
int? confidenceCategory,
}) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
RawMessagesTableCompanion(
status: Value(status),
draftJson: Value(draftJson),
confidenceAmount: Value(confidenceAmount),
confidenceAccount: Value(confidenceAccount),
confidenceType: Value(confidenceType),
confidenceMerchant: Value(confidenceMerchant),
confidenceCategory: Value(confidenceCategory),
),
);
/// Привязка к созданной транзакции (auto-apply).
Future<void> linkTransaction(String id, String transactionId) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
RawMessagesTableCompanion(
transactionId: Value(transactionId),
status: const Value(RawMessageStatus.applied),
),
);
/// Инкремент счётчика попыток парсинга.
Future<void> incrementParseAttempts(String id) => customUpdate(
'UPDATE raw_messages SET parse_attempt_count = parse_attempt_count + 1 '
'WHERE id = ?',
variables: [Variable<String>(id)],
updates: {rawMessagesTable},
);
}
@@ -0,0 +1,72 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.dart';
import '../tables/rule_candidates_table.dart';
import '../../../domain/enums.dart';
part 'rule_candidates_dao.g.dart';
@DriftAccessor(tables: [RuleCandidatesTable])
class RuleCandidatesDao extends DatabaseAccessor<AppDatabase>
with _$RuleCandidatesDaoMixin {
RuleCandidatesDao(super.db);
// ── Streams ────────────────────────────────────────────────────────────────
/// Все кандидаты пользователя — rule_suggester читает для Inbox.
Stream<List<RuleCandidatesTableData>> watchByUser(String userId) =>
(select(ruleCandidatesTable)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm.desc(t.lastSeenAt)]))
.watch();
// ── Lookups ────────────────────────────────────────────────────────────────
/// Найти кандидата по ключу (userId, kind, rawValue).
Future<RuleCandidatesTableData?> findByRawValue(
String userId,
ParseRuleKind kind,
String rawValue,
) =>
(select(ruleCandidatesTable)
..where((t) =>
t.userId.equals(userId) &
t.kind.equalsValue(kind) &
t.rawValue.equals(rawValue))
..limit(1))
.getSingleOrNull();
// ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(RuleCandidatesTableCompanion companion) =>
into(ruleCandidatesTable).insert(companion);
/// Обновить seenCount, lastSeenAt и resolvedValue существующего кандидата.
Future<void> incrementSeen(
String id, {
required String resolvedValue,
required DateTime now,
}) =>
customUpdate(
'UPDATE rule_candidates '
'SET seen_count = seen_count + 1, '
' last_seen_at = ?, '
' resolved_value = ? '
'WHERE id = ?',
variables: [
Variable<DateTime>(now),
Variable<String>(resolvedValue),
Variable<String>(id),
],
updates: {ruleCandidatesTable},
);
Future<int> deleteById(String id) =>
(delete(ruleCandidatesTable)..where((t) => t.id.equals(id))).go();
/// Удаляет все кандидаты для rawValue (при создании правила).
Future<int> deleteByRawValue(String userId, String rawValue) =>
(delete(ruleCandidatesTable)
..where((t) =>
t.userId.equals(userId) & t.rawValue.equals(rawValue)))
.go();
}
@@ -0,0 +1,38 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
import '../../../../../core/database/tables/accounts_table.dart';
/// Привязка идентификатора карты / телефона к счёту в приложении.
///
/// Используется на шаге 3 pipeline для разрешения accountId.
/// Уникальный индекс по (userId, packageName, cardLast4) через [uniqueKeys].
class AccountBindingsTable extends Table {
@override
String get tableName => 'account_bindings';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
TextColumn get packageName => text().nullable()();
/// Нормализованный ключ банка из bank_templates_catalog.
TextColumn get bankKey => text().nullable()();
TextColumn get cardLast4 => text().nullable()();
TextColumn get phone => text().nullable()();
TextColumn get accountId =>
text().references(AccountsTable, #id, onDelete: KeyAction.cascade)();
IntColumn get matchCount => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
@override
List<Set<Column>> get uniqueKeys => [
{userId, packageName, cardLast4},
];
}
@@ -0,0 +1,49 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
import '../../../../../core/database/tables/accounts_table.dart';
import '../../../../../core/database/tables/categories_table.dart';
import '../converters.dart';
/// Пользовательские правила парсинга уведомлений.
///
/// Создаются только явным действием в Inbox или редакторе.
/// Активны с момента создания ([enabled] = true).
///
/// [weight] — счётчик доверия для разрешения конфликтов и отката:
/// растёт при успешных применениях, падает при ошибках.
class ParseRulesTable extends Table {
@override
String get tableName => 'parse_rules';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
/// merchantToCategory | senderToAccount | ignore
TextColumn get kind => text().map(const ParseRuleKindConverter())();
/// contains | exact | regex
TextColumn get matchMode =>
text().map(const MatchModeConverter()).withDefault(const Constant('contains'))();
TextColumn get pattern => text()();
IntColumn get priority => integer().withDefault(const Constant(0))();
IntColumn get matchCount => integer().withDefault(const Constant(0))();
IntColumn get weight => integer().withDefault(const Constant(1))();
DateTimeColumn get lastMatchAt => dateTime().nullable()();
// Action fields (заполняются в зависимости от kind):
TextColumn get merchantCanonical => text().nullable()();
TextColumn get categoryId => text()
.references(CategoriesTable, #id, onDelete: KeyAction.setNull)
.nullable()();
TextColumn get accountId => text()
.references(AccountsTable, #id, onDelete: KeyAction.setNull)
.nullable()();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@@ -0,0 +1,49 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
import '../../../../../core/database/tables/transactions_table.dart';
import '../converters.dart';
/// Сырые входящие уведомления от банковских приложений.
///
/// Точка входа pipeline парсинга. Идемпотентность обеспечивается
/// полем [dedupHash] = hash(packageName + body).
class RawMessagesTable extends Table {
@override
String get tableName => 'raw_messages';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
TextColumn get packageName => text()();
TextColumn get title => text().nullable()();
TextColumn get body => text()();
DateTimeColumn get receivedAt => dateTime()();
/// hash(packageName + body) — для дедупликации.
TextColumn get dedupHash => text()();
TextColumn get status =>
text().map(const RawMessageStatusConverter()).withDefault(const Constant('pending'))();
IntColumn get parseAttemptCount => integer().withDefault(const Constant(0))();
TextColumn get lastParseError => text().nullable()();
/// Кешированный JSON-снимок ParseDraft + RuleSuggestion (для Inbox).
TextColumn get draftJson => text().nullable()();
// Per-field confidence scores (0100):
IntColumn get confidenceAmount => integer().nullable()();
IntColumn get confidenceAccount => integer().nullable()();
IntColumn get confidenceType => integer().nullable()();
IntColumn get confidenceMerchant => integer().nullable()();
IntColumn get confidenceCategory => integer().nullable()();
/// FK на transactions.id — заполняется при статусе applied.
TextColumn get transactionId => text()
.references(TransactionsTable, #id, onDelete: KeyAction.setNull)
.nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
import '../converters.dart';
/// Накопленные наблюдения rawValue → resolvedValue.
///
/// Источник предложений для Inbox: rule_suggester читает кандидатов,
/// чтобы подставить canonical-мерчант и вероятную категорию в кнопку
/// «Создать правило». Правилом сам по себе не становится.
///
/// [seenCount] растёт при каждом парсинге и при «Подтвердить разово».
/// При создании правила соответствующий кандидат удаляется.
class RuleCandidatesTable extends Table {
@override
String get tableName => 'rule_candidates';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
/// merchantToCategory | senderToAccount | ignore
TextColumn get kind => text().map(const ParseRuleKindConverter())();
/// Что встретили: merchant_raw, packageName+last4 и т.п.
TextColumn get rawValue => text()();
/// Наиболее вероятное разрешение: categoryId / accountId / merchantCanonical.
TextColumn get resolvedValue => text()();
IntColumn get seenCount => integer().withDefault(const Constant(1))();
DateTimeColumn get firstSeenAt => dateTime()();
DateTimeColumn get lastSeenAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
@@ -0,0 +1,23 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
/// Пары переводов, которые пользователь явно «разъединил».
///
/// Хранит нормализованную сигнатуру пары (accountFrom+accountTo+amount-bucket
/// или packageName-пара), чтобы transfer_pairing не склеивал их впредь.
class TransferPairingBlocklistTable extends Table {
@override
String get tableName => 'transfer_pairing_blocklist';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
/// Нормализованная сигнатура пары переводов.
TextColumn get signature => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@@ -0,0 +1,28 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'account_binding.freezed.dart';
/// Привязка идентификатора карты/телефона к конкретному счёту в приложении.
///
/// Используется на шаге 3 pipeline для разрешения [accountId] из уведомления.
/// Уникальный индекс по (userId, packageName, cardLast4) в таблице.
///
/// Хотя бы одно из ([packageName], [bankKey], [cardLast4], [phone])
/// должно быть заполнено для осмысленного матча.
@freezed
abstract class AccountBinding with _$AccountBinding {
const factory AccountBinding({
required String id,
required String userId,
String? packageName,
/// Нормализованный ключ банка из bank_templates_catalog.
String? bankKey,
String? cardLast4,
String? phone,
required String accountId,
@Default(0) int matchCount,
required DateTime createdAt,
}) = _AccountBinding;
}
@@ -0,0 +1,30 @@
import '../../../../core/database/converters/enum_converters.dart';
/// Встроенный regex-шаблон для разбора текста уведомления.
///
/// Шаблоны хранятся в коде (bank_templates_catalog.dart), не в БД.
/// Пользовательские шаблоны (Advanced settings) хранятся в отдельной таблице,
/// но используют ту же структуру.
///
/// [extract] — именованные capture-группы: ключ = имя поля (amount, cardLast4),
/// значение = back-reference вида r'\$1'.
///
/// Шаблоны RU-only в MVP; локализация шаблонов отложена.
class BankTemplate {
const BankTemplate({
required this.key,
required this.pattern,
required this.extract,
required this.type,
this.enabled = true,
});
final String key;
final String pattern;
final Map<String, String> extract;
final TransactionType type;
final bool enabled;
@override
String toString() => 'BankTemplate($key)';
}
@@ -0,0 +1,60 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../enums.dart';
part 'parse_draft.freezed.dart';
/// Структурированный результат разбора одного уведомления.
///
/// Промежуточный объект pipeline: создаётся regex-parser или ai-parser,
/// дополняется account_bindings, rule_lookup, затем передаётся в
/// confidence_scorer и decision gate.
///
/// [amount] — минорные единицы (всегда > 0); знак определяется [type].
/// [source] — какой этап pipeline заполнил draft.
@freezed
abstract class ParseDraft with _$ParseDraft {
const factory ParseDraft({
required String rawMessageId,
/// expense / income / transfer. «ignored» кодируется через отдельный статус
/// RawMessageStatus, тип здесь — реальный финансовый смысл операции.
required TransactionType type,
/// Сумма в минорных единицах (> 0).
required int amount,
@Default('RUB') String currency,
String? cardLast4,
/// Сырое имя мерчанта / отправителя из уведомления, до нормализации.
String? merchantRaw,
String? counterpartyName,
String? counterpartyPhone,
/// Дата-время операции из текста уведомления (может быть null).
DateTime? dateTime,
/// Гранулярный вид операции (purchase, refund, fee, …).
TxKind? kind,
/// Подсказка категории от AI («Продукты»). Только для Inbox-предложения,
/// не применяется автоматически.
String? categorySuggestion,
// Resolved fields — заполняются на последующих шагах pipeline:
/// Счёт, разрешённый через account_bindings.
String? accountId,
/// Нормализованное имя мерчанта (из правила или rule_candidate).
String? merchantCanonical,
/// Категория из сработавшего правила.
String? categoryId,
/// Целевой счёт для переводов (transfer).
String? transferToAccountId,
required ParseSource source,
}) = _ParseDraft;
}
@@ -0,0 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../enums.dart';
part 'parse_rule.freezed.dart';
/// Пользовательское правило разбора уведомлений.
///
/// Создаётся только явным действием в Inbox (или редакторе) — никакого
/// авто-промоушена. Активно с момента создания ([enabled] = true).
///
/// [weight] — счётчик доверия: растёт при успешных применениях, падает
/// при откатах («правило сработало неправильно»). При [weight] <= 0
/// правило деактивируется ([enabled] = false).
///
/// Поля действия ([merchantCanonical], [categoryId], [accountId]) заполняются
/// в зависимости от [kind].
@freezed
abstract class ParseRule with _$ParseRule {
const factory ParseRule({
required String id,
required String userId,
required ParseRuleKind kind,
required MatchMode matchMode,
required String pattern,
@Default(0) int priority,
@Default(0) int matchCount,
@Default(1) int weight,
DateTime? lastMatchAt,
// Action fields (nullable, depend on kind):
String? merchantCanonical,
String? categoryId,
String? accountId,
@Default(true) bool enabled,
required DateTime createdAt,
}) = _ParseRule;
}
@@ -0,0 +1,43 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../enums.dart';
part 'raw_message.freezed.dart';
/// Сырое входящее уведомление от банковского приложения.
///
/// Точка входа в pipeline парсинга. Все последующие сущности ссылаются на
/// [id] этой записи. Confidence-оценки хранятся здесь, а не в Transaction,
/// чтобы не раздувать горячую таблицу.
///
/// [dedupHash] = hash(packageName + body) — идемпотентность при повторной
/// доставке или перезагрузке устройства.
@freezed
abstract class RawMessage with _$RawMessage {
const factory RawMessage({
required String id,
required String userId,
required String packageName,
String? title,
required String body,
required DateTime receivedAt,
required String dedupHash,
required RawMessageStatus status,
@Default(0) int parseAttemptCount,
String? lastParseError,
/// Кешированный JSON-снимок ParseDraft + RuleSuggestion.
/// Заполняется перед отправкой в Inbox, чтобы пережить пересоздание виджета.
String? draftJson,
// Per-field confidence scores (0100). Null = ещё не посчитан.
int? confidenceAmount,
int? confidenceAccount,
int? confidenceType,
int? confidenceMerchant,
int? confidenceCategory,
/// FK на transactions.id — заполняется когда статус applied.
String? transactionId,
required DateTime createdAt,
}) = _RawMessage;
}
@@ -0,0 +1,32 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../enums.dart';
part 'rule_candidate.freezed.dart';
/// Накопленное наблюдение: rawValue встречался и был разрешён в resolvedValue.
///
/// Источник предложений для Inbox: rule_suggester читает кандидата, чтобы
/// подставить лучший canonical-мерчант и наиболее вероятную категорию
/// в кнопку «Создать правило». Сам по себе кандидат правилом не является
/// и не становится без явного действия пользователя.
///
/// [seenCount] растёт при каждом парсинге и при «Подтвердить разово».
/// При создании правила соответствующий кандидат удаляется.
@freezed
abstract class RuleCandidate with _$RuleCandidate {
const factory RuleCandidate({
required String id,
required String userId,
required ParseRuleKind kind,
/// Что встретили: merchant_raw, packageName+last4, и т.п.
required String rawValue,
/// Наиболее вероятное разрешение: categoryId / accountId / merchantCanonical.
required String resolvedValue,
required int seenCount,
required DateTime firstSeenAt,
required DateTime lastSeenAt,
}) = _RuleCandidate;
}
@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'rule_suggestion.freezed.dart';
/// Предложение правила, формируемое rule_suggester для отображения в Inbox.
///
/// Не персистируется — кешируется как часть [RawMessage.draftJson].
/// Содержит canonical-имя мерчанта и предполагаемую категорию, которые
/// подставляются в кнопку «Создать правило «merchant → category»».
///
/// [categoryName] — человекочитаемое имя категории для UI (чтобы не делать
/// отдельный запрос в Inbox).
@freezed
abstract class RuleSuggestion with _$RuleSuggestion {
const factory RuleSuggestion({
required String merchantRaw,
required String merchantCanonical,
String? categoryId,
String? categoryName,
String? accountId,
}) = _RuleSuggestion;
}
@@ -0,0 +1,53 @@
/// Enums for the notification-parsing feature.
/// Drift converters are defined in the data layer; these are pure-Dart.
// ---------------------------------------------------------------------------
// RawMessageStatus
// ---------------------------------------------------------------------------
enum RawMessageStatus {
pending,
parsing,
parsed,
parsedPartial,
pendingAi,
inbox,
applied,
ignored,
failed,
}
// ---------------------------------------------------------------------------
// ParseRuleKind
// ---------------------------------------------------------------------------
enum ParseRuleKind {
merchantToCategory,
senderToAccount,
ignore,
}
// ---------------------------------------------------------------------------
// MatchMode
// ---------------------------------------------------------------------------
enum MatchMode {
contains,
exact,
regex,
}
// ---------------------------------------------------------------------------
// TxKind — granular kind extracted from notification text
// ---------------------------------------------------------------------------
enum TxKind {
purchase,
refund,
transferOut,
transferIn,
fee,
balance,
other,
}
// ---------------------------------------------------------------------------
// ParseSource — which stage of the pipeline produced the draft
// ---------------------------------------------------------------------------
enum ParseSource { regex, ai }
@@ -63,9 +63,12 @@ class TransactionsController extends _$TransactionsController {
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? merchant,
String? extraInfo,
String? transferToAccountId,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
state = const AsyncLoading();
try {
@@ -76,9 +79,12 @@ class TransactionsController extends _$TransactionsController {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
extraInfo: extraInfo,
transferToAccountId: transferToAccountId,
rawMessageId: rawMessageId,
autoApplied: autoApplied,
appliedByRuleId: appliedByRuleId,
);
state = const AsyncData(null);
return tx;
@@ -11,9 +11,12 @@ extension TransactionMapper on TransactionsTableData {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
extraInfo: extraInfo,
transferToAccountId: transferToAccountId,
rawMessageId: rawMessageId,
autoApplied: autoApplied,
appliedByRuleId: appliedByRuleId,
createdAt: createdAt,
);
}
@@ -47,9 +47,12 @@ class TransactionRepositoryImpl implements TransactionRepository {
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? merchant,
String? extraInfo,
String? transferToAccountId,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
final id = const Uuid().v4();
await _dao.insertTransaction(
@@ -61,9 +64,12 @@ class TransactionRepositoryImpl implements TransactionRepository {
type: Value(type),
amount: amount,
date: date,
note: Value(note),
merchant: Value(merchant),
extraInfo: Value(extraInfo),
transferToAccountId: Value(transferToAccountId),
rawMessageId: Value(rawMessageId),
autoApplied: Value(autoApplied),
appliedByRuleId: Value(appliedByRuleId),
),
);
final row = await _dao.findById(id);
@@ -81,9 +87,12 @@ class TransactionRepositoryImpl implements TransactionRepository {
type: Value(transaction.type),
amount: Value(transaction.amount),
date: Value(transaction.date),
note: Value(transaction.note),
merchant: Value(transaction.merchant),
extraInfo: Value(transaction.extraInfo),
transferToAccountId: Value(transaction.transferToAccountId),
rawMessageId: Value(transaction.rawMessageId),
autoApplied: Value(transaction.autoApplied),
appliedByRuleId: Value(transaction.appliedByRuleId),
createdAt: Value(transaction.createdAt),
),
);
@@ -11,6 +11,9 @@ part 'transaction.freezed.dart';
///
/// Для перевода ([TransactionType.transfer]) заполняется [transferToAccountId].
/// [categoryId] опционален: переводы обычно без категории.
///
/// [merchant] — имя мерчанта / описание операции (переименовано из note).
/// [rawMessageId] / [autoApplied] / [appliedByRuleId] — поля notification-parsing.
@freezed
abstract class Transaction with _$Transaction {
const factory Transaction({
@@ -23,11 +26,25 @@ abstract class Transaction with _$Transaction {
/// Сумма в минорных единицах (всегда > 0).
required int amount,
required DateTime date,
String? note,
/// Имя мерчанта / магазина / описание (бывш. note).
String? merchant,
/// Пользовательский комментарий (бывш. extraInfo).
String? extraInfo,
/// Целевой счёт для переводов ([TransactionType.transfer]).
String? transferToAccountId,
/// FK на raw_messages.id — источник, из которого появилась транзакция.
String? rawMessageId,
/// true если транзакция применена автоматически сработавшим правилом.
@Default(false) bool autoApplied,
/// FK на parse_rules.id — какое правило применило транзакцию.
String? appliedByRuleId,
required DateTime createdAt,
}) = _Transaction;
}
@@ -29,11 +29,14 @@ abstract interface class TransactionRepository {
/// Сумма в минорных единицах (должна быть > 0).
required int amount,
required DateTime date,
String? note,
String? merchant,
String? extraInfo,
/// Целевой счёт для [TransactionType.transfer].
String? transferToAccountId,
String? rawMessageId,
bool autoApplied,
String? appliedByRuleId,
});
Future<Transaction> update(Transaction transaction);
@@ -92,7 +92,7 @@ class _EditHydratorState extends ConsumerState<_EditHydrator> {
accountId: widget.tx.accountId,
categoryId: widget.tx.categoryId,
transferToAccountId: widget.tx.transferToAccountId,
note: widget.tx.note,
merchant: widget.tx.merchant,
extraInfo: widget.tx.extraInfo,
));
});
@@ -112,27 +112,27 @@ class _FormBody extends ConsumerStatefulWidget {
class _FormBodyState extends ConsumerState<_FormBody> {
late final TextEditingController _amountCtrl;
late final TextEditingController _noteCtrl;
late final TextEditingController _merchantCtrl;
late final TextEditingController _extraInfoCtrl;
bool _submitting = false;
String? _amountError;
bool _amountSynced = false;
bool _noteSynced = false;
bool _merchantSynced = false;
bool _extraInfoSynced = false;
@override
void initState() {
super.initState();
_amountCtrl = TextEditingController();
_noteCtrl = TextEditingController();
_merchantCtrl = TextEditingController();
_extraInfoCtrl = TextEditingController();
}
@override
void dispose() {
_amountCtrl.dispose();
_noteCtrl.dispose();
_merchantCtrl.dispose();
_extraInfoCtrl.dispose();
super.dispose();
}
@@ -145,9 +145,9 @@ class _FormBodyState extends ConsumerState<_FormBody> {
_amountCtrl.text =
frac == 0 ? '$whole' : '$whole.${frac.toString().padLeft(2, '0')}';
}
if (!_noteSynced && (draft.note?.isNotEmpty ?? false)) {
_noteSynced = true;
_noteCtrl.text = draft.note!;
if (!_merchantSynced && (draft.merchant?.isNotEmpty ?? false)) {
_merchantSynced = true;
_merchantCtrl.text = draft.merchant!;
}
if (!_extraInfoSynced && (draft.extraInfo?.isNotEmpty ?? false)) {
_extraInfoSynced = true;
@@ -198,7 +198,7 @@ class _FormBodyState extends ConsumerState<_FormBody> {
type: draft.type,
amount: draft.amountMinor,
date: draft.date,
note: draft.note?.trim().isEmpty ?? true ? null : draft.note!.trim(),
merchant: draft.merchant?.trim().isEmpty ?? true ? null : draft.merchant!.trim(),
extraInfo: draft.extraInfo?.trim().isEmpty ?? true ? null : draft.extraInfo!.trim(),
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
@@ -216,7 +216,7 @@ class _FormBodyState extends ConsumerState<_FormBody> {
type: draft.type,
amount: draft.amountMinor,
date: draft.date,
note: draft.note?.trim().isEmpty ?? true ? null : draft.note!.trim(),
merchant: draft.merchant?.trim().isEmpty ?? true ? null : draft.merchant!.trim(),
extraInfo: draft.extraInfo?.trim().isEmpty ?? true ? null : draft.extraInfo!.trim(),
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
@@ -400,8 +400,8 @@ class _FormBodyState extends ConsumerState<_FormBody> {
style: TextStyle(fontSize: 12, color: p.ink2),
),
TextField(
controller: _noteCtrl,
onChanged: (v) => draftCtrl.setNote(v),
controller: _merchantCtrl,
onChanged: (v) => draftCtrl.setMerchant(v),
maxLines: 3,
minLines: 1,
style: TextStyle(fontSize: 14, color: p.ink),
@@ -18,7 +18,7 @@ class TransactionDraft {
this.accountId,
this.categoryId,
this.transferToAccountId,
this.note,
this.merchant,
this.extraInfo,
});
@@ -28,7 +28,7 @@ class TransactionDraft {
final String? accountId;
final String? categoryId;
final String? transferToAccountId;
final String? note;
final String? merchant;
final String? extraInfo;
TransactionDraft copyWith({
@@ -38,7 +38,7 @@ class TransactionDraft {
Object? accountId = _sentinel,
Object? categoryId = _sentinel,
Object? transferToAccountId = _sentinel,
Object? note = _sentinel,
Object? merchant = _sentinel,
Object? extraInfo = _sentinel,
}) {
return TransactionDraft(
@@ -53,7 +53,7 @@ class TransactionDraft {
transferToAccountId: identical(transferToAccountId, _sentinel)
? this.transferToAccountId
: transferToAccountId as String?,
note: identical(note, _sentinel) ? this.note : note as String?,
merchant: identical(merchant, _sentinel) ? this.merchant : merchant as String?,
extraInfo: identical(extraInfo, _sentinel) ? this.extraInfo : extraInfo as String?,
);
}
@@ -75,7 +75,6 @@ class TransactionDraftController extends _$TransactionDraftController {
void setType(TransactionType type) {
final next = state.copyWith(type: type);
// При смене типа категорию сбрасываем — она привязана к типу.
state = type == TransactionType.transfer
? next.copyWith(categoryId: null)
: next.copyWith(categoryId: null);
@@ -95,7 +94,7 @@ class TransactionDraftController extends _$TransactionDraftController {
void setTransferToAccount(String? accountId) =>
state = state.copyWith(transferToAccountId: accountId);
void setNote(String? note) => state = state.copyWith(note: note);
void setMerchant(String? merchant) => state = state.copyWith(merchant: merchant);
void setExtraInfo(String? value) => state = state.copyWith(extraInfo: value);
@@ -148,7 +148,7 @@ class UserSeeder {
type: TransactionType.expense,
amount: 234000,
date: atToday(19, 42),
note: 'Лента',
merchant: 'Лента',
extraInfo: 'Чек №481523'),
_Demo(
accountId: a.card.id,
@@ -156,7 +156,7 @@ class UserSeeder {
type: TransactionType.expense,
amount: 48000,
date: atToday(9, 15),
note: 'Кофе Хауз',
merchant: 'Кофе Хауз',
extraInfo: 'Двойной эспрессо + круассан'),
_Demo(
accountId: a.card.id,
@@ -164,14 +164,14 @@ class UserSeeder {
type: TransactionType.expense,
amount: 6200,
date: atToday(8, 50),
note: 'Метро'),
merchant: 'Метро'),
_Demo(
accountId: a.cash.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 112000,
date: atYesterday(21, 8),
note: 'Перекрёсток',
merchant: 'Перекрёсток',
extraInfo: 'Чек №209847'),
_Demo(
accountId: a.card.id,
@@ -179,7 +179,7 @@ class UserSeeder {
type: TransactionType.expense,
amount: 65000,
date: atYesterday(19, 30),
note: 'Кинотеатр',
merchant: 'Кинотеатр',
extraInfo: '2 билета · зал IMAX'),
_Demo(
accountId: a.card.id,
@@ -187,14 +187,14 @@ class UserSeeder {
type: TransactionType.transfer,
amount: 200000,
date: daysAgo(2, 16, 30),
note: 'Снятие наличных'),
merchant: 'Снятие наличных'),
_Demo(
accountId: a.card.id,
categoryId: c.rent.id,
type: TransactionType.expense,
amount: 3200000,
date: daysAgo(3, 12, 0),
note: 'Аренда квартиры',
merchant: 'Аренда квартиры',
extraInfo: 'Май 2026 · ул. Ленина 12'),
_Demo(
accountId: a.card.id,
@@ -202,7 +202,7 @@ class UserSeeder {
type: TransactionType.income,
amount: 9500000,
date: daysAgo(4, 11, 0),
note: 'Зарплата',
merchant: 'Зарплата',
extraInfo: 'Аванс за апрель'),
_Demo(
accountId: a.card.id,
@@ -210,14 +210,14 @@ class UserSeeder {
type: TransactionType.expense,
amount: 34000,
date: daysAgo(4, 18, 30),
note: 'Яндекс Такси'),
merchant: 'Яндекс Такси'),
_Demo(
accountId: a.cash.id,
categoryId: c.cafe.id,
type: TransactionType.expense,
amount: 72000,
date: daysAgo(5, 14, 0),
note: 'Шоколадница',
merchant: 'Шоколадница',
extraInfo: 'Обед с коллегами'),
_Demo(
accountId: a.card.id,
@@ -225,14 +225,14 @@ class UserSeeder {
type: TransactionType.expense,
amount: 89000,
date: daysAgo(5, 9, 20),
note: 'Магнит'),
merchant: 'Магнит'),
_Demo(
accountId: a.card.id,
transferToAccountId: a.savings.id,
type: TransactionType.transfer,
amount: 500000,
date: daysAgo(7, 11, 0),
note: 'Пополнение копилки'),
merchant: 'Пополнение копилки'),
// ── Апрель ────────────────────────────────────────────────────────────
_Demo(
accountId: a.card.id,
@@ -240,7 +240,7 @@ class UserSeeder {
type: TransactionType.expense,
amount: 120000,
date: inPrevMonth(27, 20, 0),
note: 'Концерт',
merchant: 'Концерт',
extraInfo: '2 билета'),
_Demo(
accountId: a.cash.id,
@@ -248,56 +248,56 @@ class UserSeeder {
type: TransactionType.expense,
amount: 88000,
date: inPrevMonth(25, 11, 10),
note: 'Перекрёсток'),
merchant: 'Перекрёсток'),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 38000,
date: inPrevMonth(22, 19, 0),
note: 'Яндекс Такси'),
merchant: 'Яндекс Такси'),
_Demo(
accountId: a.card.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 195000,
date: inPrevMonth(19, 17, 40),
note: 'Лента'),
merchant: 'Лента'),
_Demo(
accountId: a.card.id,
transferToAccountId: a.cash.id,
type: TransactionType.transfer,
amount: 150000,
date: inPrevMonth(17, 10, 0),
note: 'Снятие наличных'),
merchant: 'Снятие наличных'),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 6200,
date: inPrevMonth(14, 8, 45),
note: 'Метро'),
merchant: 'Метро'),
_Demo(
accountId: a.cash.id,
categoryId: c.cafe.id,
type: TransactionType.expense,
amount: 55000,
date: inPrevMonth(12, 13, 30),
note: 'Шоколадница'),
merchant: 'Шоколадница'),
_Demo(
accountId: a.card.id,
transferToAccountId: a.savings.id,
type: TransactionType.transfer,
amount: 500000,
date: inPrevMonth(11, 12, 0),
note: 'Пополнение копилки'),
merchant: 'Пополнение копилки'),
_Demo(
accountId: a.card.id,
categoryId: c.salary.id,
type: TransactionType.income,
amount: 9500000,
date: inPrevMonth(10, 11, 0),
note: 'Зарплата',
merchant: 'Зарплата',
extraInfo: 'Оклад за март'),
_Demo(
accountId: a.card.id,
@@ -305,7 +305,7 @@ class UserSeeder {
type: TransactionType.expense,
amount: 3200000,
date: inPrevMonth(5, 12, 0),
note: 'Аренда квартиры',
merchant: 'Аренда квартиры',
extraInfo: 'Апрель 2026 · ул. Ленина 12'),
];
@@ -317,7 +317,7 @@ class UserSeeder {
type: d.type,
amount: d.amount,
date: d.date,
note: d.note,
merchant: d.merchant,
extraInfo: d.extraInfo,
transferToAccountId: d.transferToAccountId,
);
@@ -358,7 +358,7 @@ class _Demo {
required this.type,
required this.amount,
required this.date,
this.note,
this.merchant,
this.extraInfo,
this.transferToAccountId,
});
@@ -367,7 +367,7 @@ class _Demo {
final TransactionType type;
final int amount;
final DateTime date;
final String? note;
final String? merchant;
final String? extraInfo;
final String? transferToAccountId;
}
@@ -16,7 +16,7 @@ class _CreateCall {
required this.type,
required this.amount,
required this.date,
this.note,
this.merchant,
this.transferToAccountId,
});
final String userId;
@@ -25,7 +25,7 @@ class _CreateCall {
final TransactionType type;
final int amount;
final DateTime date;
final String? note;
final String? merchant;
final String? transferToAccountId;
}
@@ -44,9 +44,12 @@ class FakeTransactionRepository implements TransactionRepository {
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? merchant,
String? extraInfo,
String? transferToAccountId,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
createCalls.add(_CreateCall(
userId: userId,
@@ -55,7 +58,7 @@ class FakeTransactionRepository implements TransactionRepository {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
transferToAccountId: transferToAccountId,
));
if (nextCreateError != null) {
@@ -71,7 +74,7 @@ class FakeTransactionRepository implements TransactionRepository {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
transferToAccountId: transferToAccountId,
createdAt: DateTime(2024, 1, 1),
);
@@ -163,7 +166,7 @@ void main() {
type: TransactionType.income,
amount: 9000,
date: testDate,
note: 'Зарплата',
merchant: 'Зарплата',
);
expect(repo.createCalls, hasLength(1));
@@ -174,7 +177,7 @@ void main() {
expect(call.type, TransactionType.income);
expect(call.amount, 9000);
expect(call.date, testDate);
expect(call.note, 'Зарплата');
expect(call.merchant, 'Зарплата');
});
test('перевод: передаёт transferToAccountId', () async {
@@ -34,7 +34,7 @@ class _TxCall {
required this.type,
required this.amount,
required this.date,
this.note,
this.merchant,
this.transferToAccountId,
});
final String userId;
@@ -43,7 +43,7 @@ class _TxCall {
final TransactionType type;
final int amount;
final DateTime date;
final String? note;
final String? merchant;
final String? transferToAccountId;
}
@@ -61,9 +61,12 @@ class FakeTransactionsController extends TransactionsController {
required TransactionType type,
required int amount,
required DateTime date,
String? note,
String? merchant,
String? extraInfo,
String? transferToAccountId,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
calls.add(_TxCall(
userId: userId,
@@ -72,7 +75,7 @@ class FakeTransactionsController extends TransactionsController {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
transferToAccountId: transferToAccountId,
));
return Transaction(
@@ -83,7 +86,7 @@ class FakeTransactionsController extends TransactionsController {
type: type,
amount: amount,
date: date,
note: note,
merchant: merchant,
transferToAccountId: transferToAccountId,
createdAt: DateTime(2024),
);
@@ -312,13 +315,13 @@ void main() {
notifier.setAmount(1000);
notifier.setAccount('a-1');
notifier.setCategory('cat-1');
notifier.setNote(' '); // пробелы → null после trim
notifier.setMerchant(' '); // пробелы → null после trim
await tester.pump();
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(fakeCtrl.calls, hasLength(1));
expect(fakeCtrl.calls.first.note, isNull);
expect(fakeCtrl.calls.first.merchant, isNull);
});
}
@@ -64,7 +64,7 @@ void main() {
expect(tx.type, TransactionType.expense);
expect(tx.amount, 5000);
expect(tx.date, DateTime(2024, 1, 15));
expect(tx.note, isNull);
expect(tx.merchant, isNull);
expect(tx.transferToAccountId, isNull);
});
@@ -105,9 +105,9 @@ void main() {
type: TransactionType.expense,
amount: 1000,
date: DateTime(2024, 1, 1),
note: 'Ужин в ресторане',
merchant: 'Ужин в ресторане',
);
expect(tx.note, 'Ужин в ресторане');
expect(tx.merchant, 'Ужин в ресторане');
});
test('null note сохраняется как null', () async {
@@ -118,7 +118,7 @@ void main() {
amount: 500,
date: DateTime(2024, 1, 1),
);
expect(tx.note, isNull);
expect(tx.merchant, isNull);
});
test('сохранённую транзакцию можно получить через findById', () async {