mnemo_cards/mnemo_cards_backend/lib/api/purchase/yoo_money.dart

290 lines
9.7 KiB
Dart
Raw Normal View History

2026-01-03 13:14:27 +00:00
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
2025-11-16 11:25:27 +00:00
2026-01-03 13:14:27 +00:00
/// Wrapper for YooKassa payment response
2025-12-13 13:27:05 +00:00
class YookassaPayment {
final String id;
final String status;
final String? confirmationUrl;
YookassaPayment({
required this.id,
required this.status,
this.confirmationUrl,
});
}
2025-11-16 11:25:27 +00:00
2026-01-03 13:14:27 +00:00
/// Handler for YooKassa payment integration
/// Implements YooKassa API v3 according to OpenAPI specification
/// https://yookassa.ru/developers/using-api/openapi-specification
2026-01-03 14:05:17 +00:00
/// Note: Registered via @module in modules.dart to provide environment variables
2025-11-16 11:25:27 +00:00
class YooMoneyHandler {
2025-12-13 13:27:05 +00:00
final String _shopId;
final String _secretKey;
2026-01-03 13:14:27 +00:00
final String? _returnUrlBase;
2026-01-03 13:57:30 +00:00
late final Dio? _dio;
2026-01-03 13:14:27 +00:00
final _uuid = const Uuid();
2026-01-03 13:57:30 +00:00
static const String _baseUrl = 'https://api.yookassa.ru/v3';
2026-01-03 13:14:27 +00:00
2026-01-08 13:02:47 +00:00
YooMoneyHandler({required String shopId, required String secretKey})
: _shopId = shopId,
_secretKey = secretKey,
_returnUrlBase = Platform.environment['YOOKASSA_RETURN_URL'] {
2026-01-03 13:14:27 +00:00
// Validate credentials
if (_shopId.isEmpty || _secretKey.isEmpty) {
log(
'YooKassa credentials not configured. Payments will not work.',
name: 'YooMoneyHandler',
);
2026-01-03 13:57:30 +00:00
_dio = null;
2026-01-03 13:14:27 +00:00
} else {
2026-01-03 13:57:30 +00:00
// Initialize Dio with Basic Auth
2026-01-03 13:14:27 +00:00
// According to spec: Basic Auth with shopId:secretKey
2026-01-03 13:57:30 +00:00
_dio = Dio(
BaseOptions(
baseUrl: _baseUrl,
2026-01-08 13:02:47 +00:00
headers: {'Content-Type': 'application/json'},
2026-01-03 13:57:30 +00:00
),
);
// 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);
},
2026-01-03 13:14:27 +00:00
),
);
}
}
2025-12-13 13:27:05 +00:00
2026-01-03 13:14:27 +00:00
/// Check if YooKassa is properly configured
2026-01-03 13:57:30 +00:00
bool get isConfigured => _dio != null;
2025-12-13 13:27:05 +00:00
2026-01-03 13:14:27 +00:00
/// Create a payment in YooKassa
/// According to spec: POST /v3/payments
/// Required: amount, description
/// Optional: confirmation (redirect), receipt, metadata
2025-12-13 13:27:05 +00:00
Future<YookassaPayment> createPayment({
required String amount,
required String description,
required String userId,
2026-01-08 19:56:01 +00:00
String? packId,
2025-11-16 11:25:27 +00:00
}) async {
2026-01-03 13:14:27 +00:00
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
2026-01-08 19:56:01 +00:00
final returnUrl = _buildReturnUrl(userId, packId: packId);
2026-01-03 13:14:27 +00:00
2026-01-03 13:57:30 +00:00
// Generate idempotence key for request
// According to spec: Idempotence-Key header (required)
final idempotenceKey = _uuid.v4();
2026-01-03 13:14:27 +00:00
// Create payment request according to CreatePaymentRequest schema
2026-01-03 13:57:30 +00:00
final requestBody = {
2026-01-08 13:02:47 +00:00
'amount': {'value': formattedAmount, 'currency': 'RUB'},
2026-01-03 13:57:30 +00:00
'description': description.length > 128
2026-01-03 13:14:27 +00:00
? description.substring(0, 128)
: description, // Max 128 chars per spec
2026-01-08 13:02:47 +00:00
'confirmation': {'type': 'redirect', 'return_url': returnUrl},
2026-01-03 13:57:30 +00:00
'capture': true, // Auto-capture payment when succeeded
'metadata': {
2026-01-03 13:14:27 +00:00
'userId': userId,
2026-01-08 19:56:01 +00:00
if (packId != null) 'packId': packId,
2026-01-03 13:14:27 +00:00
'createdAt': DateTime.now().toIso8601String(),
},
2026-01-03 13:57:30 +00:00
};
2026-01-03 13:14:27 +00:00
log(
'Creating YooKassa payment',
name: 'YooMoneyHandler',
error: {
'amount': formattedAmount,
'description': description,
'userId': userId,
},
);
// Create payment via YooKassa API
2026-01-03 13:57:30 +00:00
// POST /v3/payments with Idempotence-Key header
final response = await _dio!.post<Map<String, dynamic>>(
'/payments',
data: requestBody,
2026-01-08 13:02:47 +00:00
options: Options(headers: {'Idempotence-Key': idempotenceKey}),
2026-01-03 13:14:27 +00:00
);
2026-01-03 13:57:30 +00:00
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';
2026-01-08 19:56:01 +00:00
// 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
2026-01-03 13:14:27 +00:00
// Extract confirmation URL from payment response
// According to spec: confirmation.confirmation_url for redirect type
String? confirmationUrl;
2026-01-03 13:57:30 +00:00
final confirmation = paymentData['confirmation'] as Map<String, dynamic>?;
if (confirmation != null) {
final confirmationType = confirmation['type'] as String?;
if (confirmationType == 'redirect') {
confirmationUrl = confirmation['confirmation_url'] as String?;
} else {
2026-01-08 13:02:47 +00:00
print('Payment created with confirmation type: $confirmationType');
2026-01-03 13:57:30 +00:00
}
}
2026-01-03 13:14:27 +00:00
if (confirmationUrl == null) {
2026-01-08 13:02:47 +00:00
print('Warning: No confirmation URL in payment response');
2026-01-03 13:14:27 +00:00
}
2026-01-08 13:02:47 +00:00
print('YooKassa payment created successfully');
2026-01-03 13:14:27 +00:00
return YookassaPayment(
2026-01-03 13:57:30 +00:00
id: paymentId,
status: paymentStatus,
2026-01-03 13:14:27 +00:00
confirmationUrl: confirmationUrl,
);
2026-01-03 13:57:30 +00:00
} on DioException catch (e, stackTrace) {
2026-01-08 13:02:47 +00:00
print('YooKassa API error when creating payment $e $stackTrace');
2026-01-03 13:57:30 +00:00
if (e.response != null) {
2026-01-08 13:02:47 +00:00
print('YooKassa error response: ${e.response?.data}');
2026-01-03 13:57:30 +00:00
}
throw Exception(
'Failed to create YooKassa payment: ${e.message ?? 'Unknown error'}',
);
2026-01-03 13:14:27 +00:00
} on Exception catch (e, stackTrace) {
2026-01-08 13:02:47 +00:00
print('Unexpected error when creating YooKassa payment $e $stackTrace');
2026-01-03 13:14:27 +00:00
rethrow;
}
2025-11-16 11:25:27 +00:00
}
2026-01-03 13:14:27 +00:00
/// Check payment status in YooKassa
/// According to spec: GET /v3/payments/{payment_id}
2025-12-13 13:27:05 +00:00
Future<YookassaPayment> checkPayment(String paymentId) async {
2026-01-03 13:14:27 +00:00
if (!isConfigured) {
throw Exception(
'YooKassa is not configured. Please set YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY environment variables.',
);
}
try {
2026-01-08 13:02:47 +00:00
print('Checking YooKassa payment status $paymentId');
2026-01-03 13:14:27 +00:00
// Get payment info from YooKassa API
// According to spec: GET /v3/payments/{payment_id}
2026-01-03 13:57:30 +00:00
final response = await _dio!.get<Map<String, dynamic>>(
'/payments/$paymentId',
2026-01-03 13:14:27 +00:00
);
2026-01-03 13:57:30 +00:00
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;
2026-01-03 13:14:27 +00:00
// Extract confirmation URL if available
String? confirmationUrl;
2026-01-03 13:57:30 +00:00
final confirmation = paymentData['confirmation'] as Map<String, dynamic>?;
if (confirmation != null) {
final confirmationType = confirmation['type'] as String?;
if (confirmationType == 'redirect') {
confirmationUrl = confirmation['confirmation_url'] as String?;
}
}
2026-01-03 13:14:27 +00:00
2026-01-08 13:02:47 +00:00
print('''YooKassa payment status retrieved
${{'paymentId': id, 'status': paymentStatus, 'paid': paid}}''');
2026-01-03 13:14:27 +00:00
return YookassaPayment(
2026-01-03 13:57:30 +00:00
id: id,
status: paymentStatus,
2026-01-03 13:14:27 +00:00
confirmationUrl: confirmationUrl,
);
2026-01-03 13:57:30 +00:00
} on DioException catch (e, stackTrace) {
2026-01-08 13:02:47 +00:00
print('YooKassa API error when checking payment $e $stackTrace');
2026-01-03 13:57:30 +00:00
if (e.response != null) {
2026-01-08 13:02:47 +00:00
print('YooKassa error response: ${e.response?.data}');
2026-01-03 13:57:30 +00:00
}
throw Exception(
'Failed to check YooKassa payment: ${e.message ?? 'Unknown error'}',
);
2026-01-03 13:14:27 +00:00
} on Exception catch (e, stackTrace) {
2026-01-08 13:02:47 +00:00
print('Unexpected error when checking YooKassa payment $e $stackTrace');
2026-01-03 13:14:27 +00:00
rethrow;
}
}
/// Build return URL for payment confirmation
/// Uses YOOKASSA_RETURN_URL env var if set, otherwise defaults to web app URL
2026-01-08 19:56:01 +00:00
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('&');
2026-01-03 13:14:27 +00:00
// Use configured return URL base if available
final returnUrlBase = _returnUrlBase;
if (returnUrlBase != null && returnUrlBase.isNotEmpty) {
2026-01-08 19:56:01 +00:00
// Remove trailing slash and query params from base
final base = returnUrlBase.split('?').first.replaceAll(RegExp(r'/$'), '');
return '$base?$query';
2026-01-03 13:14:27 +00:00
}
// Default to web app URL
// This should be configured via environment variable in production
2026-01-08 19:56:01 +00:00
return 'https://mnemo-cards.online/payment/return?$query';
2025-11-16 11:25:27 +00:00
}
2025-12-20 18:26:15 +00:00
}