Add account bind

This commit is contained in:
2026-06-01 17:51:25 +03:00
parent f1e9496865
commit 6358c3e71b
44 changed files with 2053 additions and 86 deletions
+3 -2
View File
@@ -60,7 +60,7 @@ lib/
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
core/
database/app_database.dart # @DriftDatabase, schemaVersion=6
database/app_database.dart # @DriftDatabase, schemaVersion=7
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
database/daos/ # *_dao.dart with .watch*() methods
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
@@ -105,7 +105,8 @@ Every domain table has a `userId` FK → `users`.
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, merchant(nullable, was `note`), extraInfo(nullable), transferToAccountId(nullable), obligation/impulse(habit enums, nullable), rawMessageId/autoApplied/appliedByRuleId (parsing), createdAt |
**Notification-parsing tables** (in `features/notification_parsing/data/drift/`): `raw_messages`,
`parse_rules`, `rule_candidates`, `account_bindings`, `transfer_pairing_blocklist`. Their enums live
`parse_rules`, `rule_candidates`, `account_bindings` (+`isDefault` per-app default binding),
`source_apps` (allowlist of monitored apps), `transfer_pairing_blocklist`. Their enums live
in `notification_parsing/domain/enums.dart`; converters in `.../data/drift/converters.dart` (both
imported by `app_database.dart`).
+28 -1
View File
@@ -379,5 +379,32 @@
"habitAnalysisTile": "Habit analysis",
"profileSeedDemoTile": "Fill with demo transactions",
"profileSeedDemoSuccess": "Demo transactions added"
"profileSeedDemoSuccess": "Demo transactions added",
"commonAdd": "Add",
"commonCancel": "Cancel",
"commonDelete": "Delete",
"parsingAppsTile": "Source apps",
"sourceAppsTitle": "Source apps",
"sourceAppsAddedSection": "Monitored",
"sourceAppsAvailableSection": "Available",
"sourceAppsEmpty": "No apps added yet. Pick a bank below or add one manually.",
"sourceAppsAddManual": "Add manually",
"sourceAppsPackageLabel": "Package name",
"sourceAppsNameLabel": "Display name (optional)",
"appBindingsTitle": "Account bindings",
"appBindingsEmpty": "No bindings yet. Add one to map a card to an account.",
"appBindingsAnyCard": "Any card (default)",
"appBindingsSetDefault": "Set as default for this app",
"appBindingsAddTitle": "New binding",
"appBindingsCardLabel": "Card last 4 digits",
"appBindingsCardHelper": "Leave empty to match any card",
"appBindingsDefaultLabel": "Default for this app",
"ruleKindMerchant": "Merchant → category",
"ruleKindAccount": "Sender → account",
"ruleEditorAccountPick": "Select account"
}
+132
View File
@@ -1753,6 +1753,138 @@ abstract class AppLocalizations {
/// In ru, this message translates to:
/// **'Демо транзакции добавлены'**
String get profileSeedDemoSuccess;
/// No description provided for @commonAdd.
///
/// In ru, this message translates to:
/// **'Добавить'**
String get commonAdd;
/// No description provided for @commonCancel.
///
/// In ru, this message translates to:
/// **'Отмена'**
String get commonCancel;
/// No description provided for @commonDelete.
///
/// In ru, this message translates to:
/// **'Удалить'**
String get commonDelete;
/// No description provided for @parsingAppsTile.
///
/// In ru, this message translates to:
/// **'Приложения-источники'**
String get parsingAppsTile;
/// No description provided for @sourceAppsTitle.
///
/// In ru, this message translates to:
/// **'Приложения-источники'**
String get sourceAppsTitle;
/// No description provided for @sourceAppsAddedSection.
///
/// In ru, this message translates to:
/// **'Отслеживаются'**
String get sourceAppsAddedSection;
/// No description provided for @sourceAppsAvailableSection.
///
/// In ru, this message translates to:
/// **'Доступные'**
String get sourceAppsAvailableSection;
/// No description provided for @sourceAppsEmpty.
///
/// In ru, this message translates to:
/// **'Пока нет приложений. Выберите банк ниже или добавьте вручную.'**
String get sourceAppsEmpty;
/// No description provided for @sourceAppsAddManual.
///
/// In ru, this message translates to:
/// **'Добавить вручную'**
String get sourceAppsAddManual;
/// No description provided for @sourceAppsPackageLabel.
///
/// In ru, this message translates to:
/// **'Имя пакета'**
String get sourceAppsPackageLabel;
/// No description provided for @sourceAppsNameLabel.
///
/// In ru, this message translates to:
/// **'Отображаемое имя (необязательно)'**
String get sourceAppsNameLabel;
/// No description provided for @appBindingsTitle.
///
/// In ru, this message translates to:
/// **'Привязки счетов'**
String get appBindingsTitle;
/// No description provided for @appBindingsEmpty.
///
/// In ru, this message translates to:
/// **'Пока нет привязок. Добавьте, чтобы связать карту со счётом.'**
String get appBindingsEmpty;
/// No description provided for @appBindingsAnyCard.
///
/// In ru, this message translates to:
/// **'Любая карта (по умолчанию)'**
String get appBindingsAnyCard;
/// No description provided for @appBindingsSetDefault.
///
/// In ru, this message translates to:
/// **'Сделать счётом по умолчанию для приложения'**
String get appBindingsSetDefault;
/// No description provided for @appBindingsAddTitle.
///
/// In ru, this message translates to:
/// **'Новая привязка'**
String get appBindingsAddTitle;
/// No description provided for @appBindingsCardLabel.
///
/// In ru, this message translates to:
/// **'Последние 4 цифры карты'**
String get appBindingsCardLabel;
/// No description provided for @appBindingsCardHelper.
///
/// In ru, this message translates to:
/// **'Оставьте пустым для любой карты'**
String get appBindingsCardHelper;
/// No description provided for @appBindingsDefaultLabel.
///
/// In ru, this message translates to:
/// **'По умолчанию для приложения'**
String get appBindingsDefaultLabel;
/// No description provided for @ruleKindMerchant.
///
/// In ru, this message translates to:
/// **'Мерчант → категория'**
String get ruleKindMerchant;
/// No description provided for @ruleKindAccount.
///
/// In ru, this message translates to:
/// **'Отправитель → счёт'**
String get ruleKindAccount;
/// No description provided for @ruleEditorAccountPick.
///
/// In ru, this message translates to:
/// **'Выбрать счёт'**
String get ruleEditorAccountPick;
}
class _AppLocalizationsDelegate
+68
View File
@@ -917,4 +917,72 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileSeedDemoSuccess => 'Demo transactions added';
@override
String get commonAdd => 'Add';
@override
String get commonCancel => 'Cancel';
@override
String get commonDelete => 'Delete';
@override
String get parsingAppsTile => 'Source apps';
@override
String get sourceAppsTitle => 'Source apps';
@override
String get sourceAppsAddedSection => 'Monitored';
@override
String get sourceAppsAvailableSection => 'Available';
@override
String get sourceAppsEmpty =>
'No apps added yet. Pick a bank below or add one manually.';
@override
String get sourceAppsAddManual => 'Add manually';
@override
String get sourceAppsPackageLabel => 'Package name';
@override
String get sourceAppsNameLabel => 'Display name (optional)';
@override
String get appBindingsTitle => 'Account bindings';
@override
String get appBindingsEmpty =>
'No bindings yet. Add one to map a card to an account.';
@override
String get appBindingsAnyCard => 'Any card (default)';
@override
String get appBindingsSetDefault => 'Set as default for this app';
@override
String get appBindingsAddTitle => 'New binding';
@override
String get appBindingsCardLabel => 'Card last 4 digits';
@override
String get appBindingsCardHelper => 'Leave empty to match any card';
@override
String get appBindingsDefaultLabel => 'Default for this app';
@override
String get ruleKindMerchant => 'Merchant → category';
@override
String get ruleKindAccount => 'Sender → account';
@override
String get ruleEditorAccountPick => 'Select account';
}
+69
View File
@@ -930,4 +930,73 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get profileSeedDemoSuccess => 'Демо транзакции добавлены';
@override
String get commonAdd => 'Добавить';
@override
String get commonCancel => 'Отмена';
@override
String get commonDelete => 'Удалить';
@override
String get parsingAppsTile => 'Приложения-источники';
@override
String get sourceAppsTitle => 'Приложения-источники';
@override
String get sourceAppsAddedSection => 'Отслеживаются';
@override
String get sourceAppsAvailableSection => 'Доступные';
@override
String get sourceAppsEmpty =>
'Пока нет приложений. Выберите банк ниже или добавьте вручную.';
@override
String get sourceAppsAddManual => 'Добавить вручную';
@override
String get sourceAppsPackageLabel => 'Имя пакета';
@override
String get sourceAppsNameLabel => 'Отображаемое имя (необязательно)';
@override
String get appBindingsTitle => 'Привязки счетов';
@override
String get appBindingsEmpty =>
'Пока нет привязок. Добавьте, чтобы связать карту со счётом.';
@override
String get appBindingsAnyCard => 'Любая карта (по умолчанию)';
@override
String get appBindingsSetDefault =>
'Сделать счётом по умолчанию для приложения';
@override
String get appBindingsAddTitle => 'Новая привязка';
@override
String get appBindingsCardLabel => 'Последние 4 цифры карты';
@override
String get appBindingsCardHelper => 'Оставьте пустым для любой карты';
@override
String get appBindingsDefaultLabel => 'По умолчанию для приложения';
@override
String get ruleKindMerchant => 'Мерчант → категория';
@override
String get ruleKindAccount => 'Отправитель → счёт';
@override
String get ruleEditorAccountPick => 'Выбрать счёт';
}
+28 -1
View File
@@ -379,5 +379,32 @@
"habitAnalysisTile": "Анализ привычек",
"profileSeedDemoTile": "Заполнить демо транзакциями",
"profileSeedDemoSuccess": "Демо транзакции добавлены"
"profileSeedDemoSuccess": "Демо транзакции добавлены",
"commonAdd": "Добавить",
"commonCancel": "Отмена",
"commonDelete": "Удалить",
"parsingAppsTile": "Приложения-источники",
"sourceAppsTitle": "Приложения-источники",
"sourceAppsAddedSection": "Отслеживаются",
"sourceAppsAvailableSection": "Доступные",
"sourceAppsEmpty": "Пока нет приложений. Выберите банк ниже или добавьте вручную.",
"sourceAppsAddManual": "Добавить вручную",
"sourceAppsPackageLabel": "Имя пакета",
"sourceAppsNameLabel": "Отображаемое имя (необязательно)",
"appBindingsTitle": "Привязки счетов",
"appBindingsEmpty": "Пока нет привязок. Добавьте, чтобы связать карту со счётом.",
"appBindingsAnyCard": "Любая карта (по умолчанию)",
"appBindingsSetDefault": "Сделать счётом по умолчанию для приложения",
"appBindingsAddTitle": "Новая привязка",
"appBindingsCardLabel": "Последние 4 цифры карты",
"appBindingsCardHelper": "Оставьте пустым для любой карты",
"appBindingsDefaultLabel": "По умолчанию для приложения",
"ruleKindMerchant": "Мерчант → категория",
"ruleKindAccount": "Отправитель → счёт",
"ruleEditorAccountPick": "Выбрать счёт"
}
+12
View File
@@ -11,12 +11,14 @@ import '../../features/categories/presentation/screens/categories_list_screen.da
import '../../features/categories/presentation/screens/category_form_screen.dart';
import '../../features/home/presentation/screens/home_screen.dart';
import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart';
import '../../features/notification_parsing/presentation/screens/app_bindings_screen.dart';
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart';
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart';
import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart';
import '../../features/notification_parsing/presentation/screens/source_apps_screen.dart';
import '../../features/profile/presentation/screens/profile_screen.dart';
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
import '../../features/user/application/active_user_controller.dart';
@@ -140,6 +142,16 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.parsingLog,
builder: (context, state) => const ParsingLogScreen(),
),
GoRoute(
path: AppRoutes.parsingApps,
builder: (context, state) => const SourceAppsScreen(),
),
GoRoute(
path: AppRoutes.parsingAppBindingsPattern,
builder: (context, state) => AppBindingsScreen(
packageName: Uri.decodeComponent(state.pathParameters['pkg']!),
),
),
GoRoute(
path: AppRoutes.habitAnalysis,
pageBuilder: (context, state) =>
+4
View File
@@ -31,4 +31,8 @@ class AppRoutes {
static String parsingRuleEdit(String id) => '/settings/parsing/rules/$id';
static const parsingDebugInject = '/settings/parsing/debug';
static const parsingLog = '/settings/parsing/log';
static const parsingApps = '/settings/parsing/apps';
static const parsingAppBindingsPattern = '/settings/parsing/apps/:pkg';
static String parsingAppBindings(String packageName) =>
'/settings/parsing/apps/${Uri.encodeComponent(packageName)}';
}
+11 -1
View File
@@ -21,11 +21,13 @@ import '../../features/notification_parsing/data/drift/tables/raw_messages_table
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/source_apps_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';
import '../../features/notification_parsing/data/drift/daos/source_apps_dao.dart';
part 'app_database.g.dart';
@@ -42,6 +44,7 @@ part 'app_database.g.dart';
ParseRulesTable,
RuleCandidatesTable,
AccountBindingsTable,
SourceAppsTable,
TransferPairingBlocklistTable,
],
daos: [
@@ -55,6 +58,7 @@ part 'app_database.g.dart';
ParseRulesDao,
RuleCandidatesDao,
AccountBindingsDao,
SourceAppsDao,
],
)
class AppDatabase extends _$AppDatabase {
@@ -64,7 +68,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -116,6 +120,12 @@ class AppDatabase extends _$AppDatabase {
await m.addColumn(
settingsTable, settingsTable.habitTrackingEnabled);
}
if (from < 7) {
// v6 → v7: умолчательные привязки + allowlist приложений-источников.
await m.addColumn(
accountBindingsTable, accountBindingsTable.isDefault);
await m.createTable(sourceAppsTable);
}
},
);
@@ -45,10 +45,11 @@ class MonthKpiCard extends ConsumerWidget {
),
MoneyText(
s.balanceMinor,
color: p.ink,
color: s.balanceMinor < 0 ? p.negative : p.ink,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
withSign: true,
),
],
),
@@ -0,0 +1,55 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/entities/account_binding.dart';
import 'notification_parsing_providers.dart';
part 'account_bindings_controller.g.dart';
/// Все привязки пользователя — для экрана привязок приложения.
@riverpod
Stream<List<AccountBinding>> accountBindingsList(Ref ref, String userId) =>
ref.watch(accountBindingsRepositoryProvider).watchByUser(userId);
/// CRUD над привязками «карта/телефон → счёт».
@Riverpod(keepAlive: true)
class AccountBindingsController extends _$AccountBindingsController {
@override
AsyncValue<void> build() => const AsyncData(null);
Future<void> add({
required String userId,
required String packageName,
required String accountId,
String? cardLast4,
String? phone,
bool isDefault = false,
}) async {
state = const AsyncLoading();
try {
final repo = ref.read(accountBindingsRepositoryProvider);
final binding = await repo.create(
userId: userId,
packageName: packageName,
accountId: accountId,
cardLast4: cardLast4,
phone: phone,
);
// Единственный default на пакет: проводим через setDefault (атомарно
// снимает флаг у прочих привязок), а не через create(isDefault: true).
if (isDefault) {
await repo.setDefault(binding.id, userId, packageName);
}
state = const AsyncData(null);
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
Future<void> setDefault(String id, String userId, String packageName) => ref
.read(accountBindingsRepositoryProvider)
.setDefault(id, userId, packageName);
Future<void> delete(String id) =>
ref.read(accountBindingsRepositoryProvider).deleteById(id);
}
@@ -5,10 +5,12 @@ import '../data/repositories/account_bindings_repository_impl.dart';
import '../data/repositories/parse_rules_repository_impl.dart';
import '../data/repositories/raw_messages_repository_impl.dart';
import '../data/repositories/rule_candidates_repository_impl.dart';
import '../data/repositories/source_apps_repository_impl.dart';
import '../domain/repositories/account_bindings_repository.dart';
import '../domain/repositories/parse_rules_repository.dart';
import '../domain/repositories/raw_messages_repository.dart';
import '../domain/repositories/rule_candidates_repository.dart';
import '../domain/repositories/source_apps_repository.dart';
part 'notification_parsing_providers.g.dart';
@@ -31,3 +33,12 @@ RuleCandidatesRepository ruleCandidatesRepository(Ref ref) =>
AccountBindingsRepository accountBindingsRepository(Ref ref) =>
AccountBindingsRepositoryImpl(
ref.watch(appDatabaseProvider).accountBindingsDao);
@Riverpod(keepAlive: true)
SourceAppsRepository sourceAppsRepository(Ref ref) =>
SourceAppsRepositoryImpl(ref.watch(appDatabaseProvider).sourceAppsDao);
/// Множество включённых packageName пользователя — вход allowlist-фильтра.
@riverpod
Future<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
ref.watch(sourceAppsRepositoryProvider).enabledPackages(userId);
@@ -1,5 +1,6 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../accounts/application/account_providers.dart';
import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart';
import '../../transactions/application/transactions_controller.dart';
@@ -105,6 +106,17 @@ class ParsingWorker extends _$ParsingWorker {
final settings = await ref.read(parsingSettingsControllerProvider.future);
if (!settings.enabled) return; // фича выключена — оставляем pending.
// Allowlist (§A): парсим только включённые приложения-источники. Делаем
// ДО AI, чтобы не тратить токены на посторонние пакеты.
final enabled =
await ref.read(enabledSourcePackagesProvider(userId).future);
if (!enabled.contains(msg.packageName)) {
await ref
.read(rawMessagesRepositoryProvider)
.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// Извлечение через AI (regex-этап удалён). Без AI/сети — Inbox/ignored.
await _extractViaAi(userId, msg, settings);
}
@@ -225,25 +237,35 @@ class ParsingWorker extends _$ParsingWorker {
}) async {
final repo = ref.read(rawMessagesRepositoryProvider);
// 3. Разрешение счёта.
// 4. Правила грузим раньше — нужны резолверу (senderToAccount).
final rules =
await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft0.merchantRaw) !=
null) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
// 3. Разрешение счёта (§B): привязки → senderToAccount → дефолты.
final globalDefaultAccountId = (await ref
.read(accountRepositoryProvider)
.watchDefault(userId)
.first)
?.id;
final resolver =
AccountResolver(ref.read(accountBindingsRepositoryProvider));
final resolution = await resolver.resolve(
userId: userId,
packageName: msg.packageName,
body: msg.body,
cardLast4: draft0.cardLast4,
phone: draft0.counterpartyPhone,
merchantRaw: draft0.merchantRaw,
senderRules: rules,
globalDefaultAccountId: globalDefaultAccountId,
);
var draft = draft0.copyWith(accountId: resolution.accountId);
// 4. Правила: исключение → ignored; иначе ищем merchantToCategory.
final rules =
await ref.read(parseRulesRepositoryProvider).getEnabledByUser(userId);
if (findIgnoreRule(rules, body: msg.body, merchantRaw: draft.merchantRaw) !=
null) {
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
return;
}
final rule =
findMerchantRule(rules, body: msg.body, merchantRaw: draft.merchantRaw);
@@ -314,7 +336,8 @@ class ParsingWorker extends _$ParsingWorker {
scores: scores,
strictness: settings.strictness,
amountMinor: draft.amount,
hasAccount: draft.accountId != null,
accountResolved: draft.accountId != null,
accountTrusted: resolution.trusted,
);
if (decision == GateDecision.autoApply) {
@@ -0,0 +1,43 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/entities/source_app.dart';
import 'notification_parsing_providers.dart';
part 'source_apps_controller.g.dart';
/// Все приложения-источники пользователя — для экрана управления.
@riverpod
Stream<List<SourceApp>> sourceAppsList(Ref ref, String userId) =>
ref.watch(sourceAppsRepositoryProvider).watchByUser(userId);
/// CRUD над приложениями-источниками (allowlist).
@Riverpod(keepAlive: true)
class SourceAppsController extends _$SourceAppsController {
@override
AsyncValue<void> build() => const AsyncData(null);
Future<void> add({
required String userId,
required String packageName,
String? displayName,
}) async {
state = const AsyncLoading();
try {
await ref.read(sourceAppsRepositoryProvider).add(
userId: userId,
packageName: packageName,
displayName: displayName,
);
state = const AsyncData(null);
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
Future<void> setEnabled(String id, {required bool enabled}) =>
ref.read(sourceAppsRepositoryProvider).setEnabled(id, enabled: enabled);
Future<void> delete(String id) =>
ref.read(sourceAppsRepositoryProvider).deleteById(id);
}
@@ -69,6 +69,19 @@ class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
t.userId.equals(userId) & t.packageName.equals(packageName)))
.get();
/// Умолчательная привязка приложения (card неизвестна) — §B #4.
Future<AccountBindingsTableData?> findDefaultByPackageName(
String userId,
String packageName,
) =>
(select(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) &
t.packageName.equals(packageName) &
t.isDefault.equals(true))
..limit(1))
.getSingleOrNull();
// ── Mutations ──────────────────────────────────────────────────────────────
Future<void> insert(AccountBindingsTableCompanion companion) =>
@@ -86,6 +99,22 @@ class AccountBindingsDao extends DatabaseAccessor<AppDatabase>
updates: {accountBindingsTable},
);
/// Делает привязку [id] умолчательной для её пакета: снимает флаг у всех
/// прочих привязок этого packageName и выставляет у выбранной (атомарно).
Future<void> setDefault(
String id,
String userId,
String packageName,
) =>
transaction(() async {
await (update(accountBindingsTable)
..where((t) =>
t.userId.equals(userId) & t.packageName.equals(packageName)))
.write(const AccountBindingsTableCompanion(isDefault: Value(false)));
await (update(accountBindingsTable)..where((t) => t.id.equals(id)))
.write(const AccountBindingsTableCompanion(isDefault: Value(true)));
});
Future<int> deleteById(String id) =>
(delete(accountBindingsTable)..where((t) => t.id.equals(id))).go();
}
@@ -0,0 +1,49 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/app_database.dart';
import '../tables/source_apps_table.dart';
part 'source_apps_dao.g.dart';
@DriftAccessor(tables: [SourceAppsTable])
class SourceAppsDao extends DatabaseAccessor<AppDatabase>
with _$SourceAppsDaoMixin {
SourceAppsDao(super.db);
/// Все приложения пользователя — для экрана управления.
Stream<List<SourceAppsTableData>> watchByUser(String userId) =>
(select(sourceAppsTable)
..where((t) => t.userId.equals(userId))
..orderBy([(t) => OrderingTerm(expression: t.displayName)]))
.watch();
/// Включённые packageName — для allowlist-фильтра.
Future<List<String>> enabledPackages(String userId) async {
final rows = await (select(sourceAppsTable)
..where((t) => t.userId.equals(userId) & t.enabled.equals(true)))
.get();
return rows.map((r) => r.packageName).toList();
}
Future<SourceAppsTableData?> findByPackageName(
String userId,
String packageName,
) =>
(select(sourceAppsTable)
..where((t) =>
t.userId.equals(userId) & t.packageName.equals(packageName))
..limit(1))
.getSingleOrNull();
Future<SourceAppsTableData?> findById(String id) =>
(select(sourceAppsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
Future<void> insert(SourceAppsTableCompanion companion) =>
into(sourceAppsTable).insert(companion);
Future<void> setEnabled(String id, {required bool enabled}) =>
(update(sourceAppsTable)..where((t) => t.id.equals(id)))
.write(SourceAppsTableCompanion(enabled: Value(enabled)));
Future<int> deleteById(String id) =>
(delete(sourceAppsTable)..where((t) => t.id.equals(id))).go();
}
@@ -25,6 +25,10 @@ class AccountBindingsTable extends Table {
TextColumn get accountId =>
text().references(AccountsTable, #id, onDelete: KeyAction.cascade)();
/// Привязка по умолчанию для этого приложения (когда карта не распознана).
/// Единственная на пакет — обеспечивается через [AccountBindingsDao.setDefault].
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
IntColumn get matchCount => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@@ -0,0 +1,33 @@
import 'package:drift/drift.dart';
import '../../../../../core/database/tables/users_table.dart';
/// Приложение-источник уведомлений, добавленное пользователем (allowlist).
///
/// Присутствие строки = приложение добавлено в мониторинг; [enabled] — следим
/// ли за ним сейчас. Парсятся только включённые приложения (см.
/// `parsing_worker` allowlist-фильтр). Заводится из каталога
/// (`source_apps_catalog.dart`) или вручную (кастомный packageName).
///
/// Уникальный индекс по (userId, packageName).
class SourceAppsTable extends Table {
@override
String get tableName => 'source_apps';
TextColumn get id => text()();
TextColumn get userId =>
text().references(UsersTable, #id, onDelete: KeyAction.cascade)();
TextColumn get packageName => text()();
TextColumn get displayName => text().nullable()();
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
@override
List<Set<Column>> get uniqueKeys => [
{userId, packageName},
];
}
@@ -11,6 +11,7 @@ extension AccountBindingMapper on AccountBindingsTableData {
cardLast4: cardLast4,
phone: phone,
accountId: accountId,
isDefault: isDefault,
matchCount: matchCount,
createdAt: createdAt,
);
@@ -0,0 +1,14 @@
import '../../../../core/database/app_database.dart';
import '../../domain/entities/source_app.dart';
/// Маппер: строка Drift → доменная сущность [SourceApp].
extension SourceAppMapper on SourceAppsTableData {
SourceApp toDomain() => SourceApp(
id: id,
userId: userId,
packageName: packageName,
displayName: displayName,
enabled: enabled,
createdAt: createdAt,
);
}
@@ -1,24 +1,48 @@
import '../../domain/entities/parse_rule.dart';
import '../../domain/repositories/account_bindings_repository.dart';
import 'rule_lookup.dart';
/// Результат разрешения счёта из уведомления (шаг 3 pipeline).
/// Откуда взят разрешённый счёт (для диагностики / подсветки).
enum AccountSource {
bindingCard,
bindingPhone,
senderRule,
singleBinding,
appDefault,
ambiguous,
globalDefault,
none,
}
/// Результат разрешения счёта из уведомления (шаг 3 pipeline, §B).
class AccountResolution {
const AccountResolution({
required this.accountId,
required this.score,
required this.trusted,
required this.source,
this.bindingId,
});
/// Разрешённый счёт (null, если binding не нашёлся).
/// Разрешённый счёт (null, если ничего не нашлось).
final String? accountId;
/// Confidence по счёту 0..100 (§8.2).
/// Confidence по счёту 0..100 (§8.2) — для подсветки «?», НЕ для gate.
final int score;
/// Можно ли доверять счёту для авто-применения. Именно это поле (а не score)
/// решает судьбу в gate: неоднозначность (#5) → false → Inbox; осознанные
/// дефолты (#4, #6) → true → авто-применение разрешено.
final bool trusted;
final AccountSource source;
/// id сработавшей привязки — для инкремента matchCount.
final String? bindingId;
}
/// Разрешает `accountId` по `account_bindings` (карта/телефон → счёт).
/// Разрешает `accountId` по цепочке источников (§B): привязки карта/телефон,
/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта.
class AccountResolver {
const AccountResolver(this._bindings);
@@ -27,38 +51,105 @@ class AccountResolver {
Future<AccountResolution> resolve({
required String userId,
required String packageName,
required String body,
String? cardLast4,
String? phone,
String? merchantRaw,
List<ParseRule> senderRules = const [],
String? globalDefaultAccountId,
}) async {
// #1 — binding по packageName + cardLast4.
if (cardLast4 != null) {
final b =
await _bindings.findByPackageAndCard(userId, packageName, cardLast4);
if (b != null) {
return AccountResolution(
accountId: b.accountId, score: 100, bindingId: b.id);
accountId: b.accountId,
score: 100,
trusted: true,
source: AccountSource.bindingCard,
bindingId: b.id,
);
}
}
// #1 — binding по телефону (СБП).
if (phone != null) {
final b = await _bindings.findByPhone(userId, phone);
if (b != null) {
return AccountResolution(
accountId: b.accountId, score: 100, bindingId: b.id);
accountId: b.accountId,
score: 100,
trusted: true,
source: AccountSource.bindingPhone,
bindingId: b.id,
);
}
}
final byPkg = await _bindings.findByPackageName(userId, packageName);
if (byPkg.length == 1) {
// Один счёт для этого банка — высокая уверенность (§8.2).
// #2 — senderToAccount-правило (матч по телу).
final senderRule =
findSenderRule(senderRules, body: body, merchantRaw: merchantRaw);
if (senderRule?.accountId != null) {
return AccountResolution(
accountId: byPkg.first.accountId, score: 75, bindingId: byPkg.first.id);
}
if (byPkg.length > 1) {
// Несколько счетов — эвристика «первый», низкая уверенность.
return AccountResolution(
accountId: byPkg.first.accountId, score: 45, bindingId: byPkg.first.id);
accountId: senderRule!.accountId,
score: 90,
trusted: true,
source: AccountSource.senderRule,
);
}
return const AccountResolution(accountId: null, score: 15);
final byPkg = await _bindings.findByPackageName(userId, packageName);
// #3 — единственный binding по packageName.
if (byPkg.length == 1) {
return AccountResolution(
accountId: byPkg.first.accountId,
score: 75,
trusted: true,
source: AccountSource.singleBinding,
bindingId: byPkg.first.id,
);
}
if (byPkg.length > 1) {
// #4 — per-app default binding (card неизвестна).
final def = await _bindings.findDefaultByPackageName(userId, packageName);
if (def != null) {
return AccountResolution(
accountId: def.accountId,
score: 70,
trusted: true,
source: AccountSource.appDefault,
bindingId: def.id,
);
}
// #5 — несколько bindings без default-флага → «первый», но не доверяем.
return AccountResolution(
accountId: byPkg.first.accountId,
score: 45,
trusted: false,
source: AccountSource.ambiguous,
bindingId: byPkg.first.id,
);
}
// #6 — глобальный Account.isDefault.
if (globalDefaultAccountId != null) {
return AccountResolution(
accountId: globalDefaultAccountId,
score: 40,
trusted: true,
source: AccountSource.globalDefault,
);
}
// #7 — ничего.
return const AccountResolution(
accountId: null,
score: 15,
trusted: false,
source: AccountSource.none,
);
}
}
@@ -21,8 +21,11 @@ class FieldScores {
final int category;
/// Минимум по «прочим полям» — это и есть вход в gate (§8.7).
int get otherFieldsMin =>
[amount, account, type].reduce((a, b) => a < b ? a : b);
///
/// Счёт исключён из gate-минимума: судьбу счёта решает `accountTrusted`
/// в [decide] (см. §B), а не числовой score. Account-score остаётся для
/// подсветки «?» в Inbox.
int get otherFieldsMin => [amount, type].reduce((a, b) => a < b ? a : b);
}
/// Считает 5 per-field оценок (§8.18.6).
@@ -11,20 +11,25 @@ const int _hugeAmountMinor = 100000 * 100;
/// Decision gate (§8.7).
///
/// Молча в ленту только если есть подтверждённое правило для мерчанта,
/// sanity пройден, и min(сумма, счёт, тип) ≥ порога строгости.
/// Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту.
/// sanity пройден, счёт разрешён и доверенный, и min(сумма, тип) ≥ порога
/// строгости. Порог ([strictness]) применяется к прочим полям, НЕ к мерчанту.
///
/// [accountResolved] — счёт вообще нашёлся; [accountTrusted] — ему можно
/// доверять для авто-применения (§B): неоднозначные multi-binding (#5) дают
/// false и уходят в Inbox, осознанные дефолты (#4/#6) — true.
GateDecision decide({
required ParseRule? merchantRule,
required bool sanityPassed,
required FieldScores scores,
required int strictness,
required int amountMinor,
required bool hasAccount,
required bool accountResolved,
required bool accountTrusted,
}) {
if (amountMinor > _hugeAmountMinor) return GateDecision.inbox;
// Без разрешённого счёта нечем создать транзакцию молча.
if (!hasAccount) return GateDecision.inbox;
// Без разрешённого/доверенного счёта нечем создать транзакцию молча.
if (!accountResolved || !accountTrusted) return GateDecision.inbox;
if (merchantRule != null &&
sanityPassed &&
@@ -56,6 +56,25 @@ ParseRule? findMerchantRule(
return matches.isEmpty ? null : matches.first;
}
/// Ищет активное правило `senderToAccount`, матчащее тело сообщения (§B #2).
///
/// При конфликте — та же специфичность, что у [findMerchantRule].
ParseRule? findSenderRule(
List<ParseRule> rules, {
required String body,
String? merchantRaw,
}) {
final matches = rules
.where((r) =>
r.enabled &&
r.kind == ParseRuleKind.senderToAccount &&
r.accountId != null &&
ruleMatches(r, body: body, merchantRaw: merchantRaw))
.toList()
..sort(_bySpecificity);
return matches.isEmpty ? null : matches.first;
}
/// Ищет активное правило-исключение (kind=ignore) для сообщения.
ParseRule? findIgnoreRule(
List<ParseRule> rules, {
@@ -46,6 +46,13 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository {
.map((r) => r.toDomain())
.toList();
@override
Future<AccountBinding?> findDefaultByPackageName(
String userId,
String packageName,
) async =>
(await _dao.findDefaultByPackageName(userId, packageName))?.toDomain();
@override
Future<AccountBinding> create({
required String userId,
@@ -56,6 +63,8 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository {
required String accountId,
}) async {
final id = const Uuid().v4();
// Привязка всегда создаётся не-дефолтной; default назначается отдельно
// через [setDefault] (атомарно снимает флаг у прочих привязок пакета).
await _dao.insert(
AccountBindingsTableCompanion.insert(
id: id,
@@ -82,6 +91,10 @@ class AccountBindingsRepositoryImpl implements AccountBindingsRepository {
@override
Future<void> incrementMatchCount(String id) => _dao.incrementMatchCount(id);
@override
Future<void> setDefault(String id, String userId, String packageName) =>
_dao.setDefault(id, userId, packageName);
@override
Future<void> deleteById(String id) => _dao.deleteById(id);
}
@@ -0,0 +1,51 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/source_app.dart';
import '../../domain/repositories/source_apps_repository.dart';
import '../drift/daos/source_apps_dao.dart';
import '../mappers/source_app_mapper.dart';
class SourceAppsRepositoryImpl implements SourceAppsRepository {
const SourceAppsRepositoryImpl(this._dao);
final SourceAppsDao _dao;
@override
Stream<List<SourceApp>> watchByUser(String userId) => _dao
.watchByUser(userId)
.map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Future<Set<String>> enabledPackages(String userId) async =>
(await _dao.enabledPackages(userId)).toSet();
@override
Future<SourceApp> add({
required String userId,
required String packageName,
String? displayName,
}) async {
final existing = await _dao.findByPackageName(userId, packageName);
if (existing != null) return existing.toDomain();
final id = const Uuid().v4();
await _dao.insert(
SourceAppsTableCompanion.insert(
id: id,
userId: userId,
packageName: packageName,
displayName: Value(displayName),
),
);
final row = await _dao.findById(id);
return row!.toDomain();
}
@override
Future<void> setEnabled(String id, {required bool enabled}) =>
_dao.setEnabled(id, enabled: enabled);
@override
Future<void> deleteById(String id) => _dao.deleteById(id);
}
@@ -0,0 +1,87 @@
/// Шаблон известного приложения-источника уведомлений (хардкод-каталог).
///
/// Источник вариантов для экрана «Приложения-источники»: пользователь
/// выбирает банк из списка, и в `source_apps` заводится строка. Позже этот
/// каталог заменится нативным списком установленных приложений.
class SourceAppTemplate {
const SourceAppTemplate({
required this.packageName,
required this.displayName,
this.bankKey,
});
final String packageName;
final String displayName;
/// Нормализованный ключ банка (для будущей привязки к шаблонам разбора).
final String? bankKey;
}
/// Захардкоженный каталог известных банков РФ.
const List<SourceAppTemplate> kSourceAppsCatalog = [
SourceAppTemplate(
packageName: 'ru.sberbankmobile',
displayName: 'СберБанк',
bankKey: 'sber',
),
SourceAppTemplate(
packageName: 'com.idamob.tinkoff.android',
displayName: 'Т-Банк',
bankKey: 'tinkoff',
),
SourceAppTemplate(
packageName: 'ru.alfabank.mobile.android',
displayName: 'Альфа-Банк',
bankKey: 'alfa',
),
SourceAppTemplate(
packageName: 'ru.vtb24.mobilebanking.android',
displayName: 'ВТБ',
bankKey: 'vtb',
),
SourceAppTemplate(
packageName: 'ru.gazprombank.android.mobilebank.app',
displayName: 'Газпромбанк',
bankKey: 'gazprom',
),
SourceAppTemplate(
packageName: 'ru.raiffeisennews',
displayName: 'Райффайзен Банк',
bankKey: 'raiffeisen',
),
SourceAppTemplate(
packageName: 'com.openbank',
displayName: 'Банк Открытие',
bankKey: 'open',
),
SourceAppTemplate(
packageName: 'ru.rosbank.android',
displayName: 'Росбанк',
bankKey: 'rosbank',
),
SourceAppTemplate(
packageName: 'ru.pochta.bank',
displayName: 'Почта Банк',
bankKey: 'pochta',
),
SourceAppTemplate(
packageName: 'ru.simpls.brs.android',
displayName: 'Банк Русский Стандарт',
bankKey: 'rsb',
),
SourceAppTemplate(
packageName: 'ru.mkb.mobile',
displayName: 'МКБ',
bankKey: 'mkb',
),
SourceAppTemplate(
packageName: 'ru.akbars.mobile',
displayName: 'Ак Барс Банк',
bankKey: 'akbars',
),
SourceAppTemplate(
packageName: 'com.android.mms',
displayName: 'SMS',
bankKey: null,
),
];
@@ -22,6 +22,9 @@ abstract class AccountBinding with _$AccountBinding {
String? phone,
required String accountId,
/// Привязка по умолчанию для приложения (карта не распознана).
@Default(false) bool isDefault,
@Default(0) int matchCount,
required DateTime createdAt,
}) = _AccountBinding;
@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'source_app.freezed.dart';
/// Приложение-источник уведомлений, добавленное пользователем (allowlist).
///
/// Парсятся только включённые ([enabled]) приложения. Заводится из каталога
/// (`source_apps_catalog.dart`) или вручную (кастомный [packageName]).
@freezed
abstract class SourceApp with _$SourceApp {
const factory SourceApp({
required String id,
required String userId,
required String packageName,
String? displayName,
@Default(true) bool enabled,
required DateTime createdAt,
}) = _SourceApp;
}
@@ -25,6 +25,12 @@ abstract interface class AccountBindingsRepository {
String packageName,
);
/// Умолчательная привязка приложения (карта не распознана) — §B #4.
Future<AccountBinding?> findDefaultByPackageName(
String userId,
String packageName,
);
Future<AccountBinding> create({
required String userId,
String? packageName,
@@ -36,5 +42,8 @@ abstract interface class AccountBindingsRepository {
Future<void> incrementMatchCount(String id);
/// Делает привязку умолчательной для её пакета (сбрасывает флаг у прочих).
Future<void> setDefault(String id, String userId, String packageName);
Future<void> deleteById(String id);
}
@@ -0,0 +1,22 @@
import '../entities/source_app.dart';
/// Доступ к приложениям-источникам уведомлений ([SourceApp]) — allowlist.
abstract interface class SourceAppsRepository {
/// Все приложения-источники пользователя — для экрана управления.
Stream<List<SourceApp>> watchByUser(String userId);
/// Множество включённых packageName — для allowlist-фильтра в пайплайне.
Future<Set<String>> enabledPackages(String userId);
/// Добавляет приложение (включено по умолчанию). Возвращает сущность.
/// Если строка с таким packageName уже есть — возвращает существующую.
Future<SourceApp> add({
required String userId,
required String packageName,
String? displayName,
});
Future<void> setEnabled(String id, {required bool enabled});
Future<void> deleteById(String id);
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../transactions/presentation/widgets/account_picker_sheet.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/account_bindings_controller.dart';
import '../../domain/entities/account_binding.dart';
/// Привязки выбранного приложения-источника: card/phone → счёт + дефолт (§A).
class AppBindingsScreen extends ConsumerWidget {
const AppBindingsScreen({super.key, required this.packageName});
final String packageName;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final userId = ref.watch(activeUserControllerProvider).value?.id;
if (userId == null) {
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
}
final bindings = (ref.watch(accountBindingsListProvider(userId)).value ??
const <AccountBinding>[])
.where((b) => b.packageName == packageName)
.toList();
final accounts = ref.watch(accountsStreamProvider(userId)).value ??
const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
return Scaffold(
backgroundColor: p.paper,
appBar: AppBar(
backgroundColor: p.paper,
title: Text(l10n.appBindingsTitle),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _addBinding(context, ref, userId),
),
],
),
body: bindings.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(l10n.appBindingsEmpty,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: p.ink2)),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: bindings.length,
separatorBuilder: (_, _) => Container(height: 1, color: p.line),
itemBuilder: (context, i) {
final b = bindings[i];
final accountName =
accountById[b.accountId]?.name ?? b.accountId;
final subtitle = b.cardLast4 != null
? '•••• ${b.cardLast4}'
: (b.phone ?? l10n.appBindingsAnyCard);
return ListTile(
title: Text(accountName,
style: TextStyle(fontSize: 14, color: p.ink)),
subtitle: Text(subtitle,
style: TextStyle(fontSize: 12, color: p.ink2)),
leading: IconButton(
icon: Icon(
b.isDefault ? Icons.star : Icons.star_border,
color: b.isDefault ? p.accent : p.ink2,
),
tooltip: l10n.appBindingsSetDefault,
onPressed: b.isDefault
? null
: () => ref
.read(accountBindingsControllerProvider.notifier)
.setDefault(b.id, userId, packageName),
),
trailing: IconButton(
icon: Icon(Icons.delete_outline, color: p.ink2),
onPressed: () => ref
.read(accountBindingsControllerProvider.notifier)
.delete(b.id),
),
);
},
),
);
}
Future<void> _addBinding(
BuildContext context,
WidgetRef ref,
String userId,
) async {
final accountId = await showAccountPicker(context, userId: userId);
if (accountId == null || !context.mounted) return;
final l10n = context.l10n;
final cardCtrl = TextEditingController();
var asDefault = false;
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(l10n.appBindingsAddTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: cardCtrl,
keyboardType: TextInputType.number,
maxLength: 4,
decoration: InputDecoration(
labelText: l10n.appBindingsCardLabel,
helperText: l10n.appBindingsCardHelper,
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.appBindingsDefaultLabel),
value: asDefault,
onChanged: (v) => setState(() => asDefault = v ?? false),
),
],
),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () => context.pop(true),
child: Text(l10n.commonAdd),
),
],
),
),
);
final card = cardCtrl.text.trim();
cardCtrl.dispose();
if (ok == true) {
await ref.read(accountBindingsControllerProvider.notifier).add(
userId: userId,
packageName: packageName,
accountId: accountId,
cardLast4: card.isEmpty ? null : card,
isDefault: asDefault,
);
}
}
}
@@ -72,6 +72,11 @@ class ParsingSettingsScreen extends ConsumerWidget {
title: l10n.parsingRulesTile,
onTap: () => context.push(AppRoutes.parsingRules),
),
_NavTile(
icon: Icons.apps_outlined,
title: l10n.parsingAppsTile,
onTap: () => context.push(AppRoutes.parsingApps),
),
_NavTile(
icon: Icons.receipt_long_outlined,
title: l10n.parsingLogTile,
@@ -36,6 +36,7 @@ class RuleEditorPrefill {
/// Результат редактора в режиме compose (возвращается через pop в Inbox).
class RuleEditorResult {
const RuleEditorResult({
required this.kind,
required this.matchMode,
required this.pattern,
required this.merchantCanonical,
@@ -44,6 +45,7 @@ class RuleEditorResult {
this.priority = 0,
});
final ParseRuleKind kind;
final MatchMode matchMode;
final String pattern;
final String merchantCanonical;
@@ -68,6 +70,7 @@ class RuleEditorScreen extends ConsumerStatefulWidget {
class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
final _patternCtrl = TextEditingController();
final _merchantCtrl = TextEditingController();
ParseRuleKind _kind = ParseRuleKind.merchantToCategory;
MatchMode _matchMode = MatchMode.contains;
String? _categoryId;
String? _accountId;
@@ -101,6 +104,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
void _hydrateFromRule(ParseRule rule) {
if (_initialized) return;
_kind = rule.kind;
_patternCtrl.text = rule.pattern;
_merchantCtrl.text = rule.merchantCanonical ?? '';
_matchMode = rule.matchMode;
@@ -111,18 +115,27 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
_initialized = true;
}
bool get _isSender => _kind == ParseRuleKind.senderToAccount;
/// Готовность к сохранению: для sender→account нужен счёт.
bool get _canSave =>
_patternCtrl.text.trim().isNotEmpty && (!_isSender || _accountId != null);
Future<void> _save(String userId, ParseRule? existing) async {
final pattern = _patternCtrl.text.trim();
if (pattern.isEmpty) return;
final merchant = _merchantCtrl.text.trim();
final merchant = _isSender ? '' : _merchantCtrl.text.trim();
// sender→account не задаёт категорию/мерчанта.
final categoryId = _isSender ? null : _categoryId;
if (_isEdit && existing != null) {
await ref.read(rulesControllerProvider.notifier).update(
existing.copyWith(
kind: _kind,
matchMode: _matchMode,
pattern: pattern,
merchantCanonical: merchant.isEmpty ? null : merchant,
categoryId: _categoryId,
categoryId: categoryId,
accountId: _accountId,
priority: _priority,
enabled: _enabled,
@@ -132,10 +145,11 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
} else {
context.pop(
RuleEditorResult(
kind: _kind,
matchMode: _matchMode,
pattern: pattern,
merchantCanonical: merchant,
categoryId: _categoryId,
categoryId: categoryId,
accountId: _accountId,
priority: _priority,
),
@@ -191,6 +205,16 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
// Выбор вида — только при создании из списка правил. Из Inbox
// (prefill) композим всегда merchant→category: senderToAccount там
// не применяется (inbox_controller создаёт правило этого вида).
if (!_isEdit && widget.prefill == null) ...[
_KindSelector(
kind: _kind,
onChanged: (k) => setState(() => _kind = k),
),
const SizedBox(height: 16),
],
Text(l10n.ruleEditorIfContains,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 6),
@@ -216,36 +240,41 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
Text(l10n.ruleEditorThen,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 8),
_FieldRow(
label: l10n.ruleEditorMerchant,
child: TextField(
controller: _merchantCtrl,
textAlign: TextAlign.end,
decoration: InputDecoration(
hintText: l10n.ruleEditorMerchantHint,
isDense: true,
border: InputBorder.none,
if (!_isSender) ...[
_FieldRow(
label: l10n.ruleEditorMerchant,
child: TextField(
controller: _merchantCtrl,
textAlign: TextAlign.end,
decoration: InputDecoration(
hintText: l10n.ruleEditorMerchantHint,
isDense: true,
border: InputBorder.none,
),
),
),
),
_Divider(color: p.line),
_PickerRow(
label: l10n.ruleEditorCategory,
value: categoryName ?? l10n.inboxNoCategory,
onTap: () async {
final id = await showCategoryPicker(
context,
userId: userId,
type: CategoryType.expense,
currentCategoryId: _categoryId,
);
if (id != null) setState(() => _categoryId = id);
},
),
_Divider(color: p.line),
_Divider(color: p.line),
_PickerRow(
label: l10n.ruleEditorCategory,
value: categoryName ?? l10n.inboxNoCategory,
onTap: () async {
final id = await showCategoryPicker(
context,
userId: userId,
type: CategoryType.expense,
currentCategoryId: _categoryId,
);
if (id != null) setState(() => _categoryId = id);
},
),
_Divider(color: p.line),
],
_PickerRow(
label: l10n.ruleEditorAccount,
value: accountName ?? l10n.ruleEditorAccountUnchanged,
value: accountName ??
(_isSender
? l10n.ruleEditorAccountPick
: l10n.ruleEditorAccountUnchanged),
onTap: () async {
final id = await showAccountPicker(
context,
@@ -298,9 +327,7 @@ class _RuleEditorScreenState extends ConsumerState<RuleEditorScreen> {
backgroundColor: p.accent,
minimumSize: const Size.fromHeight(48),
),
onPressed: _patternCtrl.text.trim().isEmpty
? null
: () => _save(userId, existing),
onPressed: !_canSave ? null : () => _save(userId, existing),
child: Text(l10n.ruleEditorSave),
),
],
@@ -363,6 +390,33 @@ class _MatchesPreview extends ConsumerWidget {
}
}
class _KindSelector extends StatelessWidget {
const _KindSelector({required this.kind, required this.onChanged});
final ParseRuleKind kind;
final ValueChanged<ParseRuleKind> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SegmentedButton<ParseRuleKind>(
segments: [
ButtonSegment(
value: ParseRuleKind.merchantToCategory,
label: Text(l10n.ruleKindMerchant),
),
ButtonSegment(
value: ParseRuleKind.senderToAccount,
label: Text(l10n.ruleKindAccount),
),
],
selected: {kind},
showSelectedIcon: false,
onSelectionChanged: (s) => onChanged(s.first),
);
}
}
class _MatchModeSelector extends StatelessWidget {
const _MatchModeSelector({required this.mode, required this.onChanged});
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../user/application/active_user_controller.dart';
@@ -51,6 +53,9 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
final categories =
ref.watch(categoriesStreamProvider(userId)).value ?? const <Category>[];
final categoryById = {for (final c in categories) c.id: c};
final accounts =
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
return Scaffold(
backgroundColor: p.paper,
@@ -107,6 +112,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
itemBuilder: (context, i) => RuleCard(
rule: rules[i],
categoryById: categoryById,
accountById: accountById,
onTap: () =>
context.push(AppRoutes.parsingRuleEdit(rules[i].id)),
onToggle: (enabled) => ref
@@ -126,7 +132,7 @@ class _RulesListScreenState extends ConsumerState<RulesListScreen> {
if (result == null) return;
await ref.read(rulesControllerProvider.notifier).create(
userId: userId,
kind: ParseRuleKind.merchantToCategory,
kind: result.kind,
matchMode: result.matchMode,
pattern: result.pattern,
priority: result.priority,
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/source_apps_controller.dart';
import '../../data/source_apps/source_apps_catalog.dart';
import '../../domain/entities/source_app.dart';
/// Экран управления приложениями-источниками (allowlist, §A).
///
/// «Включённые» — мониторятся (toggle + переход к привязкам); «Доступные» —
/// из каталога, ещё не добавлены (тап включает); «Добавить вручную» —
/// кастомный packageName.
class SourceAppsScreen extends ConsumerWidget {
const SourceAppsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final userId = ref.watch(activeUserControllerProvider).value?.id;
if (userId == null) {
return Scaffold(backgroundColor: p.paper, body: const SizedBox.shrink());
}
final apps = ref.watch(sourceAppsListProvider(userId)).value ??
const <SourceApp>[];
final addedPackages = {for (final a in apps) a.packageName};
final available = kSourceAppsCatalog
.where((t) => !addedPackages.contains(t.packageName))
.toList();
return Scaffold(
backgroundColor: p.paper,
appBar: AppBar(
backgroundColor: p.paper,
title: Text(l10n.sourceAppsTitle),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: l10n.sourceAppsAddManual,
onPressed: () => _addManual(context, ref, userId),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
Text(l10n.sourceAppsAddedSection,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 8),
if (apps.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(l10n.sourceAppsEmpty,
style: TextStyle(fontSize: 14, color: p.ink2)),
)
else
_Card(
children: [
for (var i = 0; i < apps.length; i++) ...[
if (i > 0) Container(height: 1, color: p.line),
_AddedAppTile(app: apps[i], userId: userId),
],
],
),
if (available.isNotEmpty) ...[
const SizedBox(height: 20),
Text(l10n.sourceAppsAvailableSection,
style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(height: 8),
_Card(
children: [
for (var i = 0; i < available.length; i++) ...[
if (i > 0) Container(height: 1, color: p.line),
_AvailableAppTile(template: available[i], userId: userId),
],
],
),
],
],
),
);
}
Future<void> _addManual(
BuildContext context,
WidgetRef ref,
String userId,
) async {
final l10n = context.l10n;
final pkgCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.sourceAppsAddManual),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: pkgCtrl,
autofocus: true,
decoration:
InputDecoration(labelText: l10n.sourceAppsPackageLabel),
),
const SizedBox(height: 8),
TextField(
controller: nameCtrl,
decoration: InputDecoration(labelText: l10n.sourceAppsNameLabel),
),
],
),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () => context.pop(true),
child: Text(l10n.commonAdd),
),
],
),
);
final pkg = pkgCtrl.text.trim();
final name = nameCtrl.text.trim();
pkgCtrl.dispose();
nameCtrl.dispose();
if (result == true && pkg.isNotEmpty) {
await ref.read(sourceAppsControllerProvider.notifier).add(
userId: userId,
packageName: pkg,
displayName: name.isEmpty ? null : name,
);
}
}
}
class _AddedAppTile extends ConsumerWidget {
const _AddedAppTile({required this.app, required this.userId});
final SourceApp app;
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final ctrl = ref.read(sourceAppsControllerProvider.notifier);
final title = app.displayName ?? app.packageName;
return InkWell(
onTap: () => context.push(AppRoutes.parsingAppBindings(app.packageName)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 8, 8),
child: Row(
children: [
Icon(Icons.apps_outlined, size: 20, color: p.ink2),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(fontSize: 14, color: p.ink)),
if (app.displayName != null)
Text(app.packageName,
style: TextStyle(fontSize: 11, color: p.ink2)),
],
),
),
Switch(
value: app.enabled,
activeThumbColor: p.accent,
onChanged: (v) => ctrl.setEnabled(app.id, enabled: v),
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: p.ink2),
tooltip: l10n.commonDelete,
onPressed: () => ctrl.delete(app.id),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
),
);
}
}
class _AvailableAppTile extends ConsumerWidget {
const _AvailableAppTile({required this.template, required this.userId});
final SourceAppTemplate template;
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
return InkWell(
onTap: () => ref.read(sourceAppsControllerProvider.notifier).add(
userId: userId,
packageName: template.packageName,
displayName: template.displayName,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Icon(Icons.add_circle_outline, size: 20, color: p.ink2),
const SizedBox(width: 12),
Expanded(
child: Text(template.displayName,
style: TextStyle(fontSize: 14, color: p.ink)),
),
],
),
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Container(
decoration: BoxDecoration(
border: Border.all(color: p.line),
borderRadius: BorderRadius.circular(14),
),
child: Column(children: children),
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/domain/entities/category.dart';
import '../../domain/entities/parse_rule.dart';
import '../../domain/enums.dart';
@@ -12,12 +13,14 @@ class RuleCard extends StatelessWidget {
super.key,
required this.rule,
required this.categoryById,
this.accountById = const {},
required this.onTap,
required this.onToggle,
});
final ParseRule rule;
final Map<String, Category> categoryById;
final Map<String, Account> accountById;
final VoidCallback onTap;
final ValueChanged<bool> onToggle;
@@ -34,10 +37,14 @@ class RuleCard extends StatelessWidget {
final categoryName =
rule.categoryId != null ? categoryById[rule.categoryId]?.name : null;
final target = [
rule.merchantCanonical,
categoryName,
].whereType<String>().join(' · ');
final accountName =
rule.accountId != null ? accountById[rule.accountId]?.name : null;
final target = rule.kind == ParseRuleKind.senderToAccount
? (accountName ?? '')
: [
rule.merchantCanonical,
categoryName,
].whereType<String>().join(' · ');
return Dismissible(
key: ValueKey(rule.id),
+5 -1
View File
@@ -42,12 +42,16 @@ void main() {
),
);
// 2. «Откатываем» схему до v5: убираем новые колонки и версию.
// 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7
// (иначе onUpgrade 5→7 попытается создать их повторно).
await dbV6.customStatement(
'ALTER TABLE transactions DROP COLUMN obligation');
await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse');
await dbV6.customStatement(
'ALTER TABLE settings DROP COLUMN habit_tracking_enabled');
await dbV6.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV6.customStatement('DROP TABLE source_apps');
await dbV6.customStatement('PRAGMA user_version = 5');
await dbV6.close();
+82
View File
@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
/// Тест миграции v6 → v7 (account_bindings.is_default + таблица source_apps).
///
/// Поднимаем актуальную (v7) схему, «откатываем» до v6 (убираем is_default и
/// source_apps, ставим user_version = 6). Повторное открытие запускает
/// onUpgrade(6 → 7), который должен восстановить колонку и таблицу.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_v7_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 6 → 7 добавляет is_default и таблицу source_apps', () async {
const userId = 'user-1';
const accountId = 'account-1';
// 1. Актуальная схема (v7) + FK-цепочка.
final dbV7 = AppDatabase.forTesting(NativeDatabase(file));
await dbV7.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
await dbV7.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: accountId,
userId: userId,
name: 'Основной',
),
);
// 2. «Откатываем» до v6.
await dbV7.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV7.customStatement('DROP TABLE source_apps');
await dbV7.customStatement('PRAGMA user_version = 6');
await dbV7.close();
// 3. Повторное открытие → onUpgrade(6 → 7).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4a. is_default доступна с дефолтом false (round-trip привязки).
await dbMigrated.accountBindingsDao.insert(
AccountBindingsTableCompanion.insert(
id: 'b-1',
userId: userId,
accountId: accountId,
packageName: const Value('ru.sberbankmobile'),
),
);
final bindings =
await dbMigrated.accountBindingsDao.findByPackageName(
userId,
'ru.sberbankmobile',
);
expect(bindings, hasLength(1));
expect(bindings.first.isDefault, isFalse);
// 4b. Таблица source_apps существует и принимает строки.
await dbMigrated.sourceAppsDao.insert(
SourceAppsTableCompanion.insert(
id: 's-1',
userId: userId,
packageName: 'ru.sberbankmobile',
),
);
final enabled = await dbMigrated.sourceAppsDao.enabledPackages(userId);
expect(enabled, contains('ru.sberbankmobile'));
});
}
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/providers/database_provider.dart';
import 'package:new_budget/src/features/accounts/application/account_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
import 'package:new_budget/src/features/notification_parsing/data/openrouter/openrouter_client.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
/// Тесты allowlist-фильтра и проводки резолвера в [ParsingWorker] (§A/§B).
/// AI замокан (без сети): MockClient отдаёт фиксированный JSON-ответ модели.
const _userId = 'u1';
const _accountId = 'acc-default';
const _bank = 'ru.sberbankmobile';
/// AI-парсер поверх MockClient, который возвращает канонический draft-JSON.
/// [onCall] вызывается при каждом обращении — для проверки «AI не звали».
AiParser _fakeAiParser({
required String merchantRaw,
required num amount,
void Function()? onCall,
}) {
final mock = MockClient((req) async {
onCall?.call();
final content = jsonEncode({
'type': 'expense',
'kind': 'purchase',
'amount': amount,
'currency': 'RUB',
'merchantRaw': merchantRaw,
});
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
}
],
'usage': {'total_tokens': 42},
}),
200,
headers: {'content-type': 'application/json'},
);
});
return AiParser(OpenRouterClient(client: mock, apiKey: 'k'));
}
Future<void> _seed(AppDatabase db) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: _accountId,
userId: _userId,
name: 'Main',
),
);
}
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
c.read(parsingWorkerProvider(_userId));
}
Future<RawMessage> _waitTerminal(
RawMessagesRepository repo,
String id, {
Duration timeout = const Duration(seconds: 10),
}) async {
const transient = {
RawMessageStatus.pending,
RawMessageStatus.parsing,
RawMessageStatus.pendingAi,
};
final completer = Completer<RawMessage>();
late final StreamSubscription<List<RawMessage>> sub;
sub = repo.watchAll(_userId).listen((list) {
for (final m in list) {
if (m.id == id && !transient.contains(m.status)) {
if (!completer.isCompleted) completer.complete(m);
return;
}
}
});
try {
return await completer.future.timeout(timeout);
} finally {
await sub.cancel();
}
}
void main() {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
Future<void> enableAi(ProviderContainer c) async {
final settings = c.read(parsingSettingsControllerProvider.notifier);
await c.read(parsingSettingsControllerProvider.future);
await settings.setAiConsent(true);
}
tearDown(() async {
container.dispose();
await db.close();
});
test('сообщение от пакета НЕ из allowlist → ignored, AI не вызывается',
() async {
var aiCalled = false;
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
// НИ одной строки source_apps → пакет не в allowlist.
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith((ref) async => _fakeAiParser(
merchantRaw: 'LENTA',
amount: 1500,
onCall: () => aiCalled = true,
)),
]);
repo = container.read(rawMessagesRepositoryProvider);
await enableAi(container);
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.random.chat',
body: 'Payment of 1500 RUB at LENTA',
receivedAt: DateTime(2026, 5, 31, 12),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.ignored);
expect(aiCalled, isFalse, reason: 'AI must not be called for non-allowlisted package');
});
test(
'включённый банк без карты + глобальный дефолт + правило категории '
'→ авто-применение на дефолтный счёт', () async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith((ref) async =>
_fakeAiParser(merchantRaw: 'LENTA', amount: 1500)),
]);
repo = container.read(rawMessagesRepositoryProvider);
await enableAi(container);
// Allowlist: банк включён.
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-1',
userId: _userId,
packageName: _bank,
));
// Глобальный дефолтный счёт.
await container
.read(accountRepositoryProvider)
.setDefault(_accountId, _userId);
// Категория + правило merchant→category на «LENTA».
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
id: 'cat-1',
userId: _userId,
name: 'Продукты',
));
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'LENTA',
categoryId: 'cat-1',
);
// AI draft amount score = 60; снижаем строгость, чтобы пройти gate.
await container
.read(parsingSettingsControllerProvider.notifier)
.setStrictness(50);
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: 'Payment of 1500 RUB at LENTA',
receivedAt: DateTime(2026, 5, 31, 12),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.applied,
reason: 'lastParseError=${msg.lastParseError}');
// Создалась транзакция на дефолтном счёте.
final txns = await db.select(db.transactionsTable).get();
expect(txns, hasLength(1));
expect(txns.first.accountId, _accountId);
expect(txns.first.categoryId, 'cat-1');
});
}
@@ -82,6 +82,15 @@ Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
name: 'Основной',
),
);
// Allowlist: тестовый пакет должен быть включён, иначе воркер пометит
// сообщение `ignored` ещё до AI (§A).
await db.sourceAppsDao.insert(
SourceAppsTableCompanion.insert(
id: 'src-1',
userId: _userId,
packageName: 'com.example.bank',
),
);
var i = 0;
for (final name in categories) {
await db.categoriesDao.insertCategory(
@@ -0,0 +1,187 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/account_resolver.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/account_binding.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.dart';
const _userId = 'u1';
const _pkg = 'ru.sberbankmobile';
final _now = DateTime(2026, 1, 1);
AccountBinding _binding({
required String id,
required String accountId,
String? cardLast4,
String? phone,
bool isDefault = false,
}) =>
AccountBinding(
id: id,
userId: _userId,
packageName: _pkg,
cardLast4: cardLast4,
phone: phone,
accountId: accountId,
isDefault: isDefault,
createdAt: _now,
);
ParseRule _senderRule(String pattern, String accountId) => ParseRule(
id: 'sr',
userId: _userId,
kind: ParseRuleKind.senderToAccount,
matchMode: MatchMode.contains,
pattern: pattern,
accountId: accountId,
createdAt: _now,
);
/// Конфигурируемый фейк: возвращает заданные привязки из in-memory списка.
class _FakeBindingsRepo implements AccountBindingsRepository {
_FakeBindingsRepo(this.bindings);
final List<AccountBinding> bindings;
@override
Future<AccountBinding?> findByPackageAndCard(
String userId,
String packageName,
String cardLast4,
) async =>
bindings
.where((b) =>
b.packageName == packageName && b.cardLast4 == cardLast4)
.firstOrNull;
@override
Future<AccountBinding?> findByPhone(String userId, String phone) async =>
bindings.where((b) => b.phone == phone).firstOrNull;
@override
Future<List<AccountBinding>> findByPackageName(
String userId,
String packageName,
) async =>
bindings.where((b) => b.packageName == packageName).toList();
@override
Future<AccountBinding?> findDefaultByPackageName(
String userId,
String packageName,
) async =>
bindings
.where((b) => b.packageName == packageName && b.isDefault)
.firstOrNull;
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
Future<AccountResolution> _resolve(
List<AccountBinding> bindings, {
String? cardLast4,
String? phone,
List<ParseRule> senderRules = const [],
String? globalDefaultAccountId,
String body = 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
}) {
final resolver = AccountResolver(_FakeBindingsRepo(bindings));
return resolver.resolve(
userId: _userId,
packageName: _pkg,
body: body,
cardLast4: cardLast4,
phone: phone,
senderRules: senderRules,
globalDefaultAccountId: globalDefaultAccountId,
);
}
void main() {
group('AccountResolver.resolve (table B)', () {
test('#1 binding by package + card → 100, trusted', () async {
final r = await _resolve(
[_binding(id: 'b', accountId: 'acc-card', cardLast4: '3456')],
cardLast4: '3456',
);
expect(r.accountId, 'acc-card');
expect(r.score, 100);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.bindingCard);
});
test('#1 binding by phone → 100, trusted', () async {
final r = await _resolve(
[_binding(id: 'b', accountId: 'acc-phone', phone: '+79990001122')],
phone: '+79990001122',
);
expect(r.accountId, 'acc-phone');
expect(r.score, 100);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.bindingPhone);
});
test('#2 senderToAccount rule → 90, trusted', () async {
// Несколько привязок без default, чтобы single/default не перехватили,
// но правило отправителя имеет приоритет над ними.
final r = await _resolve(
[
_binding(id: 'b1', accountId: 'a1'),
_binding(id: 'b2', accountId: 'a2'),
],
senderRules: [_senderRule('PYATEROCHKA', 'acc-rule')],
);
expect(r.accountId, 'acc-rule');
expect(r.score, 90);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.senderRule);
});
test('#3 single binding by package → 75, trusted', () async {
final r = await _resolve([_binding(id: 'b', accountId: 'acc-single')]);
expect(r.accountId, 'acc-single');
expect(r.score, 75);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.singleBinding);
});
test('#4 per-app default binding → 70, trusted', () async {
final r = await _resolve([
_binding(id: 'b1', accountId: 'a1'),
_binding(id: 'b2', accountId: 'acc-default', isDefault: true),
]);
expect(r.accountId, 'acc-default');
expect(r.score, 70);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.appDefault);
});
test('#5 multiple bindings, no default → 45, NOT trusted', () async {
final r = await _resolve([
_binding(id: 'b1', accountId: 'a1'),
_binding(id: 'b2', accountId: 'a2'),
]);
expect(r.accountId, 'a1');
expect(r.score, 45);
expect(r.trusted, isFalse);
expect(r.source, AccountSource.ambiguous);
});
test('#6 global default account → 40, trusted', () async {
final r = await _resolve(const [], globalDefaultAccountId: 'acc-global');
expect(r.accountId, 'acc-global');
expect(r.score, 40);
expect(r.trusted, isTrue);
expect(r.source, AccountSource.globalDefault);
});
test('#7 nothing → null, 15, NOT trusted', () async {
final r = await _resolve(const []);
expect(r.accountId, isNull);
expect(r.score, 15);
expect(r.trusted, isFalse);
expect(r.source, AccountSource.none);
});
});
}
@@ -24,14 +24,15 @@ const _strongScores = FieldScores(
void main() {
group('decide', () {
test('rule + sanity + scores ≥ strictness → autoApply', () {
test('rule + sanity + scores ≥ strictness + trusted → autoApply', () {
final d = decide(
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
hasAccount: true,
accountResolved: true,
accountTrusted: true,
);
expect(d, GateDecision.autoApply);
});
@@ -43,7 +44,8 @@ void main() {
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
hasAccount: true,
accountResolved: true,
accountTrusted: true,
);
expect(d, GateDecision.inbox);
});
@@ -53,15 +55,16 @@ void main() {
merchantRule: _rule(),
sanityPassed: true,
scores: const FieldScores(
amount: 100,
account: 45,
amount: 45,
account: 100,
type: 100,
merchant: 100,
category: 100,
),
strictness: 85,
amountMinor: 124000,
hasAccount: true,
accountResolved: true,
accountTrusted: true,
);
expect(d, GateDecision.inbox);
});
@@ -73,7 +76,21 @@ void main() {
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
hasAccount: false,
accountResolved: false,
accountTrusted: false,
);
expect(d, GateDecision.inbox);
});
test('untrusted account (ambiguous multi-binding) → inbox', () {
final d = decide(
merchantRule: _rule(),
sanityPassed: true,
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
accountResolved: true,
accountTrusted: false,
);
expect(d, GateDecision.inbox);
});
@@ -85,7 +102,8 @@ void main() {
scores: _strongScores,
strictness: 85,
amountMinor: 100001 * 100,
hasAccount: true,
accountResolved: true,
accountTrusted: true,
);
expect(d, GateDecision.inbox);
});
@@ -97,7 +115,8 @@ void main() {
scores: _strongScores,
strictness: 85,
amountMinor: 124000,
hasAccount: true,
accountResolved: true,
accountTrusted: true,
);
expect(d, GateDecision.inbox);
});
@@ -112,6 +112,48 @@ void main() {
});
});
group('findSenderRule', () {
const body = 'Покупка PYATEROCHKA 1240';
ParseRule sender({
required String id,
required String pattern,
String? accountId = 'acc1',
bool enabled = true,
}) =>
_rule(
id: id,
pattern: pattern,
kind: ParseRuleKind.senderToAccount,
enabled: enabled,
categoryId: null,
).copyWith(accountId: accountId);
test('returns matching enabled sender rule', () {
final r = findSenderRule(
[sender(id: 's', pattern: 'PYATEROCHKA')],
body: body,
);
expect(r?.id, 's');
});
test('ignores rules without accountId', () {
final r = findSenderRule(
[sender(id: 's', pattern: 'PYATEROCHKA', accountId: null)],
body: body,
);
expect(r, isNull);
});
test('ignores merchant-kind rules', () {
final r = findSenderRule(
[_rule(id: 'm', pattern: 'PYATEROCHKA')],
body: body,
);
expect(r, isNull);
});
});
group('findIgnoreRule', () {
test('finds enabled ignore rule', () {
final r = findIgnoreRule(