yookassa
Some checks are pending
Backend CI / test (push) Waiting to run
Backend 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:
Dmitry 2026-01-03 16:57:30 +03:00
parent d6a6356254
commit a6fd99559a
3 changed files with 128 additions and 121 deletions

View file

@ -5,7 +5,6 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:yookassa_client/yookassa_client.dart';
/// Wrapper for YooKassa payment response /// Wrapper for YooKassa payment response
class YookassaPayment { class YookassaPayment {
@ -28,8 +27,9 @@ class YooMoneyHandler {
final String _shopId; final String _shopId;
final String _secretKey; final String _secretKey;
final String? _returnUrlBase; final String? _returnUrlBase;
late final YookassaClient? _yookassaClient; late final Dio? _dio;
final _uuid = const Uuid(); final _uuid = const Uuid();
static const String _baseUrl = 'https://api.yookassa.ru/v3';
YooMoneyHandler({ YooMoneyHandler({
required String shopId, required String shopId,
@ -43,22 +43,37 @@ class YooMoneyHandler {
'YooKassa credentials not configured. Payments will not work.', 'YooKassa credentials not configured. Payments will not work.',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
); );
_yookassaClient = null; _dio = null;
} else { } else {
// Initialize YooKassa client with credentials // Initialize Dio with Basic Auth
// According to spec: Basic Auth with shopId:secretKey // According to spec: Basic Auth with shopId:secretKey
_yookassaClient = YookassaClient( _dio = Dio(
Dio(), BaseOptions(
credentials: YookassaAuthCredentials( baseUrl: _baseUrl,
shopId: _shopId, headers: {
secretKey: _secretKey, '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 /// Check if YooKassa is properly configured
bool get isConfigured => _yookassaClient != null; bool get isConfigured => _dio != null;
/// Create a payment in YooKassa /// Create a payment in YooKassa
/// According to spec: POST /v3/payments /// According to spec: POST /v3/payments
@ -88,37 +103,35 @@ class YooMoneyHandler {
? '${amountValue.substring(0, amountValue.length - 2)}.${amountValue.substring(amountValue.length - 2)}' ? '${amountValue.substring(0, amountValue.length - 2)}.${amountValue.substring(amountValue.length - 2)}'
: '0.${amountValue.padLeft(2, '0')}'; : '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 // 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);
// 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 // Generate idempotence key for request
// According to spec: Idempotence-Key header (required) // According to spec: Idempotence-Key header (required)
final idempotenceKey = _uuid.v4(); 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( log(
'Creating YooKassa payment', 'Creating YooKassa payment',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
@ -130,71 +143,85 @@ class YooMoneyHandler {
); );
// Create payment via YooKassa API // Create payment via YooKassa API
// According to spec: Idempotence-Key header is required // POST /v3/payments with Idempotence-Key header
final payment = await _yookassaClient!.createPayment( final response = await _dio!.post<Map<String, dynamic>>(
paymentRequest: paymentRequest, '/payments',
idempotenceKey: idempotenceKey, 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 // 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;
payment.confirmation?.maybeMap( final confirmation = paymentData['confirmation'] as Map<String, dynamic>?;
redirect: (redirect) { if (confirmation != null) {
confirmationUrl = redirect.confirmationUrl; final confirmationType = confirmation['type'] as String?;
}, if (confirmationType == 'redirect') {
qr: (qr) { confirmationUrl = confirmation['confirmation_url'] as String?;
// For QR payments, we might need to handle differently } else {
log('QR payment created, no redirect URL', name: 'YooMoneyHandler'); log(
}, 'Payment created with confirmation type: $confirmationType',
embedded: (_) { name: 'YooMoneyHandler',
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) { if (confirmationUrl == null) {
log( log(
'Warning: No confirmation URL in payment response', 'Warning: No confirmation URL in payment response',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
error: jsonEncode(payment.toJson()), error: jsonEncode(paymentData),
); );
} }
// Map YooKassa payment status to our status
final status = _mapPaymentStatus(payment.status);
log( log(
'YooKassa payment created successfully', 'YooKassa payment created successfully',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
error: { error: {
'paymentId': payment.id, 'paymentId': paymentId,
'status': status, 'status': paymentStatus,
'hasConfirmationUrl': confirmationUrl != null, 'hasConfirmationUrl': confirmationUrl != null,
}, },
); );
return YookassaPayment( return YookassaPayment(
id: payment.id, id: paymentId,
status: status, status: paymentStatus,
confirmationUrl: confirmationUrl, confirmationUrl: confirmationUrl,
); );
} on YookassaException catch (e, stackTrace) { } on DioException catch (e, stackTrace) {
log( log(
'YooKassa API error when creating payment', 'YooKassa API error when creating payment',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
error: e, error: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
rethrow; if (e.response != null) {
log(
'YooKassa error response: ${e.response?.data}',
name: 'YooMoneyHandler',
);
}
throw Exception(
'Failed to create YooKassa payment: ${e.message ?? 'Unknown error'}',
);
} on Exception catch (e, stackTrace) { } on Exception catch (e, stackTrace) {
log( log(
'Unexpected error when creating YooKassa payment', 'Unexpected error when creating YooKassa payment',
@ -224,45 +251,61 @@ class YooMoneyHandler {
// Get payment info from YooKassa API // Get payment info from YooKassa API
// According to spec: GET /v3/payments/{payment_id} // According to spec: GET /v3/payments/{payment_id}
final payment = await _yookassaClient!.getPaymentInfo( final response = await _dio!.get<Map<String, dynamic>>(
paymentId: paymentId, '/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 // Extract confirmation URL if available
String? confirmationUrl; String? confirmationUrl;
payment.confirmation?.maybeMap( final confirmation = paymentData['confirmation'] as Map<String, dynamic>?;
redirect: (redirect) { if (confirmation != null) {
confirmationUrl = redirect.confirmationUrl; final confirmationType = confirmation['type'] as String?;
}, if (confirmationType == 'redirect') {
orElse: () {}, confirmationUrl = confirmation['confirmation_url'] as String?;
); }
}
// Map YooKassa payment status to our status
final status = _mapPaymentStatus(payment.status);
log( log(
'YooKassa payment status retrieved', 'YooKassa payment status retrieved',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
error: { error: {
'paymentId': payment.id, 'paymentId': id,
'status': status, 'status': paymentStatus,
'paid': payment.paid, 'paid': paid,
}, },
); );
return YookassaPayment( return YookassaPayment(
id: payment.id, id: id,
status: status, status: paymentStatus,
confirmationUrl: confirmationUrl, confirmationUrl: confirmationUrl,
); );
} on YookassaException catch (e, stackTrace) { } on DioException catch (e, stackTrace) {
log( log(
'YooKassa API error when checking payment', 'YooKassa API error when checking payment',
name: 'YooMoneyHandler', name: 'YooMoneyHandler',
error: e, error: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
rethrow; if (e.response != null) {
log(
'YooKassa error response: ${e.response?.data}',
name: 'YooMoneyHandler',
);
}
throw Exception(
'Failed to check YooKassa payment: ${e.message ?? 'Unknown error'}',
);
} on Exception catch (e, stackTrace) { } on Exception catch (e, stackTrace) {
log( log(
'Unexpected error when checking YooKassa payment', 'Unexpected error when checking YooKassa payment',
@ -274,17 +317,6 @@ class YooMoneyHandler {
} }
} }
/// 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 /// 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) {

View file

@ -313,14 +313,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@ -639,14 +631,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
retrofit:
dependency: transitive
description:
name: retrofit
sha256: "13a2865c0d97da580ea4e3c64d412d81f365fd5b26be2a18fca9582e021da37a"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
retry: retry:
dependency: transitive dependency: transitive
description: description:
@ -983,13 +967,5 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
yookassa_client:
dependency: "direct main"
description:
name: yookassa_client
sha256: e801e1bb22f21f883adbee15645e2c9b21c4a640f8e096006a6295c335c588aa
url: "https://pub.dev"
source: hosted
version: "1.0.5"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" dart: ">=3.9.0 <4.0.0"

View file

@ -52,7 +52,6 @@ dependencies:
uuid: ^4.5.2 uuid: ^4.5.2
minio: ^3.5.8 minio: ^3.5.8
yookassa_client: ^1.0.5
neat_periodic_task: ^2.0.1 neat_periodic_task: ^2.0.1
jaguar_jwt: ^3.0.0 jaguar_jwt: ^3.0.0