1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-01 23:31:41 +01:00

Subscription change, invoice process update

This commit is contained in:
Chad Scharf 2020-05-12 12:48:21 -04:00
parent 00af142d63
commit a9a7003bfc
2 changed files with 186 additions and 275 deletions

View File

@ -333,70 +333,59 @@ namespace Bit.Core.Services
throw new BadRequestException("Subscription not found."); throw new BadRequestException("Subscription not found.");
} }
Func<bool, Task<SubscriptionItem>> subUpdateAction = null; var prorationDate = DateTime.UtcNow;
var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId); var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId);
var subItemOptions = sub.Items.Where(i => i.Plan.Id != plan.StripeSeatPlanId)
.Select(i => new InvoiceSubscriptionItemOptions
{
Id = i.Id,
Plan = i.Plan.Id,
Quantity = i.Quantity,
}).ToList();
if (additionalSeats > 0 && seatItem == null) var subResponse = await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions()
{ {
subItemOptions.Add(new InvoiceSubscriptionItemOptions Items = new List<SubscriptionItemOptions>()
{ {
new SubscriptionItemOptions()
{
Id = seatItem?.Id,
Plan = plan.StripeSeatPlanId, Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats, Quantity = additionalSeats,
}); Deleted = (seatItem?.Id != null && additionalSeats == 0) ? true : (bool?)null
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
new SubscriptionItemCreateOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats,
Prorate = prorate,
Subscription = sub.Id
});
} }
else if (additionalSeats > 0 && seatItem != null) },
{ ProrationBehavior = "always_invoice",
subItemOptions.Add(new InvoiceSubscriptionItemOptions PaymentBehavior = "allow_incomplete",
{ DaysUntilDue = 1,
Id = seatItem.Id, CollectionMethod = "send_invoice",
Plan = plan.StripeSeatPlanId, ProrationDate = prorationDate,
Quantity = additionalSeats,
}); });
subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(seatItem.Id,
new SubscriptionItemUpdateOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats,
Prorate = prorate
});
}
else if (seatItem != null && additionalSeats == 0)
{
subItemOptions.Add(new InvoiceSubscriptionItemOptions
{
Id = seatItem.Id,
Deleted = true
});
subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(seatItem.Id,
new SubscriptionItemDeleteOptions());
}
string paymentIntentClientSecret = null; string paymentIntentClientSecret = null;
var invoicedNow = false;
if (additionalSeats > 0) if (additionalSeats > 0)
{ {
var result = await (_paymentService as StripePaymentService).PreviewUpcomingInvoiceAndPayAsync( try
organization, plan.StripeSeatPlanId, subItemOptions, 500); {
invoicedNow = result.Item1; paymentIntentClientSecret = await (_paymentService as StripePaymentService)
paymentIntentClientSecret = result.Item2; .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 || organization.Seats == 0) ? true : (bool?)null
}
},
// This proration behavior prevents a false "credit" from
// being applied forward to the next month's invoice
ProrationBehavior = "none",
});
throw;
}
} }
await subUpdateAction(!invoicedNow);
organization.Seats = (short?)newSeatTotal; organization.Seats = (short?)newSeatTotal;
await ReplaceAndUpdateCache(organization); await ReplaceAndUpdateCache(organization);
return paymentIntentClientSecret; return paymentIntentClientSecret;

View File

@ -699,7 +699,6 @@ namespace Bit.Core.Services
public async Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, public async Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId) string storagePlanId)
{ {
var subscriptionItemService = new SubscriptionItemService();
var subscriptionService = new SubscriptionService(); var subscriptionService = new SubscriptionService();
var sub = await subscriptionService.GetAsync(storableSubscriber.GatewaySubscriptionId); var sub = await subscriptionService.GetAsync(storableSubscriber.GatewaySubscriptionId);
if (sub == null) if (sub == null)
@ -707,73 +706,60 @@ namespace Bit.Core.Services
throw new GatewayException("Subscription not found."); throw new GatewayException("Subscription not found.");
} }
Func<bool, Task<SubscriptionItem>> subUpdateAction = null; var prorationDate = DateTime.UtcNow;
var storageItem = sub.Items?.FirstOrDefault(i => i.Plan.Id == storagePlanId); var storageItem = sub.Items?.FirstOrDefault(i => i.Plan.Id == storagePlanId);
var subItemOptions = sub.Items.Where(i => i.Plan.Id != storagePlanId)
.Select(i => new InvoiceSubscriptionItemOptions
{
Id = i.Id,
Plan = i.Plan.Id,
Quantity = i.Quantity,
}).ToList();
if (additionalStorage > 0 && storageItem == null) var subResponse = await subscriptionService.UpdateAsync(sub.Id, new SubscriptionUpdateOptions()
{ {
subItemOptions.Add(new InvoiceSubscriptionItemOptions Items = new List<SubscriptionItemOptions>()
{ {
new SubscriptionItemOptions()
{
Id = storageItem?.Id,
Plan = storagePlanId, Plan = storagePlanId,
Quantity = additionalStorage, Quantity = additionalStorage,
}); Deleted = (storageItem?.Id != null && additionalStorage == 0) ? true : (bool?)null
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
new SubscriptionItemCreateOptions
{
Plan = storagePlanId,
Quantity = additionalStorage,
Subscription = sub.Id,
Prorate = prorate
});
} }
else if (additionalStorage > 0 && storageItem != null) },
{ ProrationBehavior = "always_invoice",
subItemOptions.Add(new InvoiceSubscriptionItemOptions PaymentBehavior = "allow_incomplete",
{ DaysUntilDue = 1,
Id = storageItem.Id, CollectionMethod = "send_invoice",
Plan = storagePlanId, ProrationDate = prorationDate,
Quantity = additionalStorage,
}); });
subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(storageItem.Id,
new SubscriptionItemUpdateOptions
{
Plan = storagePlanId,
Quantity = additionalStorage,
Prorate = prorate
});
}
else if (additionalStorage == 0 && storageItem != null)
{
subItemOptions.Add(new InvoiceSubscriptionItemOptions
{
Id = storageItem.Id,
Deleted = true
});
subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(storageItem.Id,
new SubscriptionItemDeleteOptions());
}
string paymentIntentClientSecret = null; string paymentIntentClientSecret = null;
var invoicedNow = false;
if (additionalStorage > 0) if (additionalStorage > 0)
{ {
var result = await PreviewUpcomingInvoiceAndPayAsync( try
storableSubscriber, storagePlanId, subItemOptions, 400); {
invoicedNow = result.Item1; paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
paymentIntentClientSecret = result.Item2; storableSubscriber, subResponse?.LatestInvoiceId);
}
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
}
},
// This proration behavior prevents a false "credit" from
// being applied forward to the next month's invoice
ProrationBehavior = "none",
});
throw;
}
} }
if (subUpdateAction != null)
{
await subUpdateAction(!invoicedNow);
}
return paymentIntentClientSecret; return paymentIntentClientSecret;
} }
@ -836,8 +822,7 @@ namespace Bit.Core.Services
await customerService.DeleteAsync(subscriber.GatewayCustomerId); await customerService.DeleteAsync(subscriber.GatewayCustomerId);
} }
public async Task<Tuple<bool, string>> PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId, public async Task<string> PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, string invoiceId)
List<InvoiceSubscriptionItemOptions> subItemOptions, int prorateThreshold = 500)
{ {
var customerService = new CustomerService(); var customerService = new CustomerService();
var customerOptions = new CustomerGetOptions(); var customerOptions = new CustomerGetOptions();
@ -852,34 +837,18 @@ namespace Bit.Core.Services
} }
var invoiceService = new InvoiceService(); var invoiceService = new InvoiceService();
var invoiceItemService = new InvoiceItemService();
string paymentIntentClientSecret = null; string paymentIntentClientSecret = null;
var pendingInvoiceItems = invoiceItemService.ListAutoPaging(new InvoiceItemListOptions var invoice = await invoiceService.GetAsync(invoiceId, new InvoiceGetOptions());
if (invoice == null)
{ {
Customer = subscriber.GatewayCustomerId throw new BadRequestException("Unable to locate draft invoice for subscription update.");
}).ToList().Where(i => i.InvoiceId == null); }
var pendingInvoiceItemsDict = pendingInvoiceItems.ToDictionary(pii => pii.Id);
var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions // Invoice them and pay now instead of waiting until Stripe does this automatically.
{
Customer = subscriber.GatewayCustomerId,
Subscription = subscriber.GatewaySubscriptionId,
SubscriptionItems = subItemOptions
});
var itemsForInvoice = upcomingPreview.Lines?.Data?
.Where(i => pendingInvoiceItemsDict.ContainsKey(i.Id) || (i.Plan.Id == planId && i.Proration));
var invoiceAmount = itemsForInvoice?.Sum(i => i.Amount) ?? 0;
var invoiceNow = invoiceAmount >= prorateThreshold;
if (invoiceNow)
{
// Owes more than prorateThreshold on next invoice.
// Invoice them and pay now instead of waiting until next billing cycle.
string cardPaymentMethodId = null; string cardPaymentMethodId = null;
var invoiceAmountDue = upcomingPreview.StartingBalance + invoiceAmount; if (invoice?.AmountDue > 0 && !customer.Metadata.ContainsKey("btCustomerId"))
if (invoiceAmountDue > 0 && !customer.Metadata.ContainsKey("btCustomerId"))
{ {
var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card"; var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
var hasDefaultValidSource = customer.DefaultSource != null && var hasDefaultValidSource = customer.DefaultSource != null &&
@ -889,44 +858,26 @@ namespace Bit.Core.Services
cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id; cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
if (cardPaymentMethodId == null) if (cardPaymentMethodId == null)
{ {
// We're going to delete this draft invoice, it can't be paid
await invoiceService.DeleteAsync(invoice.Id);
throw new BadRequestException("No payment method is available."); throw new BadRequestException("No payment method is available.");
} }
} }
} }
Invoice invoice = null;
var createdInvoiceItems = new List<InvoiceItem>();
Braintree.Transaction braintreeTransaction = null; Braintree.Transaction braintreeTransaction = null;
try try
{ {
foreach (var ii in itemsForInvoice) // Finalize the invoice (from Draft) w/o auto-advance so we
// can attempt payment manually.
invoice = await invoiceService.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions()
{ {
if (pendingInvoiceItemsDict.ContainsKey(ii.Id)) AutoAdvance = false,
{
continue;
}
var invoiceItem = await invoiceItemService.CreateAsync(new InvoiceItemCreateOptions
{
Currency = ii.Currency,
Description = ii.Description,
Customer = subscriber.GatewayCustomerId,
Subscription = ii.SubscriptionId,
Discountable = ii.Discountable,
Amount = ii.Amount
}); });
createdInvoiceItems.Add(invoiceItem); var invoicePayOptions = new InvoicePayOptions()
}
invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions
{ {
CollectionMethod = "send_invoice", PaymentMethod = cardPaymentMethodId,
DaysUntilDue = 1, };
Customer = subscriber.GatewayCustomerId,
Subscription = subscriber.GatewaySubscriptionId,
DefaultPaymentMethod = cardPaymentMethodId
});
var invoicePayOptions = new InvoicePayOptions();
if (invoice.AmountDue > 0) if (invoice.AmountDue > 0)
{ {
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
@ -958,26 +909,22 @@ namespace Bit.Core.Services
} }
braintreeTransaction = transactionResult.Target; braintreeTransaction = transactionResult.Target;
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions invoice = await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
{ {
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
["btTransactionId"] = braintreeTransaction.Id, ["btTransactionId"] = braintreeTransaction.Id,
["btPayPalTransactionId"] = ["btPayPalTransactionId"] =
braintreeTransaction.PayPalDetails.AuthorizationId braintreeTransaction.PayPalDetails.AuthorizationId
} },
}); });
} invoicePayOptions.PaidOutOfBand = true;
else
{
invoicePayOptions.OffSession = true;
invoicePayOptions.PaymentMethod = cardPaymentMethodId;
} }
} }
try try
{ {
await invoiceService.PayAsync(invoice.Id, invoicePayOptions); invoice = await invoiceService.PayAsync(invoice.Id, invoicePayOptions);
} }
catch (StripeException e) catch (StripeException e)
{ {
@ -1004,38 +951,13 @@ namespace Bit.Core.Services
} }
if (invoice != null) if (invoice != null)
{ {
await invoiceService.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); if (invoice.Status == "paid")
if (invoice.StartingBalance != 0)
{ {
await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions // It's apparently paid, so we need to return w/o throwing an exception
{ return paymentIntentClientSecret;
Balance = customer.Balance
});
} }
// Restore invoice items that were brought in await invoiceService.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions());
foreach (var item in pendingInvoiceItems)
{
var i = new 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 invoiceItemService.CreateAsync(i);
}
}
else
{
foreach (var ii in createdInvoiceItems)
{
await invoiceItemService.DeleteAsync(ii.Id);
}
} }
if (e is StripeException strEx && if (e is StripeException strEx &&
@ -1044,10 +966,10 @@ namespace Bit.Core.Services
throw new GatewayException("Bank account is not yet verified."); throw new GatewayException("Bank account is not yet verified.");
} }
throw e; // Let the caller perform any subscription change cleanup
throw;
} }
} return paymentIntentClientSecret;
return new Tuple<bool, string>(invoiceNow, paymentIntentClientSecret);
} }
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,