mnemo_cards/mnemo_cards_backend/lib/api/purchase/payment_manager.dart
Dmitry b95a3f048d
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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
payment
2026-01-08 22:56:01 +03:00

469 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:developer';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/purchase/google_play_purchase_handler.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart' as drift;
import 'payment_drift_extension.dart';
import 'rustore/rustore_purchase_handler.dart';
import 'yoo_money.dart';
import '../../packs/product_availability_manager.dart';
import '../../user/user_repository.dart';
/// Result of creating YooKassa payment URL
class YookassaPaymentResult {
final String confirmationUrl;
final String paymentId;
YookassaPaymentResult({
required this.confirmationUrl,
required this.paymentId,
});
}
@lazySingleton
class PaymentManager {
final AppDatabase _db;
final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler;
PaymentManager(
this._db,
this._userRepository,
this._productAvailabilityManager,
this._yooMoneyHandler,
this._rustorePurchaseHandler,
);
/// Создать платеж в базе данных
Future<PaymentDto> createPayment(PaymentDto paymentDto, String userId) async {
print('🔍 PaymentManager.createPayment: Starting');
print(
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
);
final companion = paymentDto.toCompanion(userId);
print('🔍 PaymentManager.createPayment: Calling paymentDao.createPayment');
final paymentId = await _db.paymentDao.createPayment(companion);
print('✅ PaymentManager.createPayment: Payment created, id=$paymentId');
print('🔍 PaymentManager.createPayment: Getting payment by id');
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) {
print(
'❌ PaymentManager.createPayment: Payment not found after creation, id=$paymentId',
);
throw Exception('Failed to create payment');
}
print('✅ PaymentManager.createPayment: Payment retrieved successfully');
return payment.toDto();
}
/// Обновить платеж в базе данных
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
final companion = paymentDto.toUpdateCompanion();
await _db.paymentDao.updatePaymentCompanion(paymentId, companion);
}
/// Получить платеж по ID
Future<PaymentDto?> getPaymentById(String id) async {
final payment = await _db.paymentDao.getPaymentById(id);
return payment?.toDto();
}
/// Получить последний платеж для пользователя и пака
/// Используется при возврате с ЮКассы без paymentId
Future<PaymentDto?> getLatestPaymentForUserAndPack({
required String userId,
required String packId,
}) async {
final payment = await _db.paymentDao.getLatestPaymentForUserAndPack(
userId: userId,
packId: packId,
);
return payment?.toDto();
}
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
Future<void> grantProductToUser(
String userId,
MnemoCardsProductDto product,
) async {
await _db.transaction(() async {
if (product.type == MnemoCardsProductType.pack && product.id != null) {
await _productAvailabilityManager.grantProductAccess(
userId: userId,
product: product,
grantType: 'promo_code',
);
log('Granted pack ${product.id} to user $userId via promo code');
} else if (product.type == MnemoCardsProductType.subscription &&
product.id != null) {
final planId = product.id!;
final plan = await _db.subscriptionDao.getPlanById(planId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: userId,
start: PgDateTime(now),
finish: PgDateTime(endDate),
features: drift.Value(plan.features),
),
);
log('Granted subscription $planId to user $userId via promo code');
}
}
});
}
/// Обработать платеж - дать доступ к купленным пакетам и подпискам
Future<void> processPayment(Payment payment) async {
if (payment.status == PaymentStatus.processed.name) {
log('Payment already processed');
return;
}
if (payment.status != PaymentStatus.succeeded.name) {
throw Exception('Payment status is not succeeded');
}
// Получить пользователя
final userModel = await _userRepository.getUserById(
payment.userId,
withData: true,
);
if (userModel == null) {
log('User not found: ${payment.userId}');
return;
}
// Извлечь IDs пакетов из продуктов
final packIds = <String>[];
try {
if (payment.products.isNotEmpty && payment.products != '[]') {
for (final product in payment.products) {
final productMap = product as Map<String, dynamic>;
if (productMap['type'] == 'pack' && productMap['id'] != null) {
packIds.add(productMap['id'].toString());
}
}
}
} catch (e) {
log('Error parsing products: $e');
}
// Извлечь IDs подписок из продуктов
final subscriptionIds = <String>[];
try {
if (payment.products.isNotEmpty && payment.products != '[]') {
for (final product in payment.products) {
final productMap = product as Map<String, dynamic>;
if (productMap['type'] == 'subscription' &&
productMap['id'] != null) {
subscriptionIds.add(productMap['id'].toString());
}
}
}
} catch (e) {
log('Error parsing products: $e');
}
await _db.transaction(() async {
// Дать доступ к пакетам
for (final packId in packIds) {
await _productAvailabilityManager.grantPackAccess(
userId: payment.userId,
packId: packId,
grantType: 'purchase',
);
log('Granted access to pack $packId for user ${payment.userId}');
}
// Создать подписки
for (final subscriptionId in subscriptionIds) {
final plan = await _db.subscriptionDao.getPlanById(subscriptionId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: payment.userId,
start: PgDateTime(now),
finish: PgDateTime(endDate),
features: drift.Value(plan.features),
),
);
log(
'Created subscription for user ${payment.userId}, plan: $subscriptionId',
);
}
}
// Обновить статус платежа
// Note: Payment class should have an id field when returned from queries
// If payment.id doesn't work, the database needs to be regenerated
// For now, we'll try to find the payment by externalToken and update it
if (payment.externalToken != null && payment.externalToken!.isNotEmpty) {
try {
// Try to update by externalToken (if it's used as identifier)
// TODO: Once database is regenerated, use payment.id directly
await _db.paymentDao.updatePaymentStatus(
payment.externalToken!,
PaymentStatus.processed.name,
);
log('Payment processed successfully for user ${payment.userId}');
} catch (e) {
log('Warning: Could not update payment status: $e');
log(
'Payment processed for user ${payment.userId}, but status update failed. Database regeneration may be needed.',
);
}
} else {
log(
'Payment processed for user ${payment.userId}, but cannot update status without externalToken or payment.id',
);
}
});
}
/// Проверить платеж Google Play
Future<bool> checkGooglePayment({
required String productId,
required String token,
required UserModel? user,
}) async {
if (user == null) return false;
try {
// Найти платеж по external token
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment == null) {
log('Payment not found for token: $token');
return false;
}
// Проверить статус в Google Play
final acknowledged = await googlePurchaseHandler.acknowledge(
productId,
token,
);
if (!acknowledged) {
// Use token as payment identifier since Payment.id might not be available
await _db.paymentDao.updatePaymentStatus(
token,
PaymentStatus.waiting.name,
);
return false;
}
// Обновить статус платежа
// Use token as payment identifier
await _db.paymentDao.updatePaymentStatus(
token,
PaymentStatus.succeeded.name,
);
// Обработать платеж
await processPayment(payment);
return true;
} catch (e) {
log('Error checking Google payment: $e');
return false;
}
}
/// Проверить платеж RuStore
Future<bool> checkRustorePayment({
required String productId,
required String subscriptionToken,
required UserModel? user,
}) async {
if (user == null) return false;
try {
final rustorePurchaseResponse = await _rustorePurchaseHandler
.checkPayment(subscriptionToken);
// Найти платеж по продукту
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
final payment = payments.isNotEmpty ? payments.first : null;
if (payment == null) {
log('Payment not found for product: $productId');
return false;
}
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
// Find payment id by productId or use subscriptionToken
final token = payment.externalToken ?? subscriptionToken;
if (token.isNotEmpty) {
await _db.paymentDao.updatePaymentStatus(
token,
PaymentStatus.succeeded.name,
);
}
await processPayment(payment);
return true;
}
return false;
} catch (e) {
log('Error checking RuStore payment: $e');
return false;
}
}
/// Проверить платеж YooKassa
Future<bool> checkYookassaPayment(String token) async {
try {
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment == null) {
print('Payment not found for token: $token');
return false;
}
// Проверить статус в YooKassa
final yookassaPayment = await _yooMoneyHandler.checkPayment(token);
if (yookassaPayment.status == 'succeeded') {
// Use token as payment identifier
await _db.paymentDao.updatePaymentStatus(
token,
PaymentStatus.succeeded.name,
);
await processPayment(payment);
return true;
} else if (yookassaPayment.status == 'canceled') {
await _db.paymentDao.updatePaymentStatus(
token,
PaymentStatus.canceled.name,
);
}
return false;
} catch (e) {
log('Error checking YooKassa payment: $e');
return false;
}
}
/// Создать URL для оплаты через YooKassa
Future<YookassaPaymentResult> createYookassaUrl({
required String amount,
required String description,
required String userId,
List<MnemoCardsProductDto> products = const [],
String? packId,
}) async {
print('🔍 PaymentManager.createYookassaUrl: Starting');
print(
'🔍 PaymentManager.createYookassaUrl: amount=$amount, userId=$userId, products=${products.length}',
);
print(
'🔍 PaymentManager.createYookassaUrl: Calling YooMoneyHandler.createPayment',
);
final yookassaPayment = await _yooMoneyHandler.createPayment(
amount: amount,
description: description,
userId: userId,
packId: packId,
);
print(
'✅ PaymentManager.createYookassaUrl: YooKassa payment created, id=${yookassaPayment.id}',
);
if (yookassaPayment.confirmationUrl == null) {
print('❌ PaymentManager.createYookassaUrl: confirmationUrl is null');
throw Exception('Failed to create YooKassa payment URL');
}
// Создать запись платежа в БД
print('🔍 PaymentManager.createYookassaUrl: Creating payment in database');
final paymentDto = PaymentDto(
amount: amount,
currency: 'RUB',
date: DateTime.now(),
status: PaymentStatus.created,
paymentSystem: PaymentSystem.yookassa,
products: products,
externalToken: yookassaPayment.id,
meta: null,
);
print('🔍 PaymentManager.createYookassaUrl: Calling createPayment');
await createPayment(paymentDto, userId);
print('✅ PaymentManager.createYookassaUrl: Payment created in database');
print(
'✅ PaymentManager.createYookassaUrl: Returning confirmationUrl and paymentId',
);
return YookassaPaymentResult(
confirmationUrl: yookassaPayment.confirmationUrl!,
paymentId: yookassaPayment.id,
);
}
/// Получить платежи пользователя
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
final payments = await _db.paymentDao.getPaymentsByUserId(userIdString);
return payments.map((p) => p.toDto()).toList();
}
/// Проверить и обработать платеж (для cron задач)
Future<void> checkAndProcessPayment(Payment payment) async {
// Если платеж уже обработан, пропускаем
if (payment.status == PaymentStatus.processed.name) {
return;
}
// Если статус unknown, пропускаем
if (payment.status == PaymentStatus.unknown.name) {
return;
}
try {
final system = PaymentSystem.values.firstWhere(
(s) => s.name == payment.paymentSystem,
orElse: () => PaymentSystem.yookassa,
);
if (system == PaymentSystem.yookassa && payment.externalToken != null) {
await checkYookassaPayment(payment.externalToken!);
} else if (system == PaymentSystem.rustore &&
payment.externalToken != null) {
// Для RuStore нужен productId - получаем из products
if (payment.products.isNotEmpty && payment.products != '[]') {
try {
if (payment.products.isNotEmpty) {
final firstProduct = payment.products.first;
final productId = firstProduct['id']?.toString();
if (productId != null && payment.externalToken != null) {
final user = await _userRepository.getUserById(payment.userId);
await checkRustorePayment(
productId: productId,
subscriptionToken: payment.externalToken!,
user: user,
);
}
}
} catch (e) {
log('Error parsing products for RuStore payment: $e');
}
}
}
} catch (e, s) {
log('Error checking payment for user ${payment.userId}: $e');
print(s);
}
}
}