mnemo_cards/mnemo_cards_backend/lib/api/purchase/yoo_money.dart
2026-01-03 16:14:27 +03:00

301 lines
9.1 KiB
Dart

import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:uuid/uuid.dart';
import 'package:yookassa_client/yookassa_client.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
@LazySingleton()
class YooMoneyHandler {
final String _shopId;
final String _secretKey;
final String? _returnUrlBase;
late final YookassaClient? _yookassaClient;
final _uuid = const Uuid();
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',
);
_yookassaClient = null;
} else {
// Initialize YooKassa client with credentials
// According to spec: Basic Auth with shopId:secretKey
_yookassaClient = YookassaClient(
Dio(),
credentials: YookassaAuthCredentials(
shopId: _shopId,
secretKey: _secretKey,
),
);
}
}
/// Check if YooKassa is properly configured
bool get isConfigured => _yookassaClient != null;
/// Create a payment in YooKassa
/// According to spec: POST /v3/payments
/// Required: amount, description
/// Optional: confirmation (redirect), receipt, metadata
Future<YookassaPayment> 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')}';
// Create amount object according to MonetaryAmount schema
final yookassaAmount = Amount(
value: formattedAmount,
currency: 'RUB', // Default currency
);
// 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);
// Create payment request according to CreatePaymentRequest schema
final paymentRequest = CreatePaymentRequest(
amount: yookassaAmount,
description: description.length > 128
? description.substring(0, 128)
: description, // Max 128 chars per spec
confirmation: YookassaConfirmation.redirect(
returnUrl: returnUrl,
),
capture: true, // Auto-capture payment when succeeded
metadata: {
'userId': userId,
'createdAt': DateTime.now().toIso8601String(),
},
);
// Generate idempotence key for request
// According to spec: Idempotence-Key header (required)
final idempotenceKey = _uuid.v4();
log(
'Creating YooKassa payment',
name: 'YooMoneyHandler',
error: {
'amount': formattedAmount,
'description': description,
'userId': userId,
},
);
// Create payment via YooKassa API
// According to spec: Idempotence-Key header is required
final payment = await _yookassaClient!.createPayment(
paymentRequest: paymentRequest,
idempotenceKey: idempotenceKey,
);
// Extract confirmation URL from payment response
// According to spec: confirmation.confirmation_url for redirect type
String? confirmationUrl;
payment.confirmation?.maybeMap(
redirect: (redirect) {
confirmationUrl = redirect.confirmationUrl;
},
qr: (qr) {
// For QR payments, we might need to handle differently
log('QR payment created, no redirect URL', name: 'YooMoneyHandler');
},
embedded: (_) {
log('Embedded payment created', name: 'YooMoneyHandler');
},
external: (_) {
log('External payment created', name: 'YooMoneyHandler');
},
mobileApplication: (_) {
log('Mobile application payment created', name: 'YooMoneyHandler');
},
orElse: () {
log('Unknown confirmation type', name: 'YooMoneyHandler');
},
);
if (confirmationUrl == null) {
log(
'Warning: No confirmation URL in payment response',
name: 'YooMoneyHandler',
error: jsonEncode(payment.toJson()),
);
}
// Map YooKassa payment status to our status
final status = _mapPaymentStatus(payment.status);
log(
'YooKassa payment created successfully',
name: 'YooMoneyHandler',
error: {
'paymentId': payment.id,
'status': status,
'hasConfirmationUrl': confirmationUrl != null,
},
);
return YookassaPayment(
id: payment.id,
status: status,
confirmationUrl: confirmationUrl,
);
} on YookassaException catch (e, stackTrace) {
log(
'YooKassa API error when creating payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
rethrow;
} on Exception catch (e, stackTrace) {
log(
'Unexpected error when creating YooKassa payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
/// Check payment status in YooKassa
/// According to spec: GET /v3/payments/{payment_id}
Future<YookassaPayment> checkPayment(String paymentId) async {
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}
final payment = await _yookassaClient!.getPaymentInfo(
paymentId: paymentId,
);
// Extract confirmation URL if available
String? confirmationUrl;
payment.confirmation?.maybeMap(
redirect: (redirect) {
confirmationUrl = redirect.confirmationUrl;
},
orElse: () {},
);
// Map YooKassa payment status to our status
final status = _mapPaymentStatus(payment.status);
log(
'YooKassa payment status retrieved',
name: 'YooMoneyHandler',
error: {
'paymentId': payment.id,
'status': status,
'paid': payment.paid,
},
);
return YookassaPayment(
id: payment.id,
status: status,
confirmationUrl: confirmationUrl,
);
} on YookassaException catch (e, stackTrace) {
log(
'YooKassa API error when checking payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
rethrow;
} on Exception catch (e, stackTrace) {
log(
'Unexpected error when checking YooKassa payment',
name: 'YooMoneyHandler',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
/// Map YooKassa payment status to string status
/// According to spec: pending, waiting_for_capture, succeeded, canceled
String _mapPaymentStatus(YookassaPaymentStatus status) {
return switch (status) {
YookassaPaymentStatus.pending => 'pending',
YookassaPaymentStatus.waitingForCapture => 'waiting_for_capture',
YookassaPaymentStatus.succeeded => 'succeeded',
YookassaPaymentStatus.canceled => 'canceled',
};
}
/// 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';
}
}