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

This commit is contained in:
Dmitry 2026-01-09 00:55:15 +03:00
parent 911384f3b5
commit 34eab39aa3
8 changed files with 600 additions and 21 deletions

View file

@ -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);
}

View file

@ -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

View file

@ -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,
};
}
}

View file

@ -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 {

View file

@ -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',
);
}
}

View file

@ -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
}

View file

@ -90,11 +90,66 @@ class _PaymentReturnPageState extends State<PaymentReturnPage> {
if (result.isSuccess) {
log(
'Payment verified successfully, emitting event',
'Payment verified successfully, checking pack access',
name: 'PaymentReturnPage',
);
// Отправить событие о завершении покупки
// Проверить, что 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); // Стандартная задержка
}
while (!accessGranted && retryCount < maxRetries) {
if (retryCount > 0) {
await Future<void>.delayed(getRetryDelay(retryCount));
}
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;
if (accessGranted) {
// Только после подтверждения доступа отправляем событие
log(
'Pack access confirmed, emitting purchase completed event',
name: 'PaymentReturnPage',
);
userScope.purchaseEventBroadcaster.onPurchaseCompleted(
packId: packId,
paymentId: paymentId,
@ -114,9 +169,22 @@ class _PaymentReturnPageState extends State<PaymentReturnPage> {
if (!mounted) return;
// Перенаправить на страницу пака
// Перенаправить на страницу пака с флагом покупки
log('Redirecting to pack: $packId', name: 'PaymentReturnPage');
context.go('/pack/$packId');
context.go('/pack/$packId?justPurchased=true');
} else {
// Если доступ не выдан после всех попыток, показываем сообщение
log(
'Pack access not granted after $maxRetries attempts',
name: 'PaymentReturnPage',
);
setState(() {
_errorMessage =
'Платеж подтвержден, но доступ к паку еще не выдан. Пожалуйста, подождите несколько минут и проверьте доступ к паку позже.';
_isVerifying = false;
});
return;
}
} else {
// Платеж не подтвержден
log(

View file

@ -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>()),
);
});
});
}