1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-10 20:07:56 +01:00

Use invoice to pay if subscription set to invoice (#1571)

* Use invoice to pay if subscription set to invoice

* Apply suggestions from code review

Co-authored-by: Addison Beck <abeck@bitwarden.com>

* PR review

Move to subscriber model for subscription updates.

Co-authored-by: Addison Beck <abeck@bitwarden.com>
This commit is contained in:
Matt Gibson 2021-09-14 09:18:06 -04:00 committed by GitHub
parent cc76d45aef
commit 97b27220dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 157 additions and 145 deletions

View File

@ -0,0 +1,94 @@
using System.Linq;
using Bit.Core.Models.Table;
using Stripe;
using StaticStore = Bit.Core.Models.StaticStore;
namespace Bit.Core.Models.Business
{
public abstract class SubscriptionUpdate
{
protected abstract string PlanId { get; }
public abstract SubscriptionItemOptions RevertItemOptions(Subscription subscription);
public abstract SubscriptionItemOptions UpgradeItemOptions(Subscription subscription);
protected SubscriptionItem SubscriptionItem(Subscription subscription) =>
subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == PlanId);
}
public class SeatSubscriptionUpdate : SubscriptionUpdate
{
private readonly Organization _organization;
private readonly StaticStore.Plan _plan;
private readonly long? _additionalSeats;
protected override string PlanId => _plan.StripeSeatPlanId;
public SeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats)
{
_organization = organization;
_plan = plan;
_additionalSeats = additionalSeats;
}
public override SubscriptionItemOptions UpgradeItemOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription);
return new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanId,
Quantity = _additionalSeats,
Deleted = (item?.Id != null && _additionalSeats == 0) ? true : (bool?)null,
};
}
public override SubscriptionItemOptions RevertItemOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription);
return new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanId,
Quantity = _organization.Seats,
Deleted = item?.Id != null ? true : (bool?)null,
};
}
}
public class StorageSubscriptionUpdate : SubscriptionUpdate
{
private readonly string _plan;
private readonly long? _additionalStorage;
protected override string PlanId => _plan;
public StorageSubscriptionUpdate(string plan, long? additionalStorage)
{
_plan = plan;
_additionalStorage = additionalStorage;
}
public override SubscriptionItemOptions UpgradeItemOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription);
return new SubscriptionItemOptions
{
Id = item?.Id,
Plan = _plan,
Quantity = _additionalStorage,
Deleted = (item?.Id != null && _additionalStorage == 0) ? true : (bool?)null,
};
}
public override SubscriptionItemOptions RevertItemOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription);
return new SubscriptionItemOptions
{
Id = item?.Id,
Plan = _plan,
Quantity = item?.Quantity ?? 0,
Deleted = item?.Id != null ? true : (bool?)null,
};
}
}
}

View File

@ -15,6 +15,7 @@ namespace Bit.Core.Services
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustSeatsAsync(Organization organization, Models.StaticStore.Plan plan, int additionalSeats);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId);
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
bool skipInAppPurchaseCheck = false);

View File

