import 'dart:convert'; import 'package:crypto/crypto.dart'; /// Pago detectado en una notificacion de billetera (Yape/Plin). class CapturedPayment { const CapturedPayment({ required this.provider, required this.amount, required this.rawText, required this.packageName, required this.postedAtMillis, this.senderName, }); final String provider; // YAPE | PLIN final double amount; final String? senderName; final String rawText; final String packageName; final int postedAtMillis; /// Hash de idempotencia: el backend tiene indice unico sobre esto, asi un /// repost de Android de la misma notificacion no duplica el pago. Usa el /// timestamp DE LA NOTIFICACION (estable entre reposts), no "ahora". String get dedupHash => sha256 .convert(utf8.encode( '$packageName|$amount|${senderName ?? ''}|$postedAtMillis|$rawText')) .toString(); Map toApiPayload(String deviceId) => { 'provider': provider, 'amount': amount, 'sender_name': senderName, 'raw_text': rawText, 'package_name': packageName, 'posted_at': DateTime.fromMillisecondsSinceEpoch(postedAtMillis) .toIso8601String(), 'device_id': deviceId, 'dedup_hash': dedupHash, }; } /// Parser de notificaciones de Yape/Plin. /// /// Regex y package names validados contra apps reales del rubro en produccion /// (lector-de-yape, CheckPay, Digital_Wallet_Notifier_Flutter -- investigacion /// 2026-08-20). Formatos reales: /// Yape: "Pamela Valencia te envio S/ 100. Codigo: 593" /// "Ana te envio S/ 5" / "¡Recibiste un yapeo de S/ 20!" /// Plin (llega dentro de la app de cada banco): /// "Te plinearon S/ 15 de Carlos" (Interbank y similares) /// /// Se EXCLUYEN pagos enviados ("Yapeaste S/ 8 a Luis") y promos/cashback. class PaymentNotificationParser { static const yapePackage = 'com.bcp.innovacxion.yapeapp'; /// Packages de bancos que canalizan Plin. Frágil por diseño (cada banco /// cambia su copy cuando quiere) -- por eso Plin ademas exige keyword. static const plinBankPackages = { 'pe.com.interbank.mobilebanking', 'com.bbva.nxt_peru', 'com.pe.scotiabank.blpm.android.client', }; static final _receivedPattern = RegExp( r'(recibiste|te\s+(han\s+)?yape|te\s+envi(o|aron)|te\s+plin(earon|eo))', caseSensitive: false, ); static final _excludePattern = RegExp( r'(yapeaste|plineaste|enviaste|cashback|promocion|descuento|premio|sorteo)', caseSensitive: false, ); static final _amountPattern = RegExp( r's\s*/\.?\s*([0-9][0-9.,]*)', caseSensitive: false, ); // "NOMBRE te envio S/..." / "NOMBRE te yapeo..." (nombre antes del verbo). // Sin anchor ^ y sin '.' en la clase: el titulo ("Confirmación de Pago. ") // suele venir pegado adelante y el punto corta el arrastre. static final _senderBeforePattern = RegExp( r'(?:^|[.!]\s*)([^.!]{2,60}?)\s+te\s+(?:envi\S*|yape\S*)', caseSensitive: false, ); // "Te plinearon S/ 15 de NOMBRE" (nombre despues de "de") static final _senderAfterPattern = RegExp( r'de\s+([A-Za-zÀ-ÿÑñ.\s]{2,60}?)\s*(\.|$|,)', caseSensitive: false, ); /// Devuelve el pago detectado, o null si la notificacion no es un pago /// RECIBIDO de Yape/Plin (otra app, pago enviado, promo, summary sin monto). static CapturedPayment? parse({ required String packageName, required String? title, required String? text, required int postedAtMillis, }) { final isYape = packageName == yapePackage; final isPlinBank = plinBankPackages.contains(packageName); if (!isYape && !isPlinBank) return null; final normalized = _normalize('${title ?? ''} ${text ?? ''}'); if (normalized.trim().isEmpty) return null; if (_excludePattern.hasMatch(normalized)) return null; if (!_receivedPattern.hasMatch(normalized)) return null; // Plin dentro de una app de banco: solo cuenta si menciona plin explicito // (el resto de notifs del banco -- movimientos, sesiones -- se ignoran). if (isPlinBank && !normalized.contains('plin')) return null; final amount = _parseAmount(normalized); if (amount == null || amount <= 0) return null; final rawText = '${title ?? ''}${title != null && text != null ? '. ' : ''}${text ?? ''}' .trim(); return CapturedPayment( provider: isYape ? 'YAPE' : 'PLIN', amount: amount, senderName: _parseSender(rawText), rawText: rawText, packageName: packageName, postedAtMillis: postedAtMillis, ); } /// Minusculas + sin tildes, para que las regex no dependan del acento /// exacto del copy ("envió" vs "envio"). static String _normalize(String value) { const withAccents = 'áéíóúüÁÉÍÓÚÜ'; const withoutAccents = 'aeiouuAEIOUU'; var result = value; for (var i = 0; i < withAccents.length; i++) { result = result.replaceAll(withAccents[i], withoutAccents[i]); } return result.toLowerCase(); } /// "S/ 1,000.50" -> 1000.50 · "S/ 12.50" -> 12.5 · "S/ 5" -> 5.0 static double? _parseAmount(String normalized) { final match = _amountPattern.firstMatch(normalized); if (match == null) return null; var raw = match.group(1)!; // Coma = separador de miles en el copy real de Yape ("S/ 1,000.50"). raw = raw.replaceAll(',', ''); // Un punto final de oracion pegado al numero ("S/ 35.") no es decimal. if (raw.endsWith('.')) raw = raw.substring(0, raw.length - 1); return double.tryParse(raw); } static String? _parseSender(String rawText) { final normalized = _normalize(rawText); final before = _senderBeforePattern.firstMatch(rawText); if (before != null) { final name = before.group(1)!.trim(); if (_looksLikeName(name)) return name; } if (normalized.contains('plin')) { final after = _senderAfterPattern.firstMatch(rawText); if (after != null) { final name = after.group(1)!.trim(); if (_looksLikeName(name)) return name; } } return null; } static bool _looksLikeName(String value) { if (value.length < 2 || value.length > 60) return false; final lower = value.toLowerCase(); // Palabras del copy que no son nombre. const noise = {'confirmacion', 'pago', 'yape', 'plin', 'recibiste'}; return !noise.contains(lower); } }