Files
OnBudget/lib/src/features/notification_parsing/data/parser/account_resolver.dart
T
2026-06-01 17:51:25 +03:00

156 lines
4.7 KiB
Dart

import '../../domain/entities/parse_rule.dart';
import '../../domain/repositories/account_bindings_repository.dart';
import 'rule_lookup.dart';
/// Откуда взят разрешённый счёт (для диагностики / подсветки).
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, если ничего не нашлось).
final String? accountId;
/// 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` по цепочке источников (§B): привязки карта/телефон,
/// `senderToAccount`-правила, per-app default и глобальный дефолт счёта.
class AccountResolver {
const AccountResolver(this._bindings);
final AccountBindingsRepository _bindings;
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,
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,
trusted: true,
source: AccountSource.bindingPhone,
bindingId: b.id,
);
}
}
// #2 — senderToAccount-правило (матч по телу).
final senderRule =
findSenderRule(senderRules, body: body, merchantRaw: merchantRaw);
if (senderRule?.accountId != null) {
return AccountResolution(
accountId: senderRule!.accountId,
score: 90,
trusted: true,
source: AccountSource.senderRule,
);
}
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,
);
}
}