mnemo_cards/mnemo_cards_backend/lib/api/purchase/payment_manager.dart
Dmitry 336bafc600
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 Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (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
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
tasks and stuff
2026-01-09 20:21:18 +03:00

469 lines
16 KiB
Dart
Raw Permalink 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 'rustore/rustore_purchase_handler.dart';
import 'yoo_money.dart';
import '../../packs/product_availability_manager.dart';
import '../../repository/export.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 PaymentRepository _paymentRepository;
final SubscriptionRepository _subscriptionRepository;
final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler;
final AppDatabase _db;
PaymentManager(
this._paymentRepository,
this._subscriptionRepository,
this._userRepository,
this._productAvailabilityManager,
this._yooMoneyHandler,
this._rustorePurchaseHandler,
this._db,
);
/// Создать платеж в базе данных
Future<PaymentDto> createPayment(PaymentDto paymentDto, String userId) async {
print('🔍 PaymentManager.createPayment: Starting');
print(
'🔍 PaymentManager.createPayment: userId=$userId, externalToken=${paymentDto.externalToken}',
);
print(
'🔍 PaymentManager.createPayment: Calling paymentRepository.createPayment',
);
final payment = await _paymentRepository.createPayment(paymentDto, userId);
print('✅ PaymentManager.createPayment: Payment created successfully');
return payment;
}
/// Обновить платеж в базе данных
Future<void> updatePayment(String paymentId, PaymentDto paymentDto) async {
await _paymentRepository.updatePayment(paymentId, paymentDto);
}
/// Получить платеж по ID
Future<PaymentDto?> getPaymentById(String id) async {
return await _paymentRepository.getPaymentById(id);
}
/// Получить последний платеж для пользователя и пака
/// Используется при возврате с ЮКассы без paymentId
Future<PaymentDto?> getLatestPaymentForUserAndPack({
required String userId,
required String packId,
}) async {
return await _paymentRepository.getLatestPaymentForUserAndPack(
userId: userId,
packId: packId,
);
}
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
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 _subscriptionRepository.getPlanById(planId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert(
userId: userId,
start: PgDateTime(now),
finish: PgDateTime(endDate),
features: drift.Value(plan.features.map((f) => f.name).toList()),
),
);
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);
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 _subscriptionRepository.getPlanById(subscriptionId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _subscriptionRepository.createSubscription(
UserSubscriptionsCompanion.insert(
userId: payment.userId,
start: PgDateTime(now),
finish: PgDateTime(endDate),
features: drift.Value(plan.features.map((f) => f.name).toList()),
),
);
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 _paymentRepository.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 paymentsDto = await _paymentRepository.getPaymentsByProduct(
productId,
);
final paymentDto = paymentsDto.isNotEmpty ? paymentsDto.first : null;
if (paymentDto == 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 = paymentDto.externalToken ?? subscriptionToken;
if (token.isNotEmpty) {
await _paymentRepository.updatePaymentStatus(
token,
PaymentStatus.succeeded.name,
);
}
// Обработать платеж - нужно получить Payment из БД для processPayment
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment != null) {
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 paymentDto = await _paymentRepository.getPaymentByExternalToken(
token,
);
if (paymentDto == 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 _paymentRepository.updatePaymentStatus(
token,
PaymentStatus.succeeded.name,
);
// Обработать платеж - нужно получить Payment из БД для processPayment
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment != null) {
await processPayment(payment);
}
return true;
} else if (yookassaPayment.status == 'canceled') {
await _paymentRepository.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 {
return await _paymentRepository.getPaymentsByUserId(userIdString);
}
/// Проверить и обработать платеж (для 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);
}
}
}