diff --git a/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart index e5c9dd1..3d67204 100644 --- a/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/purchases_api_v2.dart @@ -63,6 +63,15 @@ class PurchasesApiV2 { headers: {'Content-Type': 'application/json'}, ); + Response _forbidden(String message) => Response( + 403, + body: jsonEncode({ + 'error': 'Forbidden', + 'message': message, + }), + headers: {'Content-Type': 'application/json'}, + ); + Response _notFound([String? message]) => Response.notFound( jsonEncode({ 'error': 'Not Found', @@ -430,5 +439,79 @@ class PurchasesApiV2 { } } + /// POST /api/v2/purchases/check-payment/{paymentId} + /// Force check payment status and process if succeeded + /// This endpoint allows frontend to trigger immediate payment verification + /// instead of waiting for the cron job (which runs every 5 minutes) + @Route.post('/purchases/check-payment/') + @OpenApiRouteHttp() + Future checkPayment(Request request, String paymentId) async { + try { + final user = request.user; + if (user == null || user.id == null) { + return _unauthorized(); + } + + developer.log( + 'Manual payment check requested: paymentId=$paymentId, userId=${user.id}', + name: 'PurchasesApiV2', + ); + + // Find payment by external token (YooKassa payment ID) + final payment = await _db.paymentDao.getPaymentByExternalToken(paymentId); + + if (payment == null) { + developer.log( + 'Payment not found: $paymentId', + name: 'PurchasesApiV2', + ); + return _notFound('Payment not found'); + } + + // Verify payment belongs to current user + if (payment.userId != user.id) { + developer.log( + 'Payment access denied: paymentId=$paymentId, ownerId=${payment.userId}, requesterId=${user.id}', + name: 'PurchasesApiV2', + ); + return _forbidden('Access denied'); + } + + developer.log( + 'Checking payment status: paymentId=$paymentId, currentStatus=${payment.status}', + name: 'PurchasesApiV2', + ); + + // Force check and process payment + await _paymentManager.checkAndProcessPayment(payment); + + // Get updated payment status + final updatedPayment = + await _db.paymentDao.getPaymentByExternalToken(paymentId); + + final finalStatus = updatedPayment?.status ?? payment.status; + + developer.log( + 'Payment check completed: paymentId=$paymentId, finalStatus=$finalStatus', + name: 'PurchasesApiV2', + ); + + return _ok({ + 'paymentId': paymentId, + 'status': finalStatus, + 'checked': true, + 'timestamp': DateTime.now().toIso8601String(), + }); + } catch (e, s) { + developer.log( + 'Error checking payment: $e', + error: e, + stackTrace: s, + name: 'PurchasesApiV2', + ); + return _internalServerError(e.toString()); + } + } + Router get router => _$PurchasesApiV2Router(this); } diff --git a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart index bd2b954..3ebd05d 100644 --- a/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/config/api_config_v2.dart @@ -204,6 +204,11 @@ class ApiConfigV2 { static String purchasesPaymentVerify(String paymentId) => '/purchases/payments/$paymentId/verify'; + /// POST /api/v2/purchases/check-payment/{paymentId} + /// Force check payment status and process if succeeded + static String purchasesCheckPayment(String paymentId) => + '/purchases/check-payment/$paymentId'; + // ==================== Subscriptions Endpoints ==================== /// GET /api/v2/subscriptions/plans diff --git a/mnemo_cards_web_v2/lib/domain/models/purchase_models.dart b/mnemo_cards_web_v2/lib/domain/models/purchase_models.dart index da975d0..7c0c078 100644 --- a/mnemo_cards_web_v2/lib/domain/models/purchase_models.dart +++ b/mnemo_cards_web_v2/lib/domain/models/purchase_models.dart @@ -7,15 +7,17 @@ class PackPurchaseStatus { required this.isPurchased, required this.purchased, required this.hasSubscriptionAccess, + this.isAvailable, }); final String packId; final bool isPurchased; final bool purchased; final bool hasSubscriptionAccess; + final bool? isAvailable; /// Whether the user can access the pack either via purchase or subscription. - bool get canAccess => isPurchased || hasSubscriptionAccess; + bool get canAccess => isAvailable ?? (isPurchased || hasSubscriptionAccess); factory PackPurchaseStatus.fromJson(Map json) { return PackPurchaseStatus( @@ -23,6 +25,7 @@ class PackPurchaseStatus { isPurchased: json['isPurchased'] as bool? ?? false, purchased: json['purchased'] as bool? ?? false, hasSubscriptionAccess: json['hasSubscriptionAccess'] as bool? ?? false, + isAvailable: json['isAvailable'] as bool?, ); } @@ -32,6 +35,7 @@ class PackPurchaseStatus { 'isPurchased': isPurchased, 'purchased': purchased, 'hasSubscriptionAccess': hasSubscriptionAccess, + if (isAvailable != null) 'isAvailable': isAvailable, }; } } diff --git a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart index c1c9685..c8a0454 100644 --- a/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart +++ b/mnemo_cards_web_v2/lib/domain/services/http_repository_v2.dart @@ -1285,6 +1285,48 @@ class HttpRepositoryV2 { } } + /// Force check payment status on backend. + /// Triggers immediate payment verification instead of waiting for cron job. + /// Returns the updated payment status. + Future> checkPayment(String paymentId) async { + try { + // Extract actual payment ID in case a full URL was passed + final actualPaymentId = _extractPaymentId(paymentId); + + log( + 'Forcing payment check: paymentId=$actualPaymentId', + name: 'HttpRepositoryV2', + ); + + final response = await _dio.post>( + ApiConfigV2.purchasesCheckPayment(actualPaymentId), + ); + + final data = response.data ?? const {}; + + log( + 'Payment check response: status=${data['status']}, checked=${data['checked']}', + name: 'HttpRepositoryV2', + ); + + return data; + } on DioException catch (e) { + log( + 'Error checking payment: ${e.message}', + error: e, + name: 'HttpRepositoryV2', + ); + + if (e.error is ApiException) { + rethrow; + } + throw NetworkException( + message: e.message ?? 'Network error', + originalError: e, + ); + } + } + /// Fetch processed purchases for the current user. Future> getUserPurchases() async { try { diff --git a/mnemo_cards_web_v2/lib/domain/services/pending_purchase_checker.dart b/mnemo_cards_web_v2/lib/domain/services/pending_purchase_checker.dart new file mode 100644 index 0000000..892df39 --- /dev/null +++ b/mnemo_cards_web_v2/lib/domain/services/pending_purchase_checker.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; + +import 'http_repository_v2.dart'; + +/// Service for checking pending purchases +/// +/// Automatically polls the backend to check if a pack that requires purchase +/// has become available (after payment is processed). +/// +/// Usage: +/// ```dart +/// final checker = PendingPurchaseChecker(httpRepository: repository); +/// checker.startCheckingPackAccess(packId, lastPaymentId).listen((response) { +/// if (response is CardPackDto) { +/// // Access granted! +/// } +/// }); +/// ``` +class PendingPurchaseChecker { + final HttpRepositoryV2 httpRepository; + + PendingPurchaseChecker({required this.httpRepository}); + + /// Start checking pack access status periodically + /// + /// [packId] - ID of the pack to check + /// [lastPaymentId] - Optional payment ID for force-checking payment status + /// + /// Returns a stream of pack responses. When pack becomes available, + /// emits CardPackDto and stops checking. + /// + /// Checks every 3 seconds for up to 20 attempts (1 minute total). + /// First attempt uses checkPayment endpoint if paymentId provided. + Stream startCheckingPackAccess( + String packId, { + String? lastPaymentId, + }) { + return _checkPackAccessPeriodically(packId, lastPaymentId: lastPaymentId); + } + + Stream _checkPackAccessPeriodically( + String packId, { + String? lastPaymentId, + }) async* { + const maxAttempts = 20; + const checkInterval = Duration(seconds: 3); + var attemptCount = 0; + + log( + 'Starting periodic pack access check: packId=$packId, paymentId=$lastPaymentId', + name: 'PendingPurchaseChecker', + ); + + // First attempt: force-check payment if paymentId is provided + if (lastPaymentId != null && lastPaymentId.isNotEmpty) { + try { + log( + 'Attempt 1: Force-checking payment status', + name: 'PendingPurchaseChecker', + ); + await httpRepository.checkPayment(lastPaymentId); + + // Small delay to let backend process + await Future.delayed(const Duration(milliseconds: 500)); + } catch (e) { + log( + 'Error force-checking payment: $e', + error: e, + name: 'PendingPurchaseChecker', + ); + // Continue with regular polling even if force-check fails + } + } + + // Regular polling loop + while (attemptCount < maxAttempts) { + attemptCount++; + + try { + log( + 'Attempt $attemptCount/$maxAttempts: Checking pack access', + name: 'PendingPurchaseChecker', + ); + + final response = await httpRepository.getPack(packId); + + // Check response type to see if access is granted + if (response.responseType == GetCardPackResponseType.dto) { + // Access granted! Return CardPackDto + log( + 'Pack access granted! Stopping check.', + name: 'PendingPurchaseChecker', + ); + yield response; + return; // Stop checking + } else { + // Still requires purchase (CardPackBuyDto) - keep checking + log( + 'Pack still requires purchase (type=${response.responseType.name}), continuing...', + name: 'PendingPurchaseChecker', + ); + } + + // Don't yield if still requires purchase - only yield when access granted + // This prevents unnecessary UI updates + + } catch (e, s) { + log( + 'Error checking pack access (attempt $attemptCount)', + error: e, + stackTrace: s, + name: 'PendingPurchaseChecker', + ); + // Continue checking even on error + } + + // Wait before next attempt (unless this was the last attempt) + if (attemptCount < maxAttempts) { + await Future.delayed(checkInterval); + } + } + + log( + 'Reached max attempts ($maxAttempts) without pack access. Stopping check.', + name: 'PendingPurchaseChecker', + ); + } +} + diff --git a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart index 901b83b..7949f1b 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/pack_details/pack_details_page.dart @@ -9,6 +9,7 @@ import 'package:yx_scope_flutter/yx_scope_flutter.dart'; import '../../../di/app_scope/app_scope_container.dart'; import '../../../domain/events/purchase_event_broadcaster.dart'; +import '../../../domain/services/pending_purchase_checker.dart'; import '../../../presentation/theme/app_colors.dart'; import '../../../presentation/widgets/pack_card_item.dart'; import '../../../presentation/widgets/pack_details_header.dart'; @@ -54,6 +55,7 @@ class _PackDetailsPageState extends State { int _lastAnimatedShuffleKey = 0; bool _isNavigatingToPurchase = false; StreamSubscription? _purchaseEventSubscription; + StreamSubscription? _pendingPurchaseSubscription; @override void initState() { @@ -62,11 +64,33 @@ class _PackDetailsPageState extends State { _loadTests(); _loadPackProgress(); _subscribeToPurchaseEvents(); + + // Проверить query параметр для принудительной перезагрузки + // Это дополнительная страховка на случай, если событие не дошло + WidgetsBinding.instance.addPostFrameCallback((_) { + final uri = GoRouterState.of(context).uri; + final justPurchased = uri.queryParameters['justPurchased'] == 'true'; + + if (justPurchased) { + // Небольшая задержка для гарантии, что событие успело отправиться + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + log( + 'Just purchased flag detected, reloading pack data', + name: 'PackDetailsPage', + ); + _loadPack(); + _loadPackProgress(); + } + }); + } + }); } @override void dispose() { _purchaseEventSubscription?.cancel(); + _stopPendingPurchaseCheck(); super.dispose(); } @@ -92,9 +116,10 @@ class _PackDetailsPageState extends State { if (event.packId == widget.packId && event.type == PurchaseEventType.completed) { log( - 'Purchase completed for pack ${widget.packId}, reloading data', + 'Purchase completed event received for pack ${widget.packId}, reloading data', name: 'PackDetailsPage', ); + // Перезагрузить данные (доступ уже подтвержден на backend) _loadPack(); _loadPackProgress(); } @@ -134,6 +159,14 @@ class _PackDetailsPageState extends State { _packResponse = packResponse; _isLoading = false; }); + + // If pack requires purchase, start checking for access + if (packResponse is CardPackBuyDto) { + _startPendingPurchaseCheck(); + } else { + // Pack is accessible, stop any ongoing check + _stopPendingPurchaseCheck(); + } } } catch (e, s) { log( @@ -1105,5 +1138,105 @@ class _PackDetailsPageState extends State { } } + /// Start checking for pending purchase completion + Future _startPendingPurchaseCheck() async { + _stopPendingPurchaseCheck(); + + final appScope = ScopeProvider.of( + context, + listen: false, + ); + + if (appScope == null) { + log( + 'Cannot start pending purchase check: app scope not available', + name: 'PackDetailsPage', + ); + return; + } + + log( + 'Starting pending purchase check for pack ${widget.packId}', + name: 'PackDetailsPage', + ); + + // Create checker and start monitoring + final checker = PendingPurchaseChecker( + httpRepository: appScope.httpRepository, + ); + + _pendingPurchaseSubscription = checker + .startCheckingPackAccess( + widget.packId, + lastPaymentId: null, // Could get from purchase history if available + ) + .listen( + (response) { + if (!mounted) return; + + // Access granted! + log( + 'Pending purchase completed for pack ${widget.packId}', + name: 'PackDetailsPage', + ); + + _handleAccessGranted(); + }, + onError: (Object error, StackTrace stackTrace) { + log( + 'Error in pending purchase check', + error: error, + stackTrace: stackTrace, + name: 'PackDetailsPage', + ); + }, + onDone: () { + log( + 'Pending purchase check completed', + name: 'PackDetailsPage', + ); + _stopPendingPurchaseCheck(); + }, + ); + } + + /// Stop checking for pending purchase + void _stopPendingPurchaseCheck() { + _pendingPurchaseSubscription?.cancel(); + _pendingPurchaseSubscription = null; + } + + /// Handle access granted after purchase + void _handleAccessGranted() { + final appScope = ScopeProvider.of( + context, + listen: false, + ); + final userScope = appScope?.userScopeHolder.scope; + + // Send event + if (userScope != null) { + userScope.purchaseEventBroadcaster.onPurchaseCompleted( + packId: widget.packId, + paymentId: 'auto-detected', + ); + } + + // Show success message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Покупка подтверждена! 🎉'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + + // Reload pack data + _loadPack(); + _loadPackProgress(); + } + } + // Старые методы управления удалены - заменены PackDetailsControls } diff --git a/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart b/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart index 697f0b6..44e199c 100644 --- a/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart +++ b/mnemo_cards_web_v2/lib/presentation/pages/payment_return/payment_return_page.dart @@ -90,33 +90,101 @@ class _PaymentReturnPageState extends State { if (result.isSuccess) { log( - 'Payment verified successfully, emitting event', + 'Payment verified successfully, checking pack access', name: 'PaymentReturnPage', ); - // Отправить событие о завершении покупки - userScope.purchaseEventBroadcaster.onPurchaseCompleted( - packId: packId, - paymentId: paymentId, - ); + // Проверить, что backend выдал доступ к паку + // Используем retry логику, так как processPayment может выполняться асинхронно + bool accessGranted = false; + int retryCount = 0; + const maxRetries = 10; // Увеличено с 5 до 10 + + // Сократить задержку для первых попыток + Duration getRetryDelay(int attempt) { + if (attempt < 3) { + return const Duration(milliseconds: 500); // Быстрые первые попытки + } + return const Duration(seconds: 1); // Стандартная задержка + } - // Показать успешное сообщение - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Покупка успешно завершена! 🎉'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); + while (!accessGranted && retryCount < maxRetries) { + if (retryCount > 0) { + await Future.delayed(getRetryDelay(retryCount)); + } - // Небольшая задержка для показа сообщения - await Future.delayed(const Duration(milliseconds: 500)); + if (!mounted) return; + + try { + final status = + await userScope.purchasesService.getPackPurchaseStatus(packId); + accessGranted = status.isAvailable ?? status.canAccess; + + if (accessGranted) { + log( + 'Pack access confirmed after ${retryCount + 1} attempt(s)', + name: 'PaymentReturnPage', + ); + break; + } else { + log( + 'Pack access not yet granted, retrying... (${retryCount + 1}/$maxRetries)', + name: 'PaymentReturnPage', + ); + } + } catch (e) { + log( + 'Error checking pack access: $e', + name: 'PaymentReturnPage', + ); + } + + retryCount++; + } if (!mounted) return; - // Перенаправить на страницу пака - log('Redirecting to pack: $packId', name: 'PaymentReturnPage'); - context.go('/pack/$packId'); + if (accessGranted) { + // Только после подтверждения доступа отправляем событие + log( + 'Pack access confirmed, emitting purchase completed event', + name: 'PaymentReturnPage', + ); + userScope.purchaseEventBroadcaster.onPurchaseCompleted( + packId: packId, + paymentId: paymentId, + ); + + // Показать успешное сообщение + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Покупка успешно завершена! 🎉'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + + // Небольшая задержка для показа сообщения + await Future.delayed(const Duration(milliseconds: 500)); + + if (!mounted) return; + + // Перенаправить на страницу пака с флагом покупки + log('Redirecting to pack: $packId', name: 'PaymentReturnPage'); + context.go('/pack/$packId?justPurchased=true'); + } else { + // Если доступ не выдан после всех попыток, показываем сообщение + log( + 'Pack access not granted after $maxRetries attempts', + name: 'PaymentReturnPage', + ); + setState(() { + _errorMessage = + 'Платеж подтвержден, но доступ к паку еще не выдан. Пожалуйста, подождите несколько минут и проверьте доступ к паку позже.'; + _isVerifying = false; + }); + return; + } } else { // Платеж не подтвержден log( diff --git a/mnemo_cards_web_v2/test/domain/services/pending_purchase_checker_test.dart b/mnemo_cards_web_v2/test/domain/services/pending_purchase_checker_test.dart new file mode 100644 index 0000000..8750380 --- /dev/null +++ b/mnemo_cards_web_v2/test/domain/services/pending_purchase_checker_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:mnemo_cards_common/mnemo_cards_common.dart'; +import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart'; +import 'package:mnemo_cards_web_v2/domain/services/pending_purchase_checker.dart'; + +class _MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {} + +void main() { + late _MockHttpRepositoryV2 mockRepository; + late PendingPurchaseChecker checker; + + setUp(() { + mockRepository = _MockHttpRepositoryV2(); + checker = PendingPurchaseChecker(httpRepository: mockRepository); + }); + + group('PendingPurchaseChecker', () { + test('should emit CardPackDto when access is granted immediately', () async { + // Arrange + const packId = 'test-pack'; + final cardPackDto = CardPackDto( + id: packId, + title: 'Test Pack', + subtitle: 'Test', + cards: const [], + color: null, + version: null, + ); + + when(() => mockRepository.checkPayment(any())) + .thenAnswer((_) async => {}); + when(() => mockRepository.getPack(packId)) + .thenAnswer((_) async => cardPackDto); + + // Act + final stream = checker.startCheckingPackAccess(packId); + + // Assert + await expectLater( + stream, + emits(isA()), + ); + + verify(() => mockRepository.getPack(packId)).called(1); + }); + + test('should call checkPayment when paymentId provided', () async { + // Arrange + const packId = 'test-pack'; + const paymentId = 'payment-123'; + final cardPackDto = CardPackDto( + id: packId, + title: 'Test Pack', + subtitle: 'Test', + cards: const [], + color: null, + version: null, + ); + + when(() => mockRepository.checkPayment(paymentId)) + .thenAnswer((_) async => {'status': 'succeeded'}); + when(() => mockRepository.getPack(packId)) + .thenAnswer((_) async => cardPackDto); + + // Act + final stream = checker.startCheckingPackAccess( + packId, + lastPaymentId: paymentId, + ); + + await stream.first; + + // Assert + verify(() => mockRepository.checkPayment(paymentId)).called(1); + }); + + test('should handle errors gracefully and continue checking', () async { + // Arrange + const packId = 'test-pack'; + final cardPackDto = CardPackDto( + id: packId, + title: 'Test Pack', + subtitle: 'Test', + cards: const [], + color: null, + version: null, + ); + + when(() => mockRepository.checkPayment(any())) + .thenAnswer((_) async => {}); + + var callCount = 0; + when(() => mockRepository.getPack(packId)).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw Exception('Network error'); + } + return cardPackDto; + }); + + // Act + final stream = checker.startCheckingPackAccess(packId); + + // Assert - should still succeed despite first error + await expectLater( + stream, + emits(isA()), + ); + }); + }); +}