1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

[PM-5548] Eliminate in-app purchase logic (#3640)

* Eliminate in-app purchase logic

* Totally remove obsolete and unused properties / types

* Remove unused enum values

* Restore token update
This commit is contained in:
Matt Bishop
2024-01-11 15:26:32 -05:00
committed by GitHub
parent b9c6e00c2d
commit 23f9d2261d
20 changed files with 19 additions and 809 deletions

View File

@ -1,132 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Bit.Billing.Models;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class AppleIapService : IAppleIapService
{
private readonly HttpClient _httpClient = new HttpClient();
private readonly GlobalSettings _globalSettings;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly IMetaDataRepository _metaDataRepository;
private readonly ILogger<AppleIapService> _logger;
public AppleIapService(
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
IMetaDataRepository metaDataRepository,
ILogger<AppleIapService> logger)
{
_globalSettings = globalSettings;
_hostingEnvironment = hostingEnvironment;
_metaDataRepository = metaDataRepository;
_logger = logger;
}
public async Task<AppleReceiptStatus> GetVerifiedReceiptStatusAsync(string receiptData)
{
var receiptStatus = await GetReceiptStatusAsync(receiptData);
if (receiptStatus?.Status != 0)
{
return null;
}
var validEnvironment = _globalSettings.AppleIap.AppInReview ||
(!(_hostingEnvironment.IsProduction() || _hostingEnvironment.IsEnvironment("QA")) && receiptStatus.Environment == "Sandbox") ||
((_hostingEnvironment.IsProduction() || _hostingEnvironment.IsEnvironment("QA")) && receiptStatus.Environment != "Sandbox");
var validProductBundle = receiptStatus.Receipt.BundleId == "com.bitwarden.desktop" ||
receiptStatus.Receipt.BundleId == "com.8bit.bitwarden";
var validProduct = receiptStatus.LatestReceiptInfo.LastOrDefault()?.ProductId == "premium_annually";
var validIds = receiptStatus.GetOriginalTransactionId() != null &&
receiptStatus.GetLastTransactionId() != null;
var validTransaction = receiptStatus.GetLastExpiresDate()
.GetValueOrDefault(DateTime.MinValue) > DateTime.UtcNow;
if (validEnvironment && validProductBundle && validProduct && validIds && validTransaction)
{
return receiptStatus;
}
return null;
}
public async Task SaveReceiptAsync(AppleReceiptStatus receiptStatus, Guid userId)
{
var originalTransactionId = receiptStatus.GetOriginalTransactionId();
if (string.IsNullOrWhiteSpace(originalTransactionId))
{
throw new Exception("OriginalTransactionId is null");
}
await _metaDataRepository.UpsertAsync("AppleReceipt", originalTransactionId,
new Dictionary<string, string>
{
["Data"] = receiptStatus.GetReceiptData(),
["UserId"] = userId.ToString()
});
}
public async Task<Tuple<string, Guid?>> GetReceiptAsync(string originalTransactionId)
{
var receipt = await _metaDataRepository.GetAsync("AppleReceipt", originalTransactionId);
if (receipt == null)
{
return null;
}
return new Tuple<string, Guid?>(receipt.ContainsKey("Data") ? receipt["Data"] : null,
receipt.ContainsKey("UserId") ? new Guid(receipt["UserId"]) : (Guid?)null);
}
// Internal for testing
internal async Task<AppleReceiptStatus> GetReceiptStatusAsync(string receiptData, bool prod = true,
int attempt = 0, AppleReceiptStatus lastReceiptStatus = null)
{
try
{
if (attempt > 4)
{
throw new Exception(
$"Failed verifying Apple IAP after too many attempts. Last attempt status: {lastReceiptStatus?.Status.ToString() ?? "null"}");
}
var url = string.Format("https://{0}.itunes.apple.com/verifyReceipt", prod ? "buy" : "sandbox");
var response = await _httpClient.PostAsJsonAsync(url, new AppleVerifyReceiptRequestModel
{
ReceiptData = receiptData,
Password = _globalSettings.AppleIap.Password
});
if (response.IsSuccessStatusCode)
{
var receiptStatus = await response.Content.ReadFromJsonAsync<AppleReceiptStatus>();
if (receiptStatus.Status == 21007)
{
return await GetReceiptStatusAsync(receiptData, false, attempt + 1, receiptStatus);
}
else if (receiptStatus.Status == 21005)
{
await Task.Delay(2000);
return await GetReceiptStatusAsync(receiptData, prod, attempt + 1, receiptStatus);
}
return receiptStatus;
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Error verifying Apple IAP receipt.");
}
return null;
}
}
public class AppleVerifyReceiptRequestModel
{
[JsonPropertyName("receipt-data")]
public string ReceiptData { get; set; }
[JsonPropertyName("password")]
public string Password { get; set; }
}

View File

@ -1,5 +1,4 @@
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -16,14 +15,11 @@ namespace Bit.Core.Services;
public class StripePaymentService : IPaymentService
{
private const string PremiumPlanId = "premium-annually";
private const string PremiumPlanAppleIapId = "premium-annually-appleiap";
private const decimal PremiumPlanAppleIapPrice = 14.99M;
private const string StoragePlanId = "storage-gb-annually";
private const string ProviderDiscountId = "msp-discount-35";
private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository;
private readonly IAppleIapService _appleIapService;
private readonly ILogger<StripePaymentService> _logger;
private readonly Braintree.IBraintreeGateway _btGateway;
private readonly ITaxRateRepository _taxRateRepository;
@ -33,7 +29,6 @@ public class StripePaymentService : IPaymentService
public StripePaymentService(
ITransactionRepository transactionRepository,
IUserRepository userRepository,
IAppleIapService appleIapService,
ILogger<StripePaymentService> logger,
ITaxRateRepository taxRateRepository,
IStripeAdapter stripeAdapter,
@ -42,7 +37,6 @@ public class StripePaymentService : IPaymentService
{
_transactionRepository = transactionRepository;
_userRepository = userRepository;
_appleIapService = appleIapService;
_logger = logger;
_taxRateRepository = taxRateRepository;
_stripeAdapter = stripeAdapter;
@ -345,21 +339,16 @@ public class StripePaymentService : IPaymentService
{
throw new BadRequestException("Your account does not have any credit available.");
}
if (paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.GoogleInApp)
if (paymentMethodType is PaymentMethodType.BankAccount)
{
throw new GatewayException("Payment method is not supported at this time.");
}
if ((paymentMethodType == PaymentMethodType.GoogleInApp ||
paymentMethodType == PaymentMethodType.AppleInApp) && additionalStorageGb > 0)
{
throw new BadRequestException("You cannot add storage with this payment method.");
}
var createdStripeCustomer = false;
Stripe.Customer customer = null;
Braintree.Customer braintreeCustomer = null;
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
paymentMethodType == PaymentMethodType.BankAccount || paymentMethodType == PaymentMethodType.Credit;
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount
or PaymentMethodType.Credit;
string stipeCustomerPaymentMethodId = null;
string stipeCustomerSourceToken = null;
@ -379,19 +368,9 @@ public class StripePaymentService : IPaymentService
{
if (!string.IsNullOrWhiteSpace(paymentToken))
{
try
{
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, true, taxInfo);
}
catch (Exception e)
{
var message = e.Message.ToLowerInvariant();
if (message.Contains("apple") || message.Contains("in-app"))
{
throw;
}
}
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo);
}
try
{
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId);
@ -425,18 +404,6 @@ public class StripePaymentService : IPaymentService
braintreeCustomer = customerResult.Target;
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
}
else if (paymentMethodType == PaymentMethodType.AppleInApp)
{
var verifiedReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
if (verifiedReceiptStatus == null)
{
throw new GatewayException("Cannot verify apple in-app purchase.");
}
var receiptOriginalTransactionId = verifiedReceiptStatus.GetOriginalTransactionId();
await VerifyAppleReceiptNotInUseAsync(receiptOriginalTransactionId, user);
await _appleIapService.SaveReceiptAsync(verifiedReceiptStatus, user.Id);
stripeCustomerMetadata.Add("appleReceipt", receiptOriginalTransactionId);
}
else if (!stripePaymentMethod)
{
throw new GatewayException("Payment method is not supported at this time.");
@ -488,8 +455,8 @@ public class StripePaymentService : IPaymentService
subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions
{
Plan = paymentMethodType == PaymentMethodType.AppleInApp ? PremiumPlanAppleIapId : PremiumPlanId,
Quantity = 1,
Plan = PremiumPlanId,
Quantity = 1
});
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
@ -547,7 +514,6 @@ public class StripePaymentService : IPaymentService
{
var addedCreditToStripeCustomer = false;
Braintree.Transaction braintreeTransaction = null;
Transaction appleTransaction = null;
var subInvoiceMetadata = new Dictionary<string, string>();
Stripe.Subscription subscription = null;
@ -564,39 +530,9 @@ public class StripePaymentService : IPaymentService
if (previewInvoice.AmountDue > 0)
{
var appleReceiptOrigTransactionId = customer.Metadata != null &&
customer.Metadata.ContainsKey("appleReceipt") ? customer.Metadata["appleReceipt"] : null;
var braintreeCustomerId = customer.Metadata != null &&
customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null;
if (!string.IsNullOrWhiteSpace(appleReceiptOrigTransactionId))
{
if (!subscriber.IsUser())
{
throw new GatewayException("In-app purchase is only allowed for users.");
}
var appleReceipt = await _appleIapService.GetReceiptAsync(
appleReceiptOrigTransactionId);
var verifiedAppleReceipt = await _appleIapService.GetVerifiedReceiptStatusAsync(
appleReceipt.Item1);
if (verifiedAppleReceipt == null)
{
throw new GatewayException("Failed to get Apple in-app purchase receipt data.");
}
subInvoiceMetadata.Add("appleReceipt", verifiedAppleReceipt.GetOriginalTransactionId());
var lastTransactionId = verifiedAppleReceipt.GetLastTransactionId();
subInvoiceMetadata.Add("appleReceiptTransactionId", lastTransactionId);
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(
GatewayType.AppStore, lastTransactionId);
if (existingTransaction == null)
{
appleTransaction = verifiedAppleReceipt.BuildTransactionFromLastTransaction(
PremiumPlanAppleIapPrice, subscriber.Id);
appleTransaction.Type = TransactionType.Charge;
await _transactionRepository.CreateAsync(appleTransaction);
}
}
else if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
{
var btInvoiceAmount = (previewInvoice.AmountDue / 100M);
var transactionResult = await _btGateway.Transaction.SaleAsync(
@ -712,10 +648,6 @@ public class StripePaymentService : IPaymentService
{
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
}
if (appleTransaction != null)
{
await _transactionRepository.DeleteAsync(appleTransaction);
}
if (e is Stripe.StripeException strEx &&
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
@ -965,12 +897,6 @@ public class StripePaymentService : IPaymentService
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions);
var usingInAppPaymentMethod = customer.Metadata.ContainsKey("appleReceipt");
if (usingInAppPaymentMethod)
{
throw new BadRequestException("Cannot perform this action with in-app purchase payment method. " +
"Contact support.");
}
string paymentIntentClientSecret = null;
@ -1128,8 +1054,7 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret;
}
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
bool skipInAppPurchaseCheck = false)
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
{
if (subscriber == null)
{
@ -1141,15 +1066,6 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("No subscription.");
}
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId) && !skipInAppPurchaseCheck)
{
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
if (customer.Metadata.ContainsKey("appleReceipt"))
{
throw new BadRequestException("You are required to manage your subscription from the app store.");
}
}
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
if (sub == null)
{
@ -1216,7 +1132,7 @@ public class StripePaymentService : IPaymentService
}
public async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType,
string paymentToken, bool allowInAppPurchases = false, TaxInfo taxInfo = null)
string paymentToken, TaxInfo taxInfo = null)
{
if (subscriber == null)
{
@ -1230,7 +1146,6 @@ public class StripePaymentService : IPaymentService
}
var createdCustomer = false;
AppleReceiptStatus appleReceiptStatus = null;
Braintree.Customer braintreeCustomer = null;
string stipeCustomerSourceToken = null;
string stipeCustomerPaymentMethodId = null;
@ -1238,23 +1153,10 @@ public class StripePaymentService : IPaymentService
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
paymentMethodType == PaymentMethodType.BankAccount;
var inAppPurchase = paymentMethodType == PaymentMethodType.AppleInApp ||
paymentMethodType == PaymentMethodType.GoogleInApp;
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount;
Stripe.Customer customer = null;
if (!allowInAppPurchases && inAppPurchase)
{
throw new GatewayException("In-app purchase payment method is not allowed.");
}
if (!subscriber.IsUser() && inAppPurchase)
{
throw new GatewayException("In-app purchase payment method is only allowed for users.");
}
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
var options = new Stripe.CustomerGetOptions();
@ -1266,16 +1168,6 @@ public class StripePaymentService : IPaymentService
}
}
if (inAppPurchase && customer != null && customer.Balance != 0)
{
throw new GatewayException("Customer balance cannot exist when using in-app purchases.");
}
if (!inAppPurchase && customer != null && stripeCustomerMetadata.ContainsKey("appleReceipt"))
{
throw new GatewayException("Cannot change from in-app payment method. Contact support.");
}
var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId");
if (stripePaymentMethod)
{
@ -1345,15 +1237,6 @@ public class StripePaymentService : IPaymentService
braintreeCustomer = customerResult.Target;
}
}
else if (paymentMethodType == PaymentMethodType.AppleInApp)
{
appleReceiptStatus = await _appleIapService.GetVerifiedReceiptStatusAsync(paymentToken);
if (appleReceiptStatus == null)
{
throw new GatewayException("Cannot verify Apple in-app purchase.");
}
await VerifyAppleReceiptNotInUseAsync(appleReceiptStatus.GetOriginalTransactionId(), subscriber);
}
else
{
throw new GatewayException("Payment method is not supported at this time.");
@ -1373,25 +1256,6 @@ public class StripePaymentService : IPaymentService
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
}
if (appleReceiptStatus != null)
{
var originalTransactionId = appleReceiptStatus.GetOriginalTransactionId();
if (stripeCustomerMetadata.ContainsKey("appleReceipt"))
{
if (originalTransactionId != stripeCustomerMetadata["appleReceipt"])
{
var nowSec = Utilities.CoreHelpers.ToEpocSeconds(DateTime.UtcNow);
stripeCustomerMetadata.Add($"appleReceipt_{nowSec}", stripeCustomerMetadata["appleReceipt"]);
}
stripeCustomerMetadata["appleReceipt"] = originalTransactionId;
}
else
{
stripeCustomerMetadata.Add("appleReceipt", originalTransactionId);
}
await _appleIapService.SaveReceiptAsync(appleReceiptStatus, subscriber.Id);
}
try
{
if (customer == null)
@ -1595,11 +1459,6 @@ public class StripePaymentService : IPaymentService
{
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount);
}
if (subscriber.IsUser())
{
subscriptionInfo.UsingInAppPurchase = customer.Metadata.ContainsKey("appleReceipt");
}
}
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
@ -1762,19 +1621,6 @@ public class StripePaymentService : IPaymentService
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault();
}
private async Task VerifyAppleReceiptNotInUseAsync(string receiptOriginalTransactionId, ISubscriber subscriber)
{
var existingReceipt = await _appleIapService.GetReceiptAsync(receiptOriginalTransactionId);
if (existingReceipt != null && existingReceipt.Item2.HasValue && existingReceipt.Item2 != subscriber.Id)
{
var existingUser = await _userRepository.GetByIdAsync(existingReceipt.Item2.Value);
if (existingUser != null)
{
throw new GatewayException("Apple receipt already in use by another user.");
}
}
}
private decimal GetBillingBalance(Stripe.Customer customer)
{
return customer != null ? customer.Balance / 100M : default;
@ -1787,14 +1633,6 @@ public class StripePaymentService : IPaymentService
return null;
}
if (customer.Metadata?.ContainsKey("appleReceipt") ?? false)
{
return new BillingInfo.BillingSource
{
Type = PaymentMethodType.AppleInApp
};
}
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
{
try

View File

@ -255,7 +255,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
{
try
{
await CancelPremiumAsync(user, null, true);
await CancelPremiumAsync(user);
}
catch (GatewayException) { }
}
@ -973,12 +973,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
throw new BadRequestException("You can't subtract storage!");
}
if ((paymentMethodType == PaymentMethodType.GoogleInApp ||
paymentMethodType == PaymentMethodType.AppleInApp) && additionalStorageGb > 0)
{
throw new BadRequestException("You cannot add storage with this payment method.");
}
string paymentIntentClientSecret = null;
IPaymentService paymentService = null;
if (_globalSettings.SelfHosted)
@ -1039,29 +1033,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
paymentIntentClientSecret);
}
public async Task IapCheckAsync(User user, PaymentMethodType paymentMethodType)
{
if (paymentMethodType != PaymentMethodType.AppleInApp)
{
throw new BadRequestException("Payment method not supported for in-app purchases.");
}
if (user.Premium)
{
throw new BadRequestException("Already a premium user.");
}
if (!string.IsNullOrWhiteSpace(user.GatewayCustomerId))
{
var customerService = new Stripe.CustomerService();
var customer = await customerService.GetAsync(user.GatewayCustomerId);
if (customer != null && customer.Balance != 0)
{
throw new BadRequestException("Customer balance cannot exist when using in-app purchases.");
}
}
}
public async Task UpdateLicenseAsync(User user, UserLicense license)
{
if (!_globalSettings.SelfHosted)
@ -1136,7 +1107,7 @@ public class UserService : UserManager<User>, IUserService, IDisposable
}
}
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null, bool accountDelete = false)
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null)
{
var eop = endOfPeriod.GetValueOrDefault(true);
if (!endOfPeriod.HasValue && user.PremiumExpirationDate.HasValue &&
@ -1144,11 +1115,11 @@ public class UserService : UserManager<User>, IUserService, IDisposable
{
eop = false;
}
await _paymentService.CancelSubscriptionAsync(user, eop, accountDelete);
await _paymentService.CancelSubscriptionAsync(user, eop);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.CancelSubscription, user, _currentContext)
{
EndOfPeriod = eop,
EndOfPeriod = eop
});
}