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'},
|
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(
|
Response _notFound([String? message]) => Response.notFound(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'error': 'Not Found',
|
'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);
|
Router get router => _$PurchasesApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,11 @@ class ApiConfigV2 {
|
||||||
static String purchasesPaymentVerify(String paymentId) =>
|
static String purchasesPaymentVerify(String paymentId) =>
|
||||||
'/purchases/payments/$paymentId/verify';
|
'/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 ====================
|
// ==================== Subscriptions Endpoints ====================
|
||||||
|
|
||||||
/// GET /api/v2/subscriptions/plans
|
/// GET /api/v2/subscriptions/plans
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,17 @@ class PackPurchaseStatus {
|
||||||
required this.isPurchased,
|
required this.isPurchased,
|
||||||
required this.purchased,
|
required this.purchased,
|
||||||
required this.hasSubscriptionAccess,
|
required this.hasSubscriptionAccess,
|
||||||
|
this.isAvailable,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String packId;
|
final String packId;
|
||||||
final bool isPurchased;
|
final bool isPurchased;
|
||||||
final bool purchased;
|
final bool purchased;
|
||||||
final bool hasSubscriptionAccess;
|
final bool hasSubscriptionAccess;
|
||||||
|
final bool? isAvailable;
|
||||||
|
|
||||||
/// Whether the user can access the pack either via purchase or subscription.
|
/// 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) {
|
factory PackPurchaseStatus.fromJson(Map<String, dynamic> json) {
|
||||||
return PackPurchaseStatus(
|
return PackPurchaseStatus(
|
||||||
|
|
@ -23,6 +25,7 @@ class PackPurchaseStatus {
|
||||||
isPurchased: json['isPurchased'] as bool? ?? false,
|
isPurchased: json['isPurchased'] as bool? ?? false,
|
||||||
purchased: json['purchased'] as bool? ?? false,
|
purchased: json['purchased'] as bool? ?? false,
|
||||||
hasSubscriptionAccess: json['hasSubscriptionAccess'] as bool? ?? false,
|
hasSubscriptionAccess: json['hasSubscriptionAccess'] as bool? ?? false,
|
||||||
|
isAvailable: json['isAvailable'] as bool?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +35,7 @@ class PackPurchaseStatus {
|
||||||
'isPurchased': isPurchased,
|
'isPurchased': isPurchased,
|
||||||
'purchased': purchased,
|
'purchased': purchased,
|
||||||
'hasSubscriptionAccess': hasSubscriptionAccess,
|
'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.
|
/// Fetch processed purchases for the current user.
|
||||||
Future<List<PaymentDto>> getUserPurchases() async {
|
Future<List<PaymentDto>> getUserPurchases() async {
|
||||||
try {
|
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 '../../../di/app_scope/app_scope_container.dart';
|
||||||
import '../../../domain/events/purchase_event_broadcaster.dart';
|
import '../../../domain/events/purchase_event_broadcaster.dart';
|
||||||
|
import '../../../domain/services/pending_purchase_checker.dart';
|
||||||
import '../../../presentation/theme/app_colors.dart';
|
import '../../../presentation/theme/app_colors.dart';
|
||||||
import '../../../presentation/widgets/pack_card_item.dart';
|
import '../../../presentation/widgets/pack_card_item.dart';
|
||||||
import '../../../presentation/widgets/pack_details_header.dart';
|
import '../../../presentation/widgets/pack_details_header.dart';
|
||||||
|
|
@ -54,6 +55,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
int _lastAnimatedShuffleKey = 0;
|
int _lastAnimatedShuffleKey = 0;
|
||||||
bool _isNavigatingToPurchase = false;
|
bool _isNavigatingToPurchase = false;
|
||||||
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
|
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
|
||||||
|
StreamSubscription<GetCardPackResponse>? _pendingPurchaseSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -62,11 +64,33 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
_loadTests();
|
_loadTests();
|
||||||
_loadPackProgress();
|
_loadPackProgress();
|
||||||
_subscribeToPurchaseEvents();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_purchaseEventSubscription?.cancel();
|
_purchaseEventSubscription?.cancel();
|
||||||
|
_stopPendingPurchaseCheck();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,9 +116,10 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
if (event.packId == widget.packId &&
|
if (event.packId == widget.packId &&
|
||||||
event.type == PurchaseEventType.completed) {
|
event.type == PurchaseEventType.completed) {
|
||||||
log(
|
log(
|
||||||
'Purchase completed for pack ${widget.packId}, reloading data',
|
'Purchase completed event received for pack ${widget.packId}, reloading data',
|
||||||
name: 'PackDetailsPage',
|
name: 'PackDetailsPage',
|
||||||
);
|
);
|
||||||
|
// Перезагрузить данные (доступ уже подтвержден на backend)
|
||||||
_loadPack();
|
_loadPack();
|
||||||
_loadPackProgress();
|
_loadPackProgress();
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +159,14 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
_packResponse = packResponse;
|
_packResponse = packResponse;
|
||||||
_isLoading = false;
|
_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) {
|
} catch (e, s) {
|
||||||
log(
|
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
|
// Старые методы управления удалены - заменены PackDetailsControls
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,33 +90,101 @@ class _PaymentReturnPageState extends State<PaymentReturnPage> {
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
log(
|
log(
|
||||||
'Payment verified successfully, emitting event',
|
'Payment verified successfully, checking pack access',
|
||||||
name: 'PaymentReturnPage',
|
name: 'PaymentReturnPage',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Отправить событие о завершении покупки
|
// Проверить, что backend выдал доступ к паку
|
||||||
userScope.purchaseEventBroadcaster.onPurchaseCompleted(
|
// Используем retry логику, так как processPayment может выполняться асинхронно
|
||||||
packId: packId,
|
bool accessGranted = false;
|
||||||
paymentId: paymentId,
|
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); // Стандартная задержка
|
||||||
|
}
|
||||||
|
|
||||||
// Показать успешное сообщение
|
while (!accessGranted && retryCount < maxRetries) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (retryCount > 0) {
|
||||||
const SnackBar(
|
await Future<void>.delayed(getRetryDelay(retryCount));
|
||||||
content: Text('Покупка успешно завершена! 🎉'),
|
}
|
||||||
backgroundColor: Colors.green,
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Небольшая задержка для показа сообщения
|
if (!mounted) return;
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
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;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Перенаправить на страницу пака
|
if (accessGranted) {
|
||||||
log('Redirecting to pack: $packId', name: 'PaymentReturnPage');
|
// Только после подтверждения доступа отправляем событие
|
||||||
context.go('/pack/$packId');
|
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 {
|
} else {
|
||||||
// Платеж не подтвержден
|
// Платеж не подтвержден
|
||||||
log(
|
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