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:
parent
cc76d45aef
commit
97b27220dd
94
src/Core/Models/Business/SubscriptionUpdate.cs
Normal file
94
src/Core/Models/Business/SubscriptionUpdate.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user