mnemo_cards/mnemo_cards_backend/lib/api/purchase/payment_manager.dart
2025-11-16 14:25:27 +03:00

491 lines
16 KiB
Dart

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:isar/isar.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_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yookassa_client/yookassa_client.dart';
import '../../main.dart';
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 PackManager _packManager;
final SubscriptionManager _subscriptionManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler;
final ProductsPriceResolver _productsPriceResolver;
PaymentManager(
this._packManager,
this._subscriptionManager,
this._yooMoneyHandler,
this._rustorePurchaseHandler,
this._productsPriceResolver,
);
/// Creates the Google Play and Apple Store [PurchaseHandler]
/// and their dependencies
Future<Map<String, GooglePlayPurchaseHandler>>
_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<void> init() async {
googlePurchaseHandler = (await _createPurchaseHandlers()).values.first;
}
Future<YookassaPaymentDto?> 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<String?> 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<void> processPayment(PaymentModel paymentModel) async {
if (paymentModel.status == PaymentStatus.processed) {
log('Payment already processed');
return;
}
if (paymentModel.status == PaymentStatus.succeeded) {
final user = await isar.txn(() async {
final user = await isar.userModels.get(paymentModel.userId);
await user?.subscriptionModel.load();
return user;
});
if (user == null) {
print('User not found ${paymentModel.userId}');
return;
}
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<List<PaymentModel>> getUserPayments(String userIdString) async {
final userId = int.tryParse(userIdString);
if (userId == null) return [];
return isar.txn(() async {
final user = await isar.userModels.get(userId);
if (user == null) return [];
final purchases =
user.purchases.map((e) => int.tryParse(e)).whereNotNull();
if (purchases.isEmpty) return [];
final payments =
(await isar.paymentModels.getAll(purchases.toList())).whereNotNull();
return payments.toList();
});
}
Future<List<PaymentModel>> checkAndProcessUserPayments(
UserModel model,
) async {
final payments = await getUserPayments(model.id.toString());
final updatedPayments = <PaymentModel>[];
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<bool> 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 isar.txn(() => isar.paymentModels.get(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<bool> 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 isar.writeTxn(() {
return isar.paymentModels.put(updatedModel);
});
} else {
updatedModel =
model.copyWith(meta: jsonEncode(productPurchase.toJson()));
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
if (googleStatus == PaymentStatus.succeeded) {
try {
processPayment(updatedModel);
} catch (error) {
print(error);
return false;
}
}
final acknowledged =
await googlePurchaseHandler.acknowledge(productId, token);
if (!acknowledged) {
await isar.writeTxn(
() => isar.paymentModels.put(
updatedModel.copyWith(status: PaymentStatus.waiting),
),
);
}
return true;
}
return false;
}
Future<bool> 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 isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
if (updatedModel.status == PaymentStatus.succeeded) {
try {
processPayment(updatedModel);
} catch (error) {
print(error);
return false;
}
return true;
}
return false;
}
Future<PaymentModel> 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 isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
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 isar.writeTxn(() => isar.paymentModels.put(updatedModel));
print(
'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}',
);
} else if (updatedModel.status == PaymentStatus.unknown) {
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
return updatedModel;
}
}