@ -355,6 +355,11 @@ namespace Bit.Core.Services
throw new NotFoundException();
}
if (organization.Seats == null)
{
throw new BadRequestException("Organization has no seat limit, no need to adjust seats");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("No payment method found.");
@ -376,7 +381,7 @@ namespace Bit.Core.Services
throw new BadRequestException("Plan does not allow additional seats.");
}
var newSeatTotal = organization.Seats + seatAdjustment;
var newSeatTotal = organization.Seats.Value + seatAdjustment;
if (plan.BaseSeats > newSeatTotal)
{
throw new BadRequestException($"Plan has a minimum of {plan.BaseSeats} seats.");
@ -404,104 +409,7 @@ namespace Bit.Core.Services
}
}
var subscriptionItemService = new SubscriptionItemService();
var subscriptionService = new SubscriptionService();
var sub = await subscriptionService.GetAsync(organization.GatewaySubscriptionId);
if (sub == null)
{
throw new BadRequestException("Subscription not found.");
}
var prorationDate = DateTime.UtcNow;
var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId);
// Retain original collection method and days util due
var collectionMethod = sub.CollectionMethod;
var daysUntilDue = sub.DaysUntilDue;
var subUpdateOptions = new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Id = seatItem?.Id,
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats,
Deleted = (seatItem?.Id != null && additionalSeats == 0) ? true : (bool?)null
}
},
ProrationBehavior = "always_invoice",
CollectionMethod = "send_invoice",
DaysUntilDue = daysUntilDue ?? 1,
ProrationDate = prorationDate,
};
var customer = await new CustomerService().GetAsync(sub.CustomerId);
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
&& !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode))
{
var taxRates = await _taxRateRepository.GetByLocationAsync(
new Bit.Core.Models.Table.TaxRate()
{
Country = customer.Address.Country,
PostalCode = customer.Address.PostalCode
}
);
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id)))
{
subUpdateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
};
}
}
var subResponse = await subscriptionService.UpdateAsync(sub.Id, subUpdateOptions);
string paymentIntentClientSecret = null;
if (additionalSeats > 0)
{
try
{
paymentIntentClientSecret = await (_paymentService as StripePaymentService)
.PayInvoiceAfterSubscriptionChangeAsync(organization, subResponse.LatestInvoiceId);
}
catch
{
// Need to revert the subscription
await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Id = seatItem?.Id,
Plan = plan.StripeSeatPlanId,
Quantity = organization.Seats,
Deleted = seatItem?.Id == null ? true : (bool?)null
}
},
// This proration behavior prevents a false "credit" from
// being applied forward to the next month's invoice
ProrationBehavior = "none",
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
});
throw;
}
}
// Change back the subscription collection method and/or days until due
if (collectionMethod != "send_invoice" || daysUntilDue == null)
{
await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
});
}
var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization)
{

View File

@ -13,6 +13,7 @@ using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using StripeTaxRate = Stripe.TaxRate;
using TaxRate = Bit.Core.Models.Table.TaxRate;
using StaticStore = Bit.Core.Models.StaticStore;
namespace Bit.Core.Services
{
@ -54,7 +55,7 @@ namespace Bit.Core.Services
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb,
string paymentToken, StaticStore.Plan plan, short additionalStorageGb,
int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
{
var customerService = new CustomerService();
@ -106,7 +107,7 @@ namespace Bit.Core.Services
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
{
var taxRateSearch = new TaxRate()
var taxRateSearch = new TaxRate
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode
@ -201,7 +202,7 @@ namespace Bit.Core.Services
}
}
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan,
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan,
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
{
if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))
@ -221,7 +222,7 @@ namespace Bit.Core.Services
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
{
var taxRateSearch = new TaxRate()
var taxRateSearch = new TaxRate
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode
@ -445,8 +446,8 @@ namespace Bit.Core.Services
Quantity = 1,
});
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
&& !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode))
if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry)
&& !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode))
{
var taxRates = await _taxRateRepository.GetByLocationAsync(
new Bit.Core.Models.Table.TaxRate()
@ -458,9 +459,9 @@ namespace Bit.Core.Services
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null)
{
subCreateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
subCreateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
};
}
}
@ -692,8 +693,8 @@ namespace Bit.Core.Services
}).ToList();
}
public async Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId)
private async Task<string> FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber,
SubscriptionUpdate subscriptionUpdate)
{
var subscriptionService = new SubscriptionService();
var sub = await subscriptionService.GetAsync(storableSubscriber.GatewaySubscriptionId);
@ -703,30 +704,22 @@ namespace Bit.Core.Services
}
var prorationDate = DateTime.UtcNow;
var storageItem = sub.Items?.FirstOrDefault(i => i.Plan.Id == storagePlanId);
// Retain original collection method
var collectionMethod = sub.CollectionMethod;
var daysUntilDue = sub.DaysUntilDue;
var chargeNow = collectionMethod == "charge_automatically";
var updatedItemOptions = subscriptionUpdate.UpgradeItemOptions(sub);
var subUpdateOptions = new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Id = storageItem?.Id,
Plan = storagePlanId,
Quantity = additionalStorage,
Deleted = (storageItem?.Id != null && additionalStorage == 0) ? true : (bool?)null
}
},
Items = new List<SubscriptionItemOptions> { updatedItemOptions },
ProrationBehavior = "always_invoice",
DaysUntilDue = 1,
DaysUntilDue = daysUntilDue ?? 1,
CollectionMethod = "send_invoice",
ProrationDate = prorationDate,
};
var customer = await new CustomerService().GetAsync(sub.CustomerId);
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
&& !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode))
{
var taxRates = await _taxRateRepository.GetByLocationAsync(
@ -739,9 +732,9 @@ namespace Bit.Core.Services
var taxRate = taxRates.FirstOrDefault();
if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id)))
{
subUpdateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
subUpdateOptions.DefaultTaxRates = new List<string>(1)
{
taxRate.Id
};
}
}
@ -749,50 +742,66 @@ namespace Bit.Core.Services
var subResponse = await subscriptionService.UpdateAsync(sub.Id, subUpdateOptions);
string paymentIntentClientSecret = null;
if (additionalStorage > 0)
if (updatedItemOptions.Quantity > 0)
{
try
{
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
storableSubscriber, subResponse?.LatestInvoiceId);
if (chargeNow)
{
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
storableSubscriber, subResponse?.LatestInvoiceId);
}
else
{
var invoiceService = new InvoiceService();
var invoice = await invoiceService.FinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions
{
AutoAdvance = false,
});
await invoiceService.SendInvoiceAsync(invoice.Id, new InvoiceSendOptions());
paymentIntentClientSecret = null;
}
}
catch
{
// Need to revert the subscription
await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Id = storageItem?.Id,
Plan = storagePlanId,
Quantity = storageItem?.Quantity ?? 0,
Deleted = (storageItem?.Id == null || (storageItem?.Quantity ?? 0) == 0)
? true : (bool?)null
}
},
Items = new List<SubscriptionItemOptions> { subscriptionUpdate.RevertItemOptions(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;
}
}
// Change back the subscription collection method
if (collectionMethod != "send_invoice")
// Change back the subscription collection method and/or days until due
if (collectionMethod != "send_invoice" || daysUntilDue == null)
{
await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
});
}
return paymentIntentClientSecret;
}
public Task<string> AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats)
{
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats));
}
public Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId)
{
return FinalizeSubscriptionChangeAsync(storableSubscriber, new StorageSubscriptionUpdate(storagePlanId, additionalStorage));
}
public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber)
{
if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
@ -1671,10 +1680,10 @@ namespace Bit.Core.Services
{
return;
}
var stripeTaxRateService = new TaxRateService();
var updatedStripeTaxRate = await stripeTaxRateService.UpdateAsync(
taxRate.Id,
taxRate.Id,
new TaxRateUpdateOptions() { Active = false }
);
if (!updatedStripeTaxRate.Active)