2026-01-03 13:14:27 +00:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:developer';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:dio/dio.dart';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'package:injectable/injectable.dart';
|
2026-01-03 13:14:27 +00:00
|
|
|
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
|
|
|
|
|
@LazySingleton()
|
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
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
);
|
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,
|
|
|
|
|
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);
|
|
|
|
|
},
|
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,
|
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
|
|
|
|
|
final returnUrl = _buildReturnUrl(userId);
|
|
|
|
|
|
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 = {
|
|
|
|
|
'amount': {
|
|
|
|
|
'value': formattedAmount,
|
|
|
|
|
'currency': 'RUB',
|
|
|
|
|
},
|
|
|
|
|
'description': description.length > 128
|
2026-01-03 13:14:27 +00:00
|
|
|
? description.substring(0, 128)
|
|
|
|
|
: description, // Max 128 chars per spec
|
2026-01-03 13:57:30 +00:00
|
|
|
'confirmation': {
|
|
|
|
|
'type': 'redirect',
|
|
|
|
|
'return_url': returnUrl,
|
|
|
|
|
},
|
|
|
|
|
'capture': true, // Auto-capture payment when succeeded
|
|
|
|
|
'metadata': {
|
2026-01-03 13:14:27 +00:00
|
|
|
'userId': userId,
|
|
|
|
|
'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,
|
|
|
|
|
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-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 {
|
|
|
|
|
log(
|
|
|
|
|
'Payment created with confirmation type: $confirmationType',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-03 13:14:27 +00:00
|
|
|
|
|
|
|
|
if (confirmationUrl == null) {
|
|
|
|
|
log(
|
|
|
|
|
'Warning: No confirmation URL in payment response',
|
|
|
|
|
name: 'YooMoneyHandler',
|
2026-01-03 13:57:30 +00:00
|
|
|
error: jsonEncode(paymentData),
|
2026-01-03 13:14:27 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log(
|
|
|
|
|
'YooKassa payment created successfully',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: {
|
2026-01-03 13:57:30 +00:00
|
|
|
'paymentId': paymentId,
|
|
|
|
|
'status': paymentStatus,
|
2026-01-03 13:14:27 +00:00
|
|
|
'hasConfirmationUrl': confirmationUrl != null,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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-03 13:14:27 +00:00
|
|
|
log(
|
|
|
|
|
'YooKassa API error when creating payment',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
);
|
2026-01-03 13:57:30 +00:00
|
|
|
if (e.response != null) {
|
|
|
|
|
log(
|
|
|
|
|
'YooKassa error response: ${e.response?.data}',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
throw Exception(
|
|
|
|
|
'Failed to create YooKassa payment: ${e.message ?? 'Unknown error'}',
|
|
|
|
|
);
|
2026-01-03 13:14:27 +00:00
|
|
|
} on Exception catch (e, stackTrace) {
|
|
|
|
|
log(
|
|
|
|
|
'Unexpected error when creating YooKassa payment',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
);
|
|
|
|
|
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 {
|
|
|
|
|
log(
|
|
|
|
|
'Checking YooKassa payment status',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: {'paymentId': paymentId},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
log(
|
|
|
|
|
'YooKassa payment status retrieved',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: {
|
2026-01-03 13:57:30 +00:00
|
|
|
'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-03 13:14:27 +00:00
|
|
|
log(
|
|
|
|
|
'YooKassa API error when checking payment',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
);
|
2026-01-03 13:57:30 +00:00
|
|
|
if (e.response != null) {
|
|
|
|
|
log(
|
|
|
|
|
'YooKassa error response: ${e.response?.data}',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
throw Exception(
|
|
|
|
|
'Failed to check YooKassa payment: ${e.message ?? 'Unknown error'}',
|
|
|
|
|
);
|
2026-01-03 13:14:27 +00:00
|
|
|
} on Exception catch (e, stackTrace) {
|
|
|
|
|
log(
|
|
|
|
|
'Unexpected error when checking YooKassa payment',
|
|
|
|
|
name: 'YooMoneyHandler',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: 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';
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
2025-12-20 18:26:15 +00:00
|
|
|
}
|