payment
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
911384f3b5
commit
34eab39aa3
8 changed files with 600 additions and 21 deletions
|
|
@ -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/<paymentId>')
|
||||
@OpenApiRouteHttp()
|
||||
Future<Response> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, dynamic>> 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<Map<String, dynamic>>(
|
||||
ApiConfigV2.purchasesCheckPayment(actualPaymentId),
|
||||
);
|
||||
|
||||
final data = response.data ?? const <String, dynamic>{};
|
||||
|
||||
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<List<PaymentDto>> getUserPurchases() async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<GetCardPackResponse> startCheckingPackAccess(
|
||||
String packId, {
|
||||
String? lastPaymentId,
|
||||
}) {
|
||||
return _checkPackAccessPeriodically(packId, lastPaymentId: lastPaymentId);
|
||||
}
|
||||
|
||||
Stream<GetCardPackResponse> _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<void>.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<void>.delayed(checkInterval);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
'Reached max attempts ($maxAttempts) without pack access. Stopping check.',
|
||||
name: 'PendingPurchaseChecker',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<PackDetailsPage> {
|
|||
int _lastAnimatedShuffleKey = 0;
|
||||
bool _isNavigatingToPurchase = false;
|
||||
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
|
||||
StreamSubscription<GetCardPackResponse>? _pendingPurchaseSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -62,11 +64,33 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
|||
_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<PackDetailsPage> {
|
|||
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<PackDetailsPage> {
|
|||
_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<PackDetailsPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Start checking for pending purchase completion
|
||||
Future<void> _startPendingPurchaseCheck() async {
|
||||
_stopPendingPurchaseCheck();
|
||||
|
||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||
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<AppScopeContainer>(
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,33 +90,101 @@ class _PaymentReturnPageState extends State<PaymentReturnPage> {
|
|||
|
||||
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<void>.delayed(getRetryDelay(retryCount));
|
||||
}
|
||||
|
||||
// Небольшая задержка для показа сообщения
|
||||
await Future<void>.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<void>.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(
|
||||
|
|
|
|||
|
|
@ -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 => <String, dynamic>{});
|
||||
when(() => mockRepository.getPack(packId))
|
||||
.thenAnswer((_) async => cardPackDto);
|
||||
|
||||
// Act
|
||||
final stream = checker.startCheckingPackAccess(packId);
|
||||
|
||||
// Assert
|
||||
await expectLater(
|
||||
stream,
|
||||
emits(isA<CardPackDto>()),
|
||||
);
|
||||
|
||||
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 => <String, dynamic>{});
|
||||
|
||||
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<CardPackDto>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue