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
118f0e2ede
commit
b95a3f048d
13 changed files with 563 additions and 19 deletions
|
|
@ -75,6 +75,19 @@ class PaymentManager {
|
||||||
return payment?.toDto();
|
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(
|
Future<void> grantProductToUser(
|
||||||
String userId,
|
String userId,
|
||||||
|
|
@ -349,6 +362,7 @@ class PaymentManager {
|
||||||
required String description,
|
required String description,
|
||||||
required String userId,
|
required String userId,
|
||||||
List<MnemoCardsProductDto> products = const [],
|
List<MnemoCardsProductDto> products = const [],
|
||||||
|
String? packId,
|
||||||
}) async {
|
}) async {
|
||||||
print('🔍 PaymentManager.createYookassaUrl: Starting');
|
print('🔍 PaymentManager.createYookassaUrl: Starting');
|
||||||
print(
|
print(
|
||||||
|
|
@ -362,6 +376,7 @@ class PaymentManager {
|
||||||
amount: amount,
|
amount: amount,
|
||||||
description: description,
|
description: description,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
packId: packId,
|
||||||
);
|
);
|
||||||
print(
|
print(
|
||||||
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',
|
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class YooMoneyHandler {
|
||||||
required String amount,
|
required String amount,
|
||||||
required String description,
|
required String description,
|
||||||
required String userId,
|
required String userId,
|
||||||
|
String? packId,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
|
@ -101,7 +102,7 @@ class YooMoneyHandler {
|
||||||
// Build return URL for payment confirmation
|
// Build return URL for payment confirmation
|
||||||
// According to spec: ReturnUrl - URL where user returns after payment
|
// According to spec: ReturnUrl - URL where user returns after payment
|
||||||
// Max 2048 characters per spec
|
// Max 2048 characters per spec
|
||||||
final returnUrl = _buildReturnUrl(userId);
|
final returnUrl = _buildReturnUrl(userId, packId: packId);
|
||||||
|
|
||||||
// Generate idempotence key for request
|
// Generate idempotence key for request
|
||||||
// According to spec: Idempotence-Key header (required)
|
// According to spec: Idempotence-Key header (required)
|
||||||
|
|
@ -117,6 +118,7 @@ class YooMoneyHandler {
|
||||||
'capture': true, // Auto-capture payment when succeeded
|
'capture': true, // Auto-capture payment when succeeded
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
|
if (packId != null) 'packId': packId,
|
||||||
'createdAt': DateTime.now().toIso8601String(),
|
'createdAt': DateTime.now().toIso8601String(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -152,6 +154,11 @@ class YooMoneyHandler {
|
||||||
|
|
||||||
final paymentStatus = paymentData['status'] as String? ?? 'pending';
|
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
|
// Extract confirmation URL from payment response
|
||||||
// According to spec: confirmation.confirmation_url for redirect type
|
// According to spec: confirmation.confirmation_url for redirect type
|
||||||
String? confirmationUrl;
|
String? confirmationUrl;
|
||||||
|
|
@ -252,15 +259,31 @@ class YooMoneyHandler {
|
||||||
|
|
||||||
/// Build return URL for payment confirmation
|
/// Build return URL for payment confirmation
|
||||||
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
|
/// 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
|
// Use configured return URL base if available
|
||||||
final returnUrlBase = _returnUrlBase;
|
final returnUrlBase = _returnUrlBase;
|
||||||
if (returnUrlBase != null && returnUrlBase.isNotEmpty) {
|
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
|
// Default to web app URL
|
||||||
// This should be configured via environment variable in production
|
// 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ class PurchasesApiV2 {
|
||||||
description: 'Покупка пакета: ${pack.title}',
|
description: 'Покупка пакета: ${pack.title}',
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
products: products,
|
products: products,
|
||||||
|
packId: packId,
|
||||||
);
|
);
|
||||||
print(
|
print(
|
||||||
'✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}',
|
'✅ createPackPurchase: Payment URL created: ${paymentResult.confirmationUrl}, paymentId=${paymentResult.paymentId}',
|
||||||
|
|
@ -291,6 +292,7 @@ class PurchasesApiV2 {
|
||||||
description: description,
|
description: description,
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
products: products,
|
products: products,
|
||||||
|
packId: productType == MnemoCardsProductType.pack ? productId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build return URL for payment verification
|
// Build return URL for payment verification
|
||||||
|
|
@ -328,8 +330,23 @@ class PurchasesApiV2 {
|
||||||
return _badRequest('productId query parameter is required');
|
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
|
// Check payment status
|
||||||
final isSuccess = await _paymentManager.checkYookassaPayment(paymentId);
|
final isSuccess =
|
||||||
|
await _paymentManager.checkYookassaPayment(actualPaymentId);
|
||||||
|
|
||||||
// Get product information
|
// Get product information
|
||||||
MnemoCardsProductDto? product;
|
MnemoCardsProductDto? product;
|
||||||
|
|
@ -346,7 +363,7 @@ class PurchasesApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
return _ok({
|
return _ok({
|
||||||
'paymentId': paymentId,
|
'paymentId': actualPaymentId,
|
||||||
'status': isSuccess ? 'verified' : 'pending',
|
'status': isSuccess ? 'verified' : 'pending',
|
||||||
'result': isSuccess,
|
'result': isSuccess,
|
||||||
if (product != null) 'product': product.toJson(),
|
if (product != null) 'product': product.toJson(),
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,21 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
|
||||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
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 {
|
Future<int> countPaymentsByUserId(String userId) async {
|
||||||
final countExpr = payments.id.count();
|
final countExpr = payments.id.count();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../domain/services/image_cache_service.dart';
|
||||||
import '../../domain/services/pack_progress_service.dart';
|
import '../../domain/services/pack_progress_service.dart';
|
||||||
import '../../domain/services/statistics_service.dart';
|
import '../../domain/services/statistics_service.dart';
|
||||||
import '../../domain/services/purchases_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/ads_reward_state_manager.dart';
|
||||||
import '../../domain/state/favorites_state_manager.dart';
|
import '../../domain/state/favorites_state_manager.dart';
|
||||||
import '../../domain/state/games_state_manager.dart';
|
import '../../domain/state/games_state_manager.dart';
|
||||||
|
|
@ -82,6 +83,9 @@ abstract class UserScope implements Scope {
|
||||||
|
|
||||||
/// Statistics state manager
|
/// Statistics state manager
|
||||||
StatisticsStateManager get statisticsStateManager;
|
StatisticsStateManager get statisticsStateManager;
|
||||||
|
|
||||||
|
/// Purchase event broadcaster for purchase notifications
|
||||||
|
PurchaseEventBroadcaster get purchaseEventBroadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interface for parent scope (AppScope)
|
/// Interface for parent scope (AppScope)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../../domain/services/purchases_service.dart';
|
||||||
import '../../domain/services/pack_progress_service.dart';
|
import '../../domain/services/pack_progress_service.dart';
|
||||||
import '../../domain/services/statistics_service.dart';
|
import '../../domain/services/statistics_service.dart';
|
||||||
import '../../domain/services/ads_reward_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/ads_reward_state_manager.dart';
|
||||||
import '../../domain/state/favorites_state_manager.dart';
|
import '../../domain/state/favorites_state_manager.dart';
|
||||||
import '../../domain/state/games_state_manager.dart';
|
import '../../domain/state/games_state_manager.dart';
|
||||||
|
|
@ -80,6 +81,13 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
|
||||||
// Statistics Module
|
// Statistics Module
|
||||||
late final statisticsModuleDep = dep(() => StatisticsModule(this));
|
late final statisticsModuleDep = dep(() => StatisticsModule(this));
|
||||||
|
|
||||||
|
// Purchase Event Broadcaster
|
||||||
|
late final purchaseEventBroadcasterDep = rawAsyncDep(
|
||||||
|
() => PurchaseEventBroadcaster(),
|
||||||
|
init: (_) async {},
|
||||||
|
dispose: (broadcaster) async => broadcaster.dispose(),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
UserStateManager get userStateManager => userStateManagerDep.get;
|
UserStateManager get userStateManager => userStateManagerDep.get;
|
||||||
|
|
||||||
|
|
@ -146,6 +154,10 @@ class UserScopeContainer extends ChildScopeContainer<UserScopeParent>
|
||||||
StatisticsStateManager get statisticsStateManager =>
|
StatisticsStateManager get statisticsStateManager =>
|
||||||
statisticsModuleDep.get.statisticsStateManager;
|
statisticsModuleDep.get.statisticsStateManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PurchaseEventBroadcaster get purchaseEventBroadcaster =>
|
||||||
|
purchaseEventBroadcasterDep.get;
|
||||||
|
|
||||||
// Provide httpRepository from parent
|
// Provide httpRepository from parent
|
||||||
HttpRepositoryV2 get httpRepository => parent.httpRepository;
|
HttpRepositoryV2 get httpRepository => parent.httpRepository;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
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 '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 '../../../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';
|
||||||
|
|
@ -51,6 +53,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
Map<String, int> _previousCardIndexById = {};
|
Map<String, int> _previousCardIndexById = {};
|
||||||
int _lastAnimatedShuffleKey = 0;
|
int _lastAnimatedShuffleKey = 0;
|
||||||
bool _isNavigatingToPurchase = false;
|
bool _isNavigatingToPurchase = false;
|
||||||
|
StreamSubscription<PurchaseEvent>? _purchaseEventSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -58,6 +61,53 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
_loadPack();
|
_loadPack();
|
||||||
_loadTests();
|
_loadTests();
|
||||||
_loadPackProgress();
|
_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 {
|
Future<void> _loadPack() async {
|
||||||
|
|
|
||||||
|
|
@ -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('Попробовать снова'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +96,21 @@ class _PurchasePageState extends State<PurchasePage> {
|
||||||
return;
|
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
|
// Open payment URL
|
||||||
final uri = Uri.parse(payment.purchaseUrl);
|
final uri = Uri.parse(payment.purchaseUrl);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../pages/game/game_page.dart';
|
||||||
import '../pages/games/games_page.dart';
|
import '../pages/games/games_page.dart';
|
||||||
import '../pages/home/home_page.dart';
|
import '../pages/home/home_page.dart';
|
||||||
import '../pages/pack_details/pack_details_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/playground/playground_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
import '../pages/purchase/purchase_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
|
// Test Page
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/test/:testId',
|
path: '/test/:testId',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
|
import '../../utils/card_image_utils.dart';
|
||||||
import '../../utils/color_extension.dart';
|
import '../../utils/color_extension.dart';
|
||||||
|
|
||||||
/// Кастомный заголовок для страницы деталей пака
|
/// Кастомный заголовок для страницы деталей пака
|
||||||
|
|
@ -94,19 +96,7 @@ class PackDetailsHeader extends StatelessWidget {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// Pack icon or image
|
// Pack icon or image
|
||||||
Container(
|
_buildPackImage(packColor),
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: packColor.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.collections_bookmark,
|
|
||||||
color: packColor,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: 16),
|
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(
|
Widget _buildProgressSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Color packColor,
|
Color packColor,
|
||||||
|
|
|
||||||
|
|
@ -80,4 +80,8 @@ class CardImageUtils {
|
||||||
// Fallback: use helper method
|
// Fallback: use helper method
|
||||||
return ApiConfigV2.getPackCoverUrl(pack.id);
|
return ApiConfigV2.getPackCoverUrl(pack.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? getPackCoverUrlFromDto(CardPackDto pack) {
|
||||||
|
return ApiConfigV2.getPackCoverUrl(pack.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue