import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:uuid/uuid.dart'; /// Wrapper for YooKassa payment response class YookassaPayment { final String id; final String status; final String? confirmationUrl; YookassaPayment({ required this.id, required this.status, this.confirmationUrl, }); } /// Handler for YooKassa payment integration /// Implements YooKassa API v3 according to OpenAPI specification /// https://yookassa.ru/developers/using-api/openapi-specification /// Note: Registered via @module in modules.dart to provide environment variables class YooMoneyHandler { final String _shopId; final String _secretKey; final String? _returnUrlBase; late final Dio? _dio; final _uuid = const Uuid(); static const String _baseUrl = 'https://api.yookassa.ru/v3'; YooMoneyHandler({required String shopId, required String secretKey}) : _shopId = shopId, _secretKey = secretKey, _returnUrlBase = Platform.environment['YOOKASSA_RETURN_URL'] { // Validate credentials if (_shopId.isEmpty || _secretKey.isEmpty) { log( 'YooKassa credentials not configured. Payments will not work.', name: 'YooMoneyHandler', ); _dio = null; } else { // Initialize Dio with Basic Auth // According to spec: Basic Auth with shopId:secretKey _dio = Dio( BaseOptions( baseUrl: _baseUrl, headers: {'Content-Type': 'application/json'}, ), ); // Set up Basic Auth interceptor _dio!.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { // Basic Auth: base64(shopId:secretKey) final credentials = base64Encode( utf8.encode('$_shopId:$_secretKey'), ); options.headers['Authorization'] = 'Basic $credentials'; handler.next(options); }, ), ); } } /// Check if YooKassa is properly configured bool get isConfigured => _dio != null; /// Create a payment in YooKassa /// According to spec: POST /v3/payments /// Required: amount, description /// Optional: confirmation (redirect), receipt, metadata Future createPayment({ required String amount, required String description, required String userId, }) async { if (!isConfigured) { throw Exception( 'YooKassa is not configured. Please set YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY environment variables.', ); } try { // Parse amount - remove non-digit characters and format as decimal final amountValue = amount.replaceAll(RegExp(r'\D'), ''); if (amountValue.isEmpty) { throw Exception('Invalid amount: $amount'); } // Format amount as decimal string (e.g., "1000.00") // According to spec: MonetaryAmount.value must be decimal string final formattedAmount = amountValue.length > 2 ? '${amountValue.substring(0, amountValue.length - 2)}.${amountValue.substring(amountValue.length - 2)}' : '0.${amountValue.padLeft(2, '0')}'; // 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); // Generate idempotence key for request // According to spec: Idempotence-Key header (required) final idempotenceKey = _uuid.v4(); // Create payment request according to CreatePaymentRequest schema final requestBody = { 'amount': {'value': formattedAmount, 'currency': 'RUB'}, 'description': description.length > 128 ? description.substring(0, 128) : description, // Max 128 chars per spec 'confirmation': {'type': 'redirect', 'return_url': returnUrl}, 'capture': true, // Auto-capture payment when succeeded 'metadata': { 'userId': userId, 'createdAt': DateTime.now().toIso8601String(), }, }; log( 'Creating YooKassa payment', name: 'YooMoneyHandler', error: { 'amount': formattedAmount, 'description': description, 'userId': userId, }, ); // Create payment via YooKassa API // POST /v3/payments with Idempotence-Key header final response = await _dio!.post>( '/payments', data: requestBody, options: Options(headers: {'Idempotence-Key': idempotenceKey}), ); final paymentData = response.data; if (paymentData == null) { throw Exception('Empty response from YooKassa API'); } // Extract payment ID and status final paymentId = paymentData['id'] as String?; if (paymentId == null) { throw Exception('Payment ID not found in response'); } final paymentStatus = paymentData['status'] as String? ?? 'pending'; // Extract confirmation URL from payment response // According to spec: confirmation.confirmation_url for redirect type String? confirmationUrl; final confirmation = paymentData['confirmation'] as Map?; if (confirmation != null) { final confirmationType = confirmation['type'] as String?; if (confirmationType == 'redirect') { confirmationUrl = confirmation['confirmation_url'] as String?; } else { print('Payment created with confirmation type: $confirmationType'); } } if (confirmationUrl == null) { print('Warning: No confirmation URL in payment response'); } print('YooKassa payment created successfully'); return YookassaPayment( id: paymentId, status: paymentStatus, confirmationUrl: confirmationUrl, ); } on DioException catch (e, stackTrace) { print('YooKassa API error when creating payment $e $stackTrace'); if (e.response != null) { print('YooKassa error response: ${e.response?.data}'); } throw Exception( 'Failed to create YooKassa payment: ${e.message ?? 'Unknown error'}', ); } on Exception catch (e, stackTrace) { print('Unexpected error when creating YooKassa payment $e $stackTrace'); rethrow; } } /// Check payment status in YooKassa /// According to spec: GET /v3/payments/{payment_id} Future checkPayment(String paymentId) async { if (!isConfigured) { throw Exception( 'YooKassa is not configured. Please set YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY environment variables.', ); } try { print('Checking YooKassa payment status $paymentId'); // Get payment info from YooKassa API // According to spec: GET /v3/payments/{payment_id} final response = await _dio!.get>( '/payments/$paymentId', ); final paymentData = response.data; if (paymentData == null) { throw Exception('Empty response from YooKassa API'); } // Extract payment ID and status final id = paymentData['id'] as String? ?? paymentId; final paymentStatus = paymentData['status'] as String? ?? 'pending'; final paid = paymentData['paid'] as bool? ?? false; // Extract confirmation URL if available String? confirmationUrl; final confirmation = paymentData['confirmation'] as Map?; if (confirmation != null) { final confirmationType = confirmation['type'] as String?; if (confirmationType == 'redirect') { confirmationUrl = confirmation['confirmation_url'] as String?; } } print('''YooKassa payment status retrieved ${{'paymentId': id, 'status': paymentStatus, 'paid': paid}}'''); return YookassaPayment( id: id, status: paymentStatus, confirmationUrl: confirmationUrl, ); } on DioException catch (e, stackTrace) { print('YooKassa API error when checking payment $e $stackTrace'); if (e.response != null) { print('YooKassa error response: ${e.response?.data}'); } throw Exception( 'Failed to check YooKassa payment: ${e.message ?? 'Unknown error'}', ); } on Exception catch (e, stackTrace) { print('Unexpected error when checking YooKassa payment $e $stackTrace'); rethrow; } } /// Build return URL for payment confirmation /// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL String _buildReturnUrl(String userId) { // Use configured return URL base if available final returnUrlBase = _returnUrlBase; if (returnUrlBase != null && returnUrlBase.isNotEmpty) { return '$returnUrlBase?userId=$userId'; } // Default to web app URL // This should be configured via environment variable in production return 'https://mnemo-cards.online/payment/return?userId=$userId'; } }