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-08 22:56:01 +03:00
parent 118f0e2ede
commit b95a3f048d
13 changed files with 563 additions and 19 deletions

View file

@ -75,6 +75,19 @@ class PaymentManager {
return payment?.toDto();
}
/// Получить последний платеж для пользователя и пака
/// Используется при возврате с ЮКассы без paymentId
Future<PaymentDto?> getLatestPaymentForUserAndPack({
required String userId,
required String packId,
}) async {
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack(
userId: userId,
packId: packId,
);
return payment?.toDto();
}
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
Future<void> grantProductToUser(
String userId,
@ -349,6 +362,7 @@ class PaymentManager {
required String description,
required String userId,
List<MnemoCardsProductDto> products = const [],
String? packId,
}) async {
print('🔍 PaymentManager.createYookassaUrl: Starting');
print(
@ -362,6 +376,7 @@ class PaymentManager {
amount: amount,
description: description,
userId: userId,
packId: packId,
);
print(
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',

View file

@ -78,6 +78,7 @@ class YooMoneyHandler {
required String amount,
required String description,
required String userId,
String? packId,
}) async {
if (!isConfigured) {
throw Exception(
@ -101,7 +102,7 @@ class YooMoneyHandler {
// Build return URL for payment confirmation
// According to spec: ReturnUrl - URL where user returns after payment
// Max 2048 characters per spec
final returnUrl = _buildReturnUrl(userId);
final returnUrl = _buildReturnUrl(userId, packId: packId);
// Generate idempotence key for request
// According to spec: Idempotence-Key header (required)
@ -117,6 +118,7 @@ class YooMoneyHandler {
'capture': true, // Auto-capture payment when succeeded
'metadata': {
'userId': userId,
if (packId != null) 'packId': packId,
'createdAt': DateTime.now().toIso8601String(),
},
};
@ -152,6 +154,11 @@ class YooMoneyHandler {
final paymentStatus = paymentData['status'] as String? ?? 'pending';
// Update return URL with paymentId (if needed)
// Note: YooKassa doesn't allow updating return_url after creation,
// so we include paymentId in the initial return URL via metadata or query params
// For now, we'll pass paymentId through the return URL query params
// Extract confirmation URL from payment response
// According to spec: confirmation.confirmation_url for redirect type
String? confirmationUrl;
@ -252,15 +259,31 @@ class YooMoneyHandler {
/// Build return URL for payment confirmation
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
String _buildReturnUrl(String userId) {
String _buildReturnUrl(
String userId, {
String? packId,
String? paymentId,
}) {
// Construct query parameters
final params = <String, String>{
'userId': userId,
if (packId != null) 'packId': packId,
if (paymentId != null) 'paymentId': paymentId,
};
final query = params.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&');
// Use configured return URL base if available
final returnUrlBase = _returnUrlBase;
if (returnUrlBase != null && returnUrlBase.isNotEmpty) {
return '$returnUrlBase?userId=$userId';
// Remove trailing slash and query params from base
final base = returnUrlBase.split('?').first.replaceAll(RegExp(r'/$'), '');
return '$base?$query';
}
// Default to web app URL
// This should be configured via environment variable in production
return 'https://mnemo-cards.online/payment/return?userId=$userId';
return 'https://mnemo-cards.online/payment/return?$query';
}
}

View file

@ -145,6 +145,7 @@ class PurchasesApiV2 {
description: 'Покупка пакета: ${pack.title}',
userId: user.id!,
products: products,
packId: packId,
);
print(
'✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}',
@ -291,6 +292,7 @@ class PurchasesApiV2 {
description: description,
userId: user.id!,
products: products,
packId: productType == MnemoCardsProductType.pack ? productId : null,
);
// Build return URL for payment verification
@ -328,8 +330,23 @@ class PurchasesApiV2 {
return _badRequest('productId query parameter is required');
}
// If paymentId is "latest", find the latest payment for this user and product
String actualPaymentId = paymentId;
if (paymentId == 'latest' && user.id != null) {
final latestPayment = await _paymentManager
.getLatestPaymentForUserAndPack(
userId: user.id!,
packId: productId,
);
if (latestPayment == null || latestPayment.externalToken == null) {
return _notFound('No payment found for this pack');
}
actualPaymentId = latestPayment.externalToken!;
}
// Check payment status
final isSuccess = await _paymentManager.checkYookassaPayment(paymentId);
final isSuccess =
await _paymentManager.checkYookassaPayment(actualPaymentId);
// Get product information
MnemoCardsProductDto? product;
@ -346,7 +363,7 @@ class PurchasesApiV2 {
}
return _ok({
'paymentId': paymentId,
'paymentId': actualPaymentId,
'status': isSuccess ? 'verified' : 'pending',
'result': isSuccess,
if (product != null) 'product': product.toJson(),

View file

@ -206,6 +206,21 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Получить последний платеж пользователя для продукта (пака)
/// Используется для получения paymentId при возврате с ЮКассы
Future<Payment?> getLatestPaymentForUserAndPack({
required String userId,
required String packId,
}) async {
final query = selectActive()
..where((p) => p.userId.equals(userId) & p.products.like('%"id":"$packId"%'))
..orderBy([(p) => OrderingTerm.desc(p.createdAt)])
..limit(1);
final results = await query.get();
return results.isNotEmpty ? results.first : null;
}
/// Подсчитать платежи пользователя (только активные)
Future<int> countPaymentsByUserId(String userId) async {
final countExpr = payments.id.count();

View file

@ -12,6 +12,7 @@ import '../../domain/services/image_cache_service.dart';
import '../../domain/services/pack_progress_service.dart';
import '../../domain/services/statistics_service.dart';
import '../../domain/services/purchases_service.dart';
import '../../domain/events/purchase_event_broadcaster.dart';
import '../../domain/state/ads_reward_state_manager.dart';
import '../../domain/state/favorites_state_manager.dart';
import '../../domain/state/games_state_manager.dart';
@ -82,6 +83,9 @@ abstract class UserScope implements Scope {
/// Statistics state manager
StatisticsStateManager get statisticsStateManager;
/// Purchase event broadcaster for purchase notifications
PurchaseEventBroadcaster get purchaseEventBroadcaster;
}
/// Interface for parent scope (AppScope)

View file

@ -9,6 +9,7 @@ import '../../domain/services/purchases_service.dart';
import '../../domain/services/pack_progress_service.dart';
import '../../domain/services/statistics_service.dart';
import '../../domain/services/ads_reward_service.dart';
import '../../domain/events/purchase_event_broadcaster.dart';
import '../../domain/state/ads_reward_state_manager.dart';
import '../../domain/state/favorites_state_manager.dart';
import '../../domain/state/games_state_manager.dart';
@ -80,6 +81,13 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
// Statistics Module
late final statisticsModuleDep = dep(() => StatisticsModule(this));
// Purchase Event Broadcaster
late final purchaseEventBroadcasterDep = rawAsyncDep(
() => PurchaseEventBroadcaster(),
init: (_) async {},
dispose: (broadcaster) async => broadcaster.dispose(),
);
@override
UserStateManager get userStateManager => userStateManagerDep.get;
@ -146,6 +154,10 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
StatisticsStateManager get statisticsStateManager =>
statisticsModuleDep.get.statisticsStateManager;
@override
PurchaseEventBroadcaster get purchaseEventBroadcaster =>
purchaseEventBroadcasterDep.get;
// Provide httpRepository from parent
HttpRepositoryV2 get httpRepository => parent.httpRepository;
}

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:developer';
/// Типы событий покупки
enum PurchaseEventType {
/// Покупка начата (платеж создан)
started,
/// Покупка завершена успешно
completed,
/// Покупка отменена или ошибка
failed,
}
/// Событие покупки
class PurchaseEvent {
final PurchaseEventType type;
final String? packId;
final String? paymentId;
final String? message;
final DateTime timestamp;
const PurchaseEvent({
required this.type,
this.packId,
this.paymentId,
this.message,
required this.timestamp,
});
@override
String toString() =>
'PurchaseEvent(${type.name}, pack: $packId, payment: $paymentId)';
}
/// Broadcaster для событий покупки
///
/// Используется для уведомления различных частей приложения
/// о событиях покупки (начало, завершение, ошибка)
class PurchaseEventBroadcaster {
final _controller = StreamController<PurchaseEvent>.broadcast();
/// Stream событий покупки
Stream<PurchaseEvent> get stream => _controller.stream;
/// Отправить событие о начале покупки
void onPurchaseStarted({
required String packId,
required String paymentId,
}) {
log(
'Purchase started: packId=$packId, paymentId=$paymentId',
name: 'PurchaseEventBroadcaster',
);
_controller.add(
PurchaseEvent(
type: PurchaseEventType.started,
packId: packId,
paymentId: paymentId,
timestamp: DateTime.now(),
),
);
}
/// Отправить событие о завершении покупки
void onPurchaseCompleted({
required String packId,
required String paymentId,
}) {
log(
'Purchase completed: packId=$packId, paymentId=$paymentId',
name: 'PurchaseEventBroadcaster',
);
_controller.add(
PurchaseEvent(
type: PurchaseEventType.completed,
packId: packId,
paymentId: paymentId,
timestamp: DateTime.now(),
),
);
}
/// Отправить событие об ошибке покупки
void onPurchaseFailed({
required String packId,
String? paymentId,
String? message,
}) {
log(
'Purchase failed: packId=$packId, paymentId=$paymentId, error=$message',
name: 'PurchaseEventBroadcaster',
);
_controller.add(
PurchaseEvent(
type: PurchaseEventType.failed,
packId: packId,
paymentId: paymentId,
message: message,
timestamp: DateTime.now(),
),
);
}
/// Закрыть broadcaster (вызывать в dispose)
void dispose() {
_controller.close();
}
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
@ -7,6 +8,7 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
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 '../../../presentation/theme/app_colors.dart';
import '../../../presentation/widgets/pack_card_item.dart';
import '../../../presentation/widgets/pack_details_header.dart';
@ -51,6 +53,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
Map<String, int> _previousCardIndexById = {};
int _lastAnimatedShuffleKey = 0;
bool _isNavigatingToPurchase = false;
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
@override
void initState() {
@ -58,6 +61,53 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
_loadPack();
_loadTests();
_loadPackProgress();
_subscribeToPurchaseEvents();
}
@override
void dispose() {
_purchaseEventSubscription?.cancel();
super.dispose();
}
void _subscribeToPurchaseEvents() {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
final userScope = appScope?.userScopeHolder.scope;
if (userScope == null) {
log(
'User scope not available, skipping purchase event subscription',
name: 'PackDetailsPage',
);
return;
}
_purchaseEventSubscription =
userScope.purchaseEventBroadcaster.stream.listen(
(event) {
// Перезагрузить данные только если событие относится к текущему паку
if (event.packId == widget.packId &&
event.type == PurchaseEventType.completed) {
log(
'Purchase completed for pack ${widget.packId}, reloading data',
name: 'PackDetailsPage',
);
_loadPack();
_loadPackProgress();
}
},
onError: (Object error, StackTrace stackTrace) {
log(
'Error in purchase event stream',
error: error,
stackTrace: stackTrace,
name: 'PackDetailsPage',
);
},
);
}
Future<void> _loadPack() async {

View file

@ -0,0 +1,228 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import '../../../di/app_scope/app_scope_container.dart';
/// Страница обработки возврата с ЮКассы
///
/// Автоматически верифицирует платеж и перенаправляет на страницу пака
/// Query parameters:
/// - userId: ID пользователя
/// - paymentId: ID платежа в ЮКассе
/// - packId: ID купленного пака
class PaymentReturnPage extends StatefulWidget {
const PaymentReturnPage({
required this.queryParams,
super.key,
});
final Map<String, String> queryParams;
@override
State<PaymentReturnPage> createState() => _PaymentReturnPageState();
}
class _PaymentReturnPageState extends State<PaymentReturnPage> {
String? _errorMessage;
bool _isVerifying = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_verifyAndRedirect();
});
}
Future<void> _verifyAndRedirect() async {
var paymentId = widget.queryParams['paymentId'];
final packId = widget.queryParams['packId'];
final userId = widget.queryParams['userId'];
log(
'Processing payment return: paymentId=$paymentId, packId=$packId, userId=$userId',
name: 'PaymentReturnPage',
);
// Валидация параметров
if (packId == null) {
setState(() {
_errorMessage =
'Некорректные параметры возврата. Пожалуйста, проверьте свои покупки в профиле.';
_isVerifying = false;
});
return;
}
// Если paymentId отсутствует, используем "latest" для поиска последнего платежа
if (paymentId == null) {
log(
'PaymentId not provided, will use latest payment for pack',
name: 'PaymentReturnPage',
);
paymentId = 'latest';
}
try {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
final userScope = appScope?.userScopeHolder.scope;
if (userScope == null) {
throw Exception('User scope не доступен');
}
// Верифицировать платеж
log('Verifying payment: $paymentId', name: 'PaymentReturnPage');
final result = await userScope.purchasesService.verifyPayment(
paymentId: paymentId,
productId: packId,
productType: MnemoCardsProductType.pack,
);
if (!mounted) return;
if (result.isSuccess) {
log(
'Payment verified successfully, emitting 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');
} else {
// Платеж не подтвержден
log(
'Payment verification failed: ${result.status}',
name: 'PaymentReturnPage',
);
setState(() {
_errorMessage =
'Платеж еще не подтвержден (${result.status}). Пожалуйста, подождите несколько минут и проверьте доступ к паку позже.';
_isVerifying = false;
});
}
} catch (e, s) {
log(
'Error verifying payment',
error: e,
stackTrace: s,
name: 'PaymentReturnPage',
);
if (!mounted) return;
setState(() {
_errorMessage =
'Ошибка проверки платежа: ${e.toString()}. Попробуйте проверить доступ к паку позже или обратитесь в поддержку.';
_isVerifying = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Обработка платежа'),
),
body: Center(
child: _isVerifying
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Проверяем платеж...',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Это может занять несколько секунд',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
)
: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Не удалось подтвердить платеж',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
_errorMessage ?? 'Произошла ошибка',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () => context.go('/home'),
child: const Text('На главную'),
),
const SizedBox(width: 16),
FilledButton(
onPressed: () {
setState(() {
_isVerifying = true;
_errorMessage = null;
});
_verifyAndRedirect();
},
child: const Text('Попробовать снова'),
),
],
),
],
),
),
),
);
}
}

View file

@ -96,6 +96,21 @@ class _PurchasePageState extends State<PurchasePage> {
return;
}
// Emit purchase started event
if (mounted) {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
final userScope = appScope?.userScopeHolder.scope;
if (userScope != null) {
userScope.purchaseEventBroadcaster.onPurchaseStarted(
packId: widget.packId,
paymentId: payment.paymentId,
);
}
}
// Open payment URL
final uri = Uri.parse(payment.purchaseUrl);
if (await canLaunchUrl(uri)) {

View file

@ -7,6 +7,7 @@ import '../pages/game/game_page.dart';
import '../pages/games/games_page.dart';
import '../pages/home/home_page.dart';
import '../pages/pack_details/pack_details_page.dart';
import '../pages/payment_return/payment_return_page.dart';
import '../pages/playground/playground_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/purchase/purchase_page.dart';
@ -133,6 +134,18 @@ GoRouter createAppRouter({required UserScopeHolder userScopeHolder}) {
},
),
// Payment Return Page (from YooKassa)
GoRoute(
path: '/payment/return',
name: 'payment-return',
pageBuilder: (context, state) {
final queryParams = state.uri.queryParameters;
return MaterialPage(
child: PaymentReturnPage(queryParams: queryParams),
);
},
),
// Test Page
GoRoute(
path: '/test/:testId',

View file

@ -1,7 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../../utils/card_image_utils.dart';
import '../../utils/color_extension.dart';
/// Кастомный заголовок для страницы деталей пака
@ -94,19 +96,7 @@ class PackDetailsHeader extends StatelessWidget {
const SizedBox(width: 16),
// Pack icon or image
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: packColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.collections_bookmark,
color: packColor,
size: 28,
),
),
_buildPackImage(packColor),
const SizedBox(width: 16),
@ -150,6 +140,53 @@ class PackDetailsHeader extends StatelessWidget {
);
}
Widget _buildPackImage(Color packColor) {
final imageUrl = CardImageUtils.getPackCoverUrlFromDto(pack);
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: packColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: 48,
height: 48,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: CircularProgressIndicator(
value: progress.progress,
strokeWidth: 2,
),
);
},
errorWidget: (context, url, error) {
return Center(
child: Icon(
Icons.collections_bookmark,
color: packColor,
size: 28,
),
);
},
),
)
: Center(
child: Icon(
Icons.collections_bookmark,
color: packColor,
size: 28,
),
),
);
}
Widget _buildProgressSection(
BuildContext context,
Color packColor,

View file

@ -80,4 +80,8 @@ class CardImageUtils {
// Fallback: use helper method
return ApiConfigV2.getPackCoverUrl(pack.id);
}
static String? getPackCoverUrlFromDto(CardPackDto pack) {
return ApiConfigV2.getPackCoverUrl(pack.id);
}
}