491 lines
16 KiB
Dart
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;
|
|
}
|
|
}
|