import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:googleapis/androidpublisher/v3.dart' as ap; import 'package:googleapis/firestore/v1.dart' as fs; import 'package:googleapis_auth/auth_io.dart' as auth; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/api/purchase/google_play_purchase_handler.dart'; import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.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:yookassa_client/yookassa_client.dart'; import 'package:drift/drift.dart' as drift; import 'payment_drift_extension.dart'; import '../../main.dart' as backend_main; import '../../packs/pack_manager.dart'; import '../../packs/products_price_resolver.dart'; import '../subscription/subscription_manager.dart'; import 'iap_repository.dart'; import 'rustore/rustore_purchase_handler.dart'; import 'yoo_money.dart'; @lazySingleton class PaymentManager { final AppDatabase _db; final PackManager _packManager; final SubscriptionManager _subscriptionManager; late final GooglePlayPurchaseHandler googlePurchaseHandler; final YooMoneyHandler _yooMoneyHandler; final RustorePurchaseHandler _rustorePurchaseHandler; final ProductsPriceResolver _productsPriceResolver; PaymentManager( this._db, this._packManager, this._subscriptionManager, this._yooMoneyHandler, this._rustorePurchaseHandler, this._productsPriceResolver, ); /// Создать платеж в базе данных Future createPayment(PaymentDto paymentDto, int userId) async { final companion = paymentDto.toCompanion(userId); final paymentId = await _db.paymentDao.createPayment(companion); final payment = await _db.paymentDao.getPaymentById(paymentId); if (payment == null) { throw Exception('Failed to create payment'); } return payment.toDto(); } /// Обновить платеж в базе данных Future updatePayment(PaymentDto paymentDto) async { final paymentId = int.parse(paymentDto.id); final companion = paymentDto.toUpdateCompanion(paymentId); await _db.paymentDao.updatePaymentCompanion(companion); } /// Получить платеж по ID Future getPaymentById(int id) async { final payment = await _db.paymentDao.getPaymentById(id); return payment?.toDto(); } /// Creates the Google Play and Apple Store [PurchaseHandler] /// and their dependencies Future> _createPurchaseHandlers() async { // Configure Android Publisher API access final serviceAccountGooglePlay = File('data/service-account-google-play.json').readAsStringSync(); final clientCredentialsGooglePlay = auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay); final clientGooglePlay = await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [ ap.AndroidPublisherApi.androidpublisherScope, ]); final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay); // Configure Firestore API access final serviceAccountFirebase = File('assets/service-account-firebase.json').readAsStringSync(); final clientCredentialsFirebase = auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase); final clientFirebase = await auth.clientViaServiceAccount(clientCredentialsFirebase, [ fs.FirestoreApi.cloudPlatformScope, ]); final firestoreApi = fs.FirestoreApi(clientFirebase); final dynamic json = jsonDecode(serviceAccountFirebase); final projectId = json['project_id'] as String; final iapRepository = IapRepository(firestoreApi, projectId); return { 'google_play': GooglePlayPurchaseHandler( androidPublisher, iapRepository, ), // 'app_store': AppStorePurchaseHandler( // iapRepository, // ), }; } Future init() async { googlePurchaseHandler = (await _createPurchaseHandlers()).values.first; } Future createYooMoneyPayment( String productId, MnemoCardsProductType productType, UserModel? user, ) async { if (user == null) { return null; } if (productType == MnemoCardsProductType.pack) { final pack = await _packManager.getBuyPage(productId, user); if (pack == null) { return null; } final dto = await _yooMoneyHandler.createPayment( productId: pack.id, productType: MnemoCardsProductType.pack, // Price with discounts price: pack.price!, title: pack.title, user: user, ); return dto; } else if (productType == MnemoCardsProductType.subscription) { await user.subscriptionModel.load(); if (user.subscriptionModel.value?.isActive == true) { return null; } final sub = await _subscriptionManager.getSubscriptionPlan(productId); if (sub == null) { return null; } final dto = await _yooMoneyHandler.createPayment( productId: productId, productType: MnemoCardsProductType.subscription, title: sub.ui?.title ?? 'Подписка', price: await _productsPriceResolver.userSubscriptionPrice(user, sub), user: user, ); return dto; } return null; } // Future createGooglePayment(String packId, UserModel? user) async { // if (user == null) { // return null; // } // final pack = await _packManager.getBuyPage(packId, user); // if (pack == null) { // return null; // } // final product = await googlePurchaseHandler.getProduct(pack.googlePlayId!); // if (product == null) { // return null; // } // isar.writeTxnSync(() { // final paymentId = isar.paymentModels.putSync( // PaymentModel.google( // amount: product.defaultPrice.priceMicros, // currency: product.defaultPrice.currency, // userId: user.id!, // packs: [pack.id], // externalToken: payment.id, // meta: jsonEncode(payment.toJson()), // ), // ); // isar.userModels.putSync( // user.copyWith(purchases: [...user.purchases, paymentId.toString()]), // ); // }) // // if (url != null) { // [3, 5, 10].map( // (e) => // Future.delayed( // Duration(minutes: e), // () => checkAndProcessUserPayments(user), // ), // ); // } // return url; // } Future processPayment(PaymentModel paymentModel) async { if (paymentModel.status == PaymentStatus.processed) { log('Payment already processed'); return; } if (paymentModel.status == PaymentStatus.succeeded) { // TODO: Replace with _db.userDao after model conversion final user = await backend_main.database.transaction(() async { // Temporary: Need to convert between Isar UserModel and Drift User final user = await backend_main.database.userDao.getUserById(paymentModel.userId); await user?.subscriptionModel.load(); return user; }); if (user == null) { print('User not found ${paymentModel.userId}'); return; } // TODO: Replace with PackDao after model conversion (CardPackModel -> CardPack) final packs = await isar.txn( () async => (await isar.cardPackModels.getAll( { ...paymentModel.packs.map((e) => int.parse(e)), ...paymentModel.products .where((p) => p.type == MnemoCardsProductModelType.pack) .map((e) => e.id) .whereNotNull(), }.toList(), )) .whereNotNull(), ); final subs = paymentModel.products .where((p) => p.type == MnemoCardsProductModelType.subscription) .toList(); SubscriptionPlanModel? planModel; if (subs.isNotEmpty) { planModel = await _subscriptionManager .getSubscriptionPlan(subs.first.id.toString()); } await isar.writeTxn(() async { if (packs.isNotEmpty) { await (user.packs..addAll(packs)).save(); } if (planModel != null) { final userSubscriptionModel = UserSubscriptionModel( start: DateTime.now(), finish: DateTime.now().add(Duration(days: planModel.durationDays)), features: [...planModel.features], ); user.subscriptionModel.value = userSubscriptionModel; await isar.userSubscriptionModels.put(userSubscriptionModel); await user.subscriptionModel.save(); } await isar.userModels.put(user); await isar.paymentModels.put( paymentModel.copyWith( status: PaymentStatus.processed, ), ); }); } else { throw Exception('Payment status is not success'); } } Future> getUserPayments(String userIdString) async { final userId = int.tryParse(userIdString); if (userId == null) return []; final payments = await _db.paymentDao.getPaymentsByUserId(userId); return payments.map((p) => p.toDto()).toList(); } Future> checkAndProcessUserPayments( UserModel model, ) async { final payments = await getUserPayments(model.id.toString()); final updatedPayments = []; for (final payment in payments) { try { updatedPayments.add( await checkAndProcessPayment(payment), ); } catch (e) { print(e); } } return updatedPayments .where((element) => element.status == PaymentStatus.succeeded) .toList(); } Future checkYookassaPayment( MnemoCardsProductDto product, String token, UserModel? user, ) async { final modelId = int.tryParse(token); if (modelId == null) { log('Invalid payment model id: ${token}'); return false; } final model = await _db.paymentDao.getPaymentById(modelId); if (model == null) { log('Payment model with id ${modelId} not found'); return false; } if (model.externalToken == null) { log('Payment model ${modelId} has no external token'); return false; } final result = await checkAndProcessPayment(model); return result.status == PaymentStatus.succeeded; } Future checkGooglePayment({ required MnemoCardsProductDto product, required String token, required UserModel? user, }) async { final productId = product.id.toString(); if (product.type == MnemoCardsProductType.pack) { final productPurchase = await googlePurchaseHandler.handleNonSubscription( productId: productId, token: token, ); if (productPurchase == null) { return false; } final pack = await isar.txn( () => isar.cardPackModels .filter() .googlePlayIdEqualTo(productId) .findFirst(), ); PaymentModel? model = await isar.txn( () => isar.paymentModels.filter().externalTokenEqualTo(token).findFirst(), ); final googleStatus = GooglePlayPurchaseHandler.nonSubscriptionStatusFrom( productPurchase.purchaseState); PaymentModel updatedModel; if (model == null) { final product = (await googlePurchaseHandler.getProduct(productId))!; final price = product.prices?[productPurchase.regionCode] ?? product.defaultPrice; final amount = int.tryParse(price?.priceMicros ?? '')?.toString() ?? ''; final currency = price?.currency ?? ''; updatedModel = PaymentModel.google( amount: amount, currency: currency, userId: user!.id!, externalToken: token, date: DateTime.now(), packs: [pack?.id.toString() ?? ''], products: [ MnemoCardsProductModelBase.pack(pack?.id), ], subscription: productId == 'subscription', meta: jsonEncode(productPurchase.toJson()), status: PaymentStatus.created, ); await _db.paymentDao.updatePayment(updatedModel.toPayment()); } else { updatedModel = model.copyWith(meta: jsonEncode(productPurchase.toJson())); await _db.paymentDao.updatePayment(updatedModel.toPayment()); } if (googleStatus == PaymentStatus.succeeded) { try { processPayment(updatedModel); } catch (error) { print(error); return false; } } final acknowledged = await googlePurchaseHandler.acknowledge(productId, token); if (!acknowledged) { await _db.paymentDao.updatePayment( updatedModel.copyWith(status: PaymentStatus.waiting).toPayment(), ); } return true; } return false; } Future checkRustorePayment({ required String productId, required String subscriptionToken, required UserModel? user, }) async { final rustorePurchaseResponse = await _rustorePurchaseHandler.checkPayment(subscriptionToken); final pack = await isar.txn( () => isar.cardPackModels.filter().rustoreIdEqualTo(productId).findFirst(), ); PaymentModel? model = await isar.txn( () => isar.paymentModels .filter() .externalTokenEqualTo(subscriptionToken) .findFirst(), ); final meta = rustorePurchaseResponse?.let(jsonEncode); PaymentModel updatedModel; if (model == null) { updatedModel = PaymentModel.rustore( amount: pack?.price ?? 'no-price', currency: 'rub', userId: user!.id!, externalToken: subscriptionToken, date: DateTime.now(), products: [ MnemoCardsProductModelBase.pack(pack?.id), ], packs: [pack?.id.toString() ?? ''], subscription: productId == 'subscription', meta: meta, status: rustorePurchaseResponse?.invoiceStatus.toPaymentStatus() ?? PaymentStatus.created, ); await isar.writeTxn(() { return isar.paymentModels.put(updatedModel); }); } else { updatedModel = model.copyWith(meta: meta); await _db.paymentDao.updatePayment(updatedModel.toPayment()); } if (updatedModel.status == PaymentStatus.succeeded) { try { processPayment(updatedModel); } catch (error) { print(error); return false; } return true; } return false; } Future checkAndProcessPayment(PaymentModel model) async { var paymentStatus = model.status; if (model.status == PaymentStatus.created) { if (model.paymentSystem == PaymentSystem.rustore) { if (model.externalToken != null) { final payment = await _rustorePurchaseHandler.checkPayment(model.externalToken!); paymentStatus = payment?.invoiceStatus.toPaymentStatus() ?? PaymentStatus.unknown; } } else if (model.paymentSystem == PaymentSystem.yookassa) { print( 'Checking ${model.paymentSystem.name} ${model.status.name} ${model.id}'); try { final payment = await _yooMoneyHandler.checkPayment(model.externalToken!); paymentStatus = payment.status.toPaymentStatus(); } on DioException catch (e) { print( 'Dio exception ${model.paymentSystem.name} ${model.status.name} ${model.id}'); print('Yookassa dio error: ${e}'); final error = e.error; if (error is YookassaException) { print('Yookassa payment error: ${e}'); if (error.code == YookassaErrorCode.notFound) { paymentStatus = PaymentStatus.unknown; print('Yookassa payment not found: ${model.externalToken}'); } else { rethrow; } } } on Object catch (e, s) { print( 'Exception ${model.paymentSystem.name} ${model.status.name} ${model.id}'); print(e); print(s); } } print( 'Checked ${model.paymentSystem.name} ${model.status.name}->${paymentStatus} ${model.id}'); } final updatedModel = model.copyWith( status: paymentStatus, ); if (model.status != updatedModel.status) { await _db.paymentDao.updatePayment(updatedModel.toPayment()); } if (updatedModel.status == PaymentStatus.succeeded) { print( 'Processing ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}'); try { processPayment(updatedModel); } catch (error) { print(error); print( 'Process error ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}'); return model; } await _db.paymentDao.updatePayment(updatedModel.toPayment()); print( 'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}', ); } else if (updatedModel.status == PaymentStatus.unknown) { await _db.paymentDao.updatePayment(updatedModel.toPayment()); } return updatedModel; } }