From d9faa9a6dfd7d68b6f19c1d1ba9bb40b6d54496f Mon Sep 17 00:00:00 2001
From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
Date: Wed, 1 Nov 2023 15:19:28 +0100
Subject: [PATCH] [PM-3892] Implement dollar threshold for all subscriptions
(#3283)
* Initial commit
* Fix the failing text
* Fix the unpaid invoice issue
* fix the unpaid invoice issue
* Changes for the threshold amount
* remove the billing threshold
* Add some comments to the old method
* Fixing issues on secret manager test
* import missing package
* Resolve pr comments
* Refactor PreviewUpcomingInvoiceAndPayAsync method
* Resolve some pr comments
* Resolving the comment around constant
* Resolve pr comment
* Add new class
* Resolve pr comments
* Change the prorateThreshold from 5 to 500 dollars
* Fix the failing test
* Fix the server returns a 500 error with the banner
---
src/Core/Constants.cs | 14 +
.../Models/Business/InvoicePreviewResult.cs | 7 +
.../Models/Business/PendingInoviceItems.cs | 9 +
.../Business/SecretsManagerSubscribeUpdate.cs | 8 +-
src/Core/Services/IStripeAdapter.cs | 4 +
.../Services/Implementations/StripeAdapter.cs | 18 +
.../Implementations/StripePaymentService.cs | 392 +++++++++++++++---
.../Services/StripePaymentServiceTests.cs | 296 +++++++++++++
8 files changed, 692 insertions(+), 56 deletions(-)
create mode 100644 src/Core/Models/Business/InvoicePreviewResult.cs
create mode 100644 src/Core/Models/Business/PendingInoviceItems.cs
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 908d0a7861..e32b0f4ce4 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -24,6 +24,20 @@ public static class Constants
public const string Fido2KeyCipherMinimumVersion = "2023.10.0";
public const string CipherKeyEncryptionMinimumVersion = "2023.9.2";
+
+ ///
+ /// When you set the ProrationBehavior to create_prorations,
+ /// Stripe will automatically create prorations for any changes made to the subscription,
+ /// such as changing the plan, adding or removing quantities, or applying discounts.
+ ///
+ public const string CreateProrations = "create_prorations";
+
+ ///
+ /// When you set the ProrationBehavior to always_invoice,
+ /// Stripe will always generate an invoice when a subscription update occurs,
+ /// regardless of whether there is a proration or not.
+ ///
+ public const string AlwaysInvoice = "always_invoice";
}
public static class TokenPurposes
diff --git a/src/Core/Models/Business/InvoicePreviewResult.cs b/src/Core/Models/Business/InvoicePreviewResult.cs
new file mode 100644
index 0000000000..d9e211cfb2
--- /dev/null
+++ b/src/Core/Models/Business/InvoicePreviewResult.cs
@@ -0,0 +1,7 @@
+namespace Bit.Core.Models.Business;
+
+public class InvoicePreviewResult
+{
+ public bool IsInvoicedNow { get; set; }
+ public string PaymentIntentClientSecret { get; set; }
+}
diff --git a/src/Core/Models/Business/PendingInoviceItems.cs b/src/Core/Models/Business/PendingInoviceItems.cs
new file mode 100644
index 0000000000..1aee15a3aa
--- /dev/null
+++ b/src/Core/Models/Business/PendingInoviceItems.cs
@@ -0,0 +1,9 @@
+using Stripe;
+
+namespace Bit.Core.Models.Business;
+
+public class PendingInoviceItems
+{
+ public IEnumerable PendingInvoiceItems { get; set; }
+ public IDictionary PendingInvoiceItemsDict { get; set; }
+}
diff --git a/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs
index 8f3fb89349..54bc8cb95e 100644
--- a/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs
+++ b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs
@@ -44,7 +44,7 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate
{
updatedItems.Add(new SubscriptionItemOptions
{
- Price = _plan.SecretsManager.StripeSeatPlanId,
+ Plan = _plan.SecretsManager.StripeSeatPlanId,
Quantity = _additionalSeats
});
}
@@ -53,7 +53,7 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate
{
updatedItems.Add(new SubscriptionItemOptions
{
- Price = _plan.SecretsManager.StripeServiceAccountPlanId,
+ Plan = _plan.SecretsManager.StripeServiceAccountPlanId,
Quantity = _additionalServiceAccounts
});
}
@@ -63,14 +63,14 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate
{
updatedItems.Add(new SubscriptionItemOptions
{
- Price = _plan.SecretsManager.StripeSeatPlanId,
+ Plan = _plan.SecretsManager.StripeSeatPlanId,
Quantity = _previousSeats,
Deleted = _previousSeats == 0 ? true : (bool?)null,
});
updatedItems.Add(new SubscriptionItemOptions
{
- Price = _plan.SecretsManager.StripeServiceAccountPlanId,
+ Plan = _plan.SecretsManager.StripeServiceAccountPlanId,
Quantity = _previousServiceAccounts,
Deleted = _previousServiceAccounts == 0 ? true : (bool?)null,
});
diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs
index ff922161cc..4f1cdd37eb 100644
--- a/src/Core/Services/IStripeAdapter.cs
+++ b/src/Core/Services/IStripeAdapter.cs
@@ -1,4 +1,5 @@
using Bit.Core.Models.BitStripe;
+using Stripe;
namespace Bit.Core.Services;
@@ -14,8 +15,11 @@ public interface IStripeAdapter
Task SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null);
Task SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null);
Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
+ Task InvoiceCreateAsync(Stripe.InvoiceCreateOptions options);
+ Task InvoiceItemCreateAsync(Stripe.InvoiceItemCreateOptions options);
Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
Task> InvoiceListAsync(Stripe.InvoiceListOptions options);
+ IEnumerable InvoiceItemListAsync(InvoiceItemListOptions options);
Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options);
Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options);
Task InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options);
diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs
index b4776bc6ef..478d092fdc 100644
--- a/src/Core/Services/Implementations/StripeAdapter.cs
+++ b/src/Core/Services/Implementations/StripeAdapter.cs
@@ -1,4 +1,5 @@
using Bit.Core.Models.BitStripe;
+using Stripe;
namespace Bit.Core.Services;
@@ -16,6 +17,7 @@ public class StripeAdapter : IStripeAdapter
private readonly Stripe.BankAccountService _bankAccountService;
private readonly Stripe.PriceService _priceService;
private readonly Stripe.TestHelpers.TestClockService _testClockService;
+ private readonly Stripe.InvoiceItemService _invoiceItemService;
public StripeAdapter()
{
@@ -31,6 +33,7 @@ public class StripeAdapter : IStripeAdapter
_bankAccountService = new Stripe.BankAccountService();
_priceService = new Stripe.PriceService();
_testClockService = new Stripe.TestHelpers.TestClockService();
+ _invoiceItemService = new Stripe.InvoiceItemService();
}
public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options)
@@ -79,6 +82,16 @@ public class StripeAdapter : IStripeAdapter
return _invoiceService.UpcomingAsync(options);
}
+ public Task InvoiceCreateAsync(Stripe.InvoiceCreateOptions options)
+ {
+ return _invoiceService.CreateAsync(options);
+ }
+
+ public Task InvoiceItemCreateAsync(Stripe.InvoiceItemCreateOptions options)
+ {
+ return _invoiceItemService.CreateAsync(options);
+ }
+
public Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options)
{
return _invoiceService.GetAsync(id, options);
@@ -89,6 +102,11 @@ public class StripeAdapter : IStripeAdapter
return _invoiceService.ListAsync(options);
}
+ public IEnumerable InvoiceItemListAsync(InvoiceItemListOptions options)
+ {
+ return _invoiceItemService.ListAutoPaging(options);
+ }
+
public Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options)
{
return _invoiceService.UpdateAsync(id, options);
diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs
index 0f7965db77..214b2bff10 100644
--- a/src/Core/Services/Implementations/StripePaymentService.cs
+++ b/src/Core/Services/Implementations/StripePaymentService.cs
@@ -6,6 +6,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
+using Stripe;
using StaticStore = Bit.Core.Models.StaticStore;
using TaxRate = Bit.Core.Entities.TaxRate;
@@ -749,16 +750,14 @@ public class StripePaymentService : IPaymentService
prorationDate ??= DateTime.UtcNow;
var collectionMethod = sub.CollectionMethod;
var daysUntilDue = sub.DaysUntilDue;
- var chargeNow = collectionMethod == "charge_automatically";
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
var subUpdateOptions = new Stripe.SubscriptionUpdateOptions
{
Items = updatedItemOptions,
- ProrationBehavior = "always_invoice",
+ ProrationBehavior = Constants.CreateProrations,
DaysUntilDue = daysUntilDue ?? 1,
- CollectionMethod = "send_invoice",
- ProrationDate = prorationDate,
+ CollectionMethod = "send_invoice"
};
if (!subscriptionUpdate.UpdateNeeded(sub))
@@ -792,66 +791,50 @@ public class StripePaymentService : IPaymentService
string paymentIntentClientSecret = null;
try
{
- var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions);
+ var subItemOptions = updatedItemOptions.Select(itemOption =>
+ new Stripe.InvoiceSubscriptionItemOptions
+ {
+ Id = itemOption.Id,
+ Plan = itemOption.Plan,
+ Quantity = itemOption.Quantity,
+ }).ToList();
- var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions());
+ var reviewInvoiceResponse = await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, subItemOptions);
+ paymentIntentClientSecret = reviewInvoiceResponse.PaymentIntentClientSecret;
+
+ var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions);
+ var invoice =
+ await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions());
if (invoice == null)
{
throw new BadRequestException("Unable to locate draft invoice for subscription update.");
}
-
- if (invoice.AmountDue > 0 && updatedItemOptions.Any(i => i.Quantity > 0))
+ }
+ catch (Exception e)
+ {
+ // Need to revert the subscription
+ await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
{
- try
- {
- if (chargeNow)
- {
- paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
- storableSubscriber, invoice);
- }
- else
- {
- invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions
- {
- AutoAdvance = false,
- });
- await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions());
- paymentIntentClientSecret = null;
- }
- }
- catch
- {
- // Need to revert the subscription
- await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
- {
- Items = subscriptionUpdate.RevertItemsOptions(sub),
- // This proration behavior prevents a false "credit" from
- // being applied forward to the next month's invoice
- ProrationBehavior = "none",
- CollectionMethod = collectionMethod,
- DaysUntilDue = daysUntilDue,
- });
- throw;
- }
- }
- else if (!invoice.Paid)
- {
- // Pay invoice with no charge to customer this completes the invoice immediately without waiting the scheduled 1h
- invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId);
- paymentIntentClientSecret = null;
- }
-
+ Items = subscriptionUpdate.RevertItemsOptions(sub),
+ // This proration behavior prevents a false "credit" from
+ // being applied forward to the next month's invoice
+ ProrationBehavior = "none",
+ CollectionMethod = collectionMethod,
+ DaysUntilDue = daysUntilDue,
+ });
+ throw;
}
finally
{
// Change back the subscription collection method and/or days until due
if (collectionMethod != "send_invoice" || daysUntilDue == null)
{
- await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions
- {
- CollectionMethod = collectionMethod,
- DaysUntilDue = daysUntilDue,
- });
+ await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
+ new Stripe.SubscriptionUpdateOptions
+ {
+ CollectionMethod = collectionMethod,
+ DaysUntilDue = daysUntilDue,
+ });
}
}
@@ -934,6 +917,7 @@ public class StripePaymentService : IPaymentService
await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId);
}
+ //This method is no-longer is use because we return the dollar threshold feature on invoice will be generated. but we dont want to lose this implementation.
public async Task PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Stripe.Invoice invoice)
{
var customerOptions = new Stripe.CustomerGetOptions();
@@ -1103,6 +1087,310 @@ public class StripePaymentService : IPaymentService
return paymentIntentClientSecret;
}
+ internal async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber,
+ List subItemOptions, int prorateThreshold = 50000)
+ {
+ var customer = await CheckInAppPurchaseMethod(subscriber);
+
+ string paymentIntentClientSecret = null;
+
+ var pendingInvoiceItems = GetPendingInvoiceItems(subscriber);
+
+ var upcomingPreview = await GetUpcomingInvoiceAsync(subscriber, subItemOptions);
+
+ var itemsForInvoice = GetItemsForInvoice(subItemOptions, upcomingPreview, pendingInvoiceItems);
+ var invoiceAmount = itemsForInvoice?.Sum(i => i.Amount) ?? 0;
+ var invoiceNow = invoiceAmount >= prorateThreshold;
+ if (invoiceNow)
+ {
+ await ProcessImmediateInvoiceAsync(subscriber, upcomingPreview, invoiceAmount, customer, itemsForInvoice, pendingInvoiceItems, paymentIntentClientSecret);
+ }
+
+ return new InvoicePreviewResult { IsInvoicedNow = invoiceNow, PaymentIntentClientSecret = paymentIntentClientSecret };
+ }
+
+ private async Task ProcessImmediateInvoiceAsync(ISubscriber subscriber, Invoice upcomingPreview, long invoiceAmount,
+ Customer customer, IEnumerable itemsForInvoice, PendingInoviceItems pendingInvoiceItems,
+ string paymentIntentClientSecret)
+ {
+ // Owes more than prorateThreshold on the next invoice.
+ // Invoice them and pay now instead of waiting until the next billing cycle.
+
+ string cardPaymentMethodId = null;
+ var invoiceAmountDue = upcomingPreview.StartingBalance + invoiceAmount;
+ cardPaymentMethodId = GetCardPaymentMethodId(invoiceAmountDue, customer, cardPaymentMethodId);
+
+ Stripe.Invoice invoice = null;
+ var createdInvoiceItems = new List();
+ Braintree.Transaction braintreeTransaction = null;
+
+ try
+ {
+ await CreateInvoiceItemsAsync(subscriber, itemsForInvoice, pendingInvoiceItems, createdInvoiceItems);
+
+ invoice = await CreateInvoiceAsync(subscriber, cardPaymentMethodId);
+
+ var invoicePayOptions = new Stripe.InvoicePayOptions();
+ await CreateBrainTreeTransactionRequestAsync(subscriber, invoice, customer, invoicePayOptions,
+ cardPaymentMethodId, braintreeTransaction);
+
+ await InvoicePayAsync(invoicePayOptions, invoice, paymentIntentClientSecret);
+ }
+ catch (Exception e)
+ {
+ if (braintreeTransaction != null)
+ {
+ await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
+ }
+
+ if (invoice != null)
+ {
+ if (invoice.Status == "paid")
+ {
+ // It's apparently paid, so we return without throwing an exception
+ return new InvoicePreviewResult
+ {
+ IsInvoicedNow = false,
+ PaymentIntentClientSecret = paymentIntentClientSecret
+ };
+ }
+
+ await RestoreInvoiceItemsAsync(invoice, customer, pendingInvoiceItems.PendingInvoiceItems);
+ }
+ else
+ {
+ foreach (var ii in createdInvoiceItems)
+ {
+ await _stripeAdapter.InvoiceDeleteAsync(ii.Id);
+ }
+ }
+
+ if (e is Stripe.StripeException strEx &&
+ (strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
+ {
+ throw new GatewayException("Bank account is not yet verified.");
+ }
+
+ throw;
+ }
+
+ return new InvoicePreviewResult
+ {
+ IsInvoicedNow = false,
+ PaymentIntentClientSecret = paymentIntentClientSecret
+ };
+ }
+
+ private static IEnumerable GetItemsForInvoice(List subItemOptions, Invoice upcomingPreview,
+ PendingInoviceItems pendingInvoiceItems)
+ {
+ var itemsForInvoice = upcomingPreview.Lines?.Data?
+ .Where(i => pendingInvoiceItems.PendingInvoiceItemsDict.ContainsKey(i.Id) ||
+ (i.Plan.Id == subItemOptions[0]?.Plan && i.Proration));
+ return itemsForInvoice;
+ }
+
+ private PendingInoviceItems GetPendingInvoiceItems(ISubscriber subscriber)
+ {
+ var pendingInvoiceItems = new PendingInoviceItems();
+ var invoiceItems = _stripeAdapter.InvoiceItemListAsync(new Stripe.InvoiceItemListOptions
+ {
+ Customer = subscriber.GatewayCustomerId
+ }).ToList().Where(i => i.InvoiceId == null);
+ pendingInvoiceItems.PendingInvoiceItemsDict = invoiceItems.ToDictionary(pii => pii.Id);
+ return pendingInvoiceItems;
+ }
+
+ private async Task CheckInAppPurchaseMethod(ISubscriber subscriber)
+ {
+ var customerOptions = GetCustomerPaymentOptions();
+ 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.");
+ }
+
+ return customer;
+ }
+
+ private string GetCardPaymentMethodId(long invoiceAmountDue, Customer customer, string cardPaymentMethodId)
+ {
+ try
+ {
+ if (invoiceAmountDue <= 0 || customer.Metadata.ContainsKey("btCustomerId")) return cardPaymentMethodId;
+ var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
+ var hasDefaultValidSource = customer.DefaultSource != null &&
+ (customer.DefaultSource is Stripe.Card ||
+ customer.DefaultSource is Stripe.BankAccount);
+ if (hasDefaultCardPaymentMethod || hasDefaultValidSource) return cardPaymentMethodId;
+ cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
+ if (cardPaymentMethodId == null)
+ {
+ throw new BadRequestException("No payment method is available.");
+ }
+ }
+ catch (Exception e)
+ {
+ throw new BadRequestException("No payment method is available.");
+ }
+
+
+ return cardPaymentMethodId;
+ }
+
+ private async Task GetUpcomingInvoiceAsync(ISubscriber subscriber, List subItemOptions)
+ {
+ var upcomingPreview = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
+ {
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = subscriber.GatewaySubscriptionId,
+ SubscriptionItems = subItemOptions
+ });
+ return upcomingPreview;
+ }
+
+ private async Task RestoreInvoiceItemsAsync(Invoice invoice, Customer customer, IEnumerable pendingInvoiceItems)
+ {
+ invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new Stripe.InvoiceVoidOptions());
+ if (invoice.StartingBalance != 0)
+ {
+ await _stripeAdapter.CustomerUpdateAsync(customer.Id,
+ new Stripe.CustomerUpdateOptions { Balance = customer.Balance });
+ }
+
+ // Restore invoice items that were brought in
+ foreach (var item in pendingInvoiceItems)
+ {
+ var i = new Stripe.InvoiceItemCreateOptions
+ {
+ Currency = item.Currency,
+ Description = item.Description,
+ Customer = item.CustomerId,
+ Subscription = item.SubscriptionId,
+ Discountable = item.Discountable,
+ Metadata = item.Metadata,
+ Quantity = item.Proration ? 1 : item.Quantity,
+ UnitAmount = item.UnitAmount
+ };
+ await _stripeAdapter.InvoiceItemCreateAsync(i);
+ }
+ }
+
+ private async Task InvoicePayAsync(InvoicePayOptions invoicePayOptions, Invoice invoice, string paymentIntentClientSecret)
+ {
+ try
+ {
+ await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions);
+ }
+ catch (Stripe.StripeException e)
+ {
+ if (e.HttpStatusCode == System.Net.HttpStatusCode.PaymentRequired &&
+ e.StripeError?.Code == "invoice_payment_intent_requires_action")
+ {
+ // SCA required, get intent client secret
+ var invoiceGetOptions = new Stripe.InvoiceGetOptions();
+ invoiceGetOptions.AddExpand("payment_intent");
+ invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions);
+ paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret;
+ }
+ else
+ {
+ throw new GatewayException("Unable to pay invoice.");
+ }
+ }
+ }
+
+ private async Task CreateBrainTreeTransactionRequestAsync(ISubscriber subscriber, Invoice invoice, Customer customer,
+ InvoicePayOptions invoicePayOptions, string cardPaymentMethodId, Braintree.Transaction braintreeTransaction)
+ {
+ if (invoice.AmountDue > 0)
+ {
+ if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
+ {
+ invoicePayOptions.PaidOutOfBand = true;
+ var btInvoiceAmount = (invoice.AmountDue / 100M);
+ var transactionResult = await _btGateway.Transaction.SaleAsync(
+ new Braintree.TransactionRequest
+ {
+ Amount = btInvoiceAmount,
+ CustomerId = customer.Metadata["btCustomerId"],
+ Options = new Braintree.TransactionOptionsRequest
+ {
+ SubmitForSettlement = true,
+ PayPal = new Braintree.TransactionOptionsPayPalRequest
+ {
+ CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id}"
+ }
+ },
+ CustomFields = new Dictionary
+ {
+ [subscriber.BraintreeIdField()] = subscriber.Id.ToString()
+ }
+ });
+
+ if (!transactionResult.IsSuccess())
+ {
+ throw new GatewayException("Failed to charge PayPal customer.");
+ }
+
+ braintreeTransaction = transactionResult.Target;
+ await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions
+ {
+ Metadata = new Dictionary
+ {
+ ["btTransactionId"] = braintreeTransaction.Id,
+ ["btPayPalTransactionId"] =
+ braintreeTransaction.PayPalDetails.AuthorizationId
+ }
+ });
+ }
+ else
+ {
+ invoicePayOptions.OffSession = true;
+ invoicePayOptions.PaymentMethod = cardPaymentMethodId;
+ }
+ }
+ }
+
+ private async Task CreateInvoiceAsync(ISubscriber subscriber, string cardPaymentMethodId)
+ {
+ Invoice invoice;
+ invoice = await _stripeAdapter.InvoiceCreateAsync(new Stripe.InvoiceCreateOptions
+ {
+ CollectionMethod = "send_invoice",
+ DaysUntilDue = 1,
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = subscriber.GatewaySubscriptionId,
+ DefaultPaymentMethod = cardPaymentMethodId
+ });
+ return invoice;
+ }
+
+ private async Task CreateInvoiceItemsAsync(ISubscriber subscriber, IEnumerable itemsForInvoice,
+ PendingInoviceItems pendingInvoiceItems, List createdInvoiceItems)
+ {
+ foreach (var invoiceLineItem in itemsForInvoice)
+ {
+ if (pendingInvoiceItems.PendingInvoiceItemsDict.ContainsKey(invoiceLineItem.Id))
+ {
+ continue;
+ }
+
+ var invoiceItem = await _stripeAdapter.InvoiceItemCreateAsync(new Stripe.InvoiceItemCreateOptions
+ {
+ Currency = invoiceLineItem.Currency,
+ Description = invoiceLineItem.Description,
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = invoiceLineItem.Subscription,
+ Discountable = invoiceLineItem.Discountable,
+ Amount = invoiceLineItem.Amount
+ });
+ createdInvoiceItems.Add(invoiceItem);
+ }
+ }
+
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
bool skipInAppPurchaseCheck = false)
{
diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs
index 2133a14a97..9ef4b0233c 100644
--- a/test/Core.Test/Services/StripePaymentServiceTests.cs
+++ b/test/Core.Test/Services/StripePaymentServiceTests.cs
@@ -739,4 +739,300 @@ public class StripePaymentServiceTests
Assert.Null(result);
}
+
+ [Theory, BitAutoData]
+ public async Task PreviewUpcomingInvoiceAndPayAsync_WithInAppPaymentMethod_ThrowsBadRequestException(SutProvider sutProvider,
+ Organization subscriber, List subItemOptions)
+ {
+ var stripeAdapter = sutProvider.GetDependency();
+ stripeAdapter.CustomerGetAsync(Arg.Any(), Arg.Any())
+ .Returns(new Stripe.Customer { Metadata = new Dictionary { { "appleReceipt", "dummyData" } } });
+
+ var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, subItemOptions));
+ Assert.Equal("Cannot perform this action with in-app purchase payment method. Contact support.", ex.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async void PreviewUpcomingInvoiceAndPayAsync_UpcomingInvoiceBelowThreshold_DoesNotInvoiceNow(SutProvider sutProvider,
+ Organization subscriber, List subItemOptions)
+ {
+ var prorateThreshold = 50000;
+ var invoiceAmountBelowThreshold = prorateThreshold - 100;
+ var customer = MockStripeCustomer(subscriber);
+ sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer);
+ var invoiceItem = MockInoviceItemList(subscriber, "planId", invoiceAmountBelowThreshold, customer);
+ sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions
+ {
+ Customer = subscriber.GatewayCustomerId
+ }).ReturnsForAnyArgs(invoiceItem);
+
+ var invoiceLineItem = CreateInvoiceLineTime(subscriber, "planId", invoiceAmountBelowThreshold);
+ sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
+ {
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = subscriber.GatewaySubscriptionId,
+ SubscriptionItems = subItemOptions
+ }).ReturnsForAnyArgs(invoiceLineItem);
+
+ sutProvider.GetDependency().InvoiceCreateAsync(Arg.Is(options =>
+ options.CollectionMethod == "send_invoice" &&
+ options.DaysUntilDue == 1 &&
+ options.Customer == subscriber.GatewayCustomerId &&
+ options.Subscription == subscriber.GatewaySubscriptionId &&
+ options.DefaultPaymentMethod == customer.InvoiceSettings.DefaultPaymentMethod.Id
+ )).ReturnsForAnyArgs(new Stripe.Invoice
+ {
+ Id = "mockInvoiceId",
+ CollectionMethod = "send_invoice",
+ DueDate = DateTime.Now.AddDays(1),
+ Customer = customer,
+ Subscription = new Stripe.Subscription
+ {
+ Id = "mockSubscriptionId",
+ Customer = customer,
+ Status = "active",
+ CurrentPeriodStart = DateTime.UtcNow,
+ CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1),
+ CollectionMethod = "charge_automatically",
+ },
+ DefaultPaymentMethod = customer.InvoiceSettings.DefaultPaymentMethod,
+ AmountDue = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ Status = "draft",
+ });
+
+ var result = await sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, new List(), prorateThreshold);
+
+ Assert.False(result.IsInvoicedNow);
+ Assert.Null(result.PaymentIntentClientSecret);
+ }
+
+ [Theory, BitAutoData]
+ public async void PreviewUpcomingInvoiceAndPayAsync_NoPaymentMethod_ThrowsBadRequestException(SutProvider sutProvider,
+ Organization subscriber, List subItemOptions, string planId)
+ {
+ var prorateThreshold = 120000;
+ var invoiceAmountBelowThreshold = prorateThreshold;
+ var customer = new Stripe.Customer
+ {
+ Metadata = new Dictionary(),
+ Id = subscriber.GatewayCustomerId,
+ DefaultSource = null,
+ InvoiceSettings = new Stripe.CustomerInvoiceSettings
+ {
+ DefaultPaymentMethod = null
+ }
+ };
+ sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer);
+ var invoiceItem = MockInoviceItemList(subscriber, planId, invoiceAmountBelowThreshold, customer);
+ sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions
+ {
+ Customer = subscriber.GatewayCustomerId
+ }).ReturnsForAnyArgs(invoiceItem);
+
+ var invoiceLineItem = CreateInvoiceLineTime(subscriber, planId, invoiceAmountBelowThreshold);
+ sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
+ {
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = subscriber.GatewaySubscriptionId,
+ SubscriptionItems = subItemOptions
+ }).ReturnsForAnyArgs(invoiceLineItem);
+
+ var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, subItemOptions));
+ Assert.Equal("No payment method is available.", ex.Message);
+ }
+
+ [Theory, BitAutoData]
+ public async void PreviewUpcomingInvoiceAndPayAsync_UpcomingInvoiceAboveThreshold_DoesInvoiceNow(SutProvider sutProvider,
+ Organization subscriber, List subItemOptions, string planId)
+ {
+ var prorateThreshold = 50000;
+ var invoiceAmountBelowThreshold = 100000;
+ var customer = MockStripeCustomer(subscriber);
+ sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer);
+ var invoiceItem = MockInoviceItemList(subscriber, planId, invoiceAmountBelowThreshold, customer);
+ sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions
+ {
+ Customer = subscriber.GatewayCustomerId
+ }).ReturnsForAnyArgs(invoiceItem);
+
+ var invoiceLineItem = CreateInvoiceLineTime(subscriber, planId, invoiceAmountBelowThreshold);
+ sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions
+ {
+ Customer = subscriber.GatewayCustomerId,
+ Subscription = subscriber.GatewaySubscriptionId,
+ SubscriptionItems = subItemOptions
+ }).ReturnsForAnyArgs(invoiceLineItem);
+
+ var invoice = MockInVoice(customer, invoiceAmountBelowThreshold);
+ sutProvider.GetDependency().InvoiceCreateAsync(Arg.Is(options =>
+ options.CollectionMethod == "send_invoice" &&
+ options.DaysUntilDue == 1 &&
+ options.Customer == subscriber.GatewayCustomerId &&
+ options.Subscription == subscriber.GatewaySubscriptionId &&
+ options.DefaultPaymentMethod == customer.InvoiceSettings.DefaultPaymentMethod.Id
+ )).ReturnsForAnyArgs(invoice);
+
+ var result = await sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, new List(), prorateThreshold);
+
+ await sutProvider.GetDependency().Received(1).InvoicePayAsync(invoice.Id,
+ Arg.Is((options =>
+ options.OffSession == true
+ )));
+
+
+ Assert.True(result.IsInvoicedNow);
+ Assert.Null(result.PaymentIntentClientSecret);
+ }
+
+ private static Stripe.Invoice MockInVoice(Stripe.Customer customer, int invoiceAmountBelowThreshold) =>
+ new()
+ {
+ Id = "mockInvoiceId",
+ CollectionMethod = "send_invoice",
+ DueDate = DateTime.Now.AddDays(1),
+ Customer = customer,
+ Subscription = new Stripe.Subscription
+ {
+ Id = "mockSubscriptionId",
+ Customer = customer,
+ Status = "active",
+ CurrentPeriodStart = DateTime.UtcNow,
+ CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1),
+ CollectionMethod = "charge_automatically",
+ },
+ DefaultPaymentMethod = customer.InvoiceSettings.DefaultPaymentMethod,
+ AmountDue = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ Status = "draft",
+ };
+
+ private static List MockInoviceItemList(Organization subscriber, string planId, int invoiceAmountBelowThreshold, Stripe.Customer customer) =>
+ new()
+ {
+ new Stripe.InvoiceItem
+ {
+ Id = "ii_1234567890",
+ Amount = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ CustomerId = subscriber.GatewayCustomerId,
+ Description = "Sample invoice item 1",
+ Date = DateTime.UtcNow,
+ Discountable = true,
+ InvoiceId = "548458365"
+ },
+ new Stripe.InvoiceItem
+ {
+ Id = "ii_0987654321",
+ Amount = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ CustomerId = customer.Id,
+ Description = "Sample invoice item 2",
+ Date = DateTime.UtcNow.AddDays(-5),
+ Discountable = false,
+ InvoiceId = null,
+ Proration = true,
+ Plan = new Stripe.Plan
+ {
+ Id = planId,
+ Amount = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ Interval = "month",
+ IntervalCount = 1,
+ },
+ }
+ };
+
+ private static Stripe.Customer MockStripeCustomer(Organization subscriber)
+ {
+ var customer = new Stripe.Customer
+ {
+ Metadata = new Dictionary(),
+ Id = subscriber.GatewayCustomerId,
+ DefaultSource = new Stripe.Card
+ {
+ Id = "card_12345",
+ Last4 = "1234",
+ Brand = "Visa",
+ ExpYear = 2025,
+ ExpMonth = 12
+ },
+ InvoiceSettings = new Stripe.CustomerInvoiceSettings
+ {
+ DefaultPaymentMethod = new Stripe.PaymentMethod
+ {
+ Id = "pm_12345",
+ Type = "card",
+ Card = new Stripe.PaymentMethodCard
+ {
+ Last4 = "1234",
+ Brand = "Visa",
+ ExpYear = 2025,
+ ExpMonth = 12
+ }
+ }
+ }
+ };
+ return customer;
+ }
+
+ private static Stripe.Invoice CreateInvoiceLineTime(Organization subscriber, string planId, int invoiceAmountBelowThreshold) =>
+ new()
+ {
+ AmountDue = invoiceAmountBelowThreshold,
+ AmountPaid = 0,
+ AmountRemaining = invoiceAmountBelowThreshold,
+ CustomerId = subscriber.GatewayCustomerId,
+ SubscriptionId = subscriber.GatewaySubscriptionId,
+ ApplicationFeeAmount = 0,
+ Currency = "usd",
+ Description = "Upcoming Invoice",
+ Discount = null,
+ DueDate = DateTime.UtcNow.AddDays(1),
+ EndingBalance = 0,
+ Number = "INV12345",
+ Paid = false,
+ PeriodStart = DateTime.UtcNow,
+ PeriodEnd = DateTime.UtcNow.AddMonths(1),
+ ReceiptNumber = null,
+ StartingBalance = 0,
+ Status = "draft",
+ Id = "ii_0987654321",
+ Total = invoiceAmountBelowThreshold,
+ Lines = new Stripe.StripeList
+ {
+ Data = new List
+ {
+ new Stripe.InvoiceLineItem
+ {
+ Amount = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ Description = "Sample line item",
+ Id = "ii_0987654321",
+ Livemode = false,
+ Object = "line_item",
+ Discountable = false,
+ Period = new Stripe.InvoiceLineItemPeriod()
+ {
+ Start = DateTime.UtcNow,
+ End = DateTime.UtcNow.AddMonths(1)
+ },
+ Plan = new Stripe.Plan
+ {
+ Id = planId,
+ Amount = invoiceAmountBelowThreshold,
+ Currency = "usd",
+ Interval = "month",
+ IntervalCount = 1,
+ },
+ Proration = true,
+ Quantity = 1,
+ Subscription = subscriber.GatewaySubscriptionId,
+ SubscriptionItem = "si_12345",
+ Type = "subscription",
+ UnitAmountExcludingTax = invoiceAmountBelowThreshold,
+ }
+ }
+ }
+ };
}