1
0
mirror of https://github.com/bitwarden/server.git synced 2025-01-22 21:51:22 +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.");
}
Func<bool, Task<SubscriptionItem>> subUpdateAction = null;
var prorationDate = DateTime.UtcNow;
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,
Quantity = additionalSeats,
});
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
new SubscriptionItemCreateOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats,
Prorate = prorate,
Subscription = sub.Id
});
Deleted = (seatItem?.Id != null && additionalSeats == 0) ? true : (bool?)null
}
else if (additionalSeats > 0 && seatItem != null)
{
subItemOptions.Add(new InvoiceSubscriptionItemOptions
{
Id = seatItem.Id,
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats,
},
ProrationBehavior = "always_invoice",
PaymentBehavior = "allow_incomplete",
DaysUntilDue = 1,
CollectionMethod = "send_invoice",
ProrationDate = prorationDate,
});
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;
var invoicedNow = false;
if (additionalSeats > 0)
{
var result = await (_paymentService as StripePaymentService).PreviewUpcomingInvoiceAndPayAsync(
organization, plan.StripeSeatPlanId, subItemOptions, 500);
invoicedNow = result.Item1;
paymentIntentClientSecret = result.Item2;
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 || 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;
await ReplaceAndUpdateCache(organization);
return paymentIntentClientSecret;

View File

@ -699,7 +699,6 @@ namespace Bit.Core.Services
public async Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId)
{
var subscriptionItemService = new SubscriptionItemService();
var subscriptionService = new SubscriptionService();
var sub = await subscriptionService.GetAsync(storableSubscriber.GatewaySubscriptionId);
if (sub == null)
@ -707,73 +706,60 @@ namespace Bit.Core.Services
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 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,
Quantity = additionalStorage,
});
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
new SubscriptionItemCreateOptions
{
Plan = storagePlanId,
Quantity = additionalStorage,
Subscription = sub.Id,
Prorate = prorate
});
Deleted = (storageItem?.Id != null && additionalStorage == 0) ? true : (bool?)null
}
else if (additionalStorage > 0 && storageItem != null)
{
subItemOptions.Add(new InvoiceSubscriptionItemOptions
{
Id = storageItem.Id,
Plan = storagePlanId,
Quantity = additionalStorage,
},
ProrationBehavior = "always_invoice",
PaymentBehavior = "allow_incomplete",
DaysUntilDue = 1,
CollectionMethod = "send_invoice",
ProrationDate = prorationDate,
});
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;
var invoicedNow = false;
if (additionalStorage > 0)
{
var result = await PreviewUpcomingInvoiceAndPayAsync(
storableSubscriber, storagePlanId, subItemOptions, 400);
invoicedNow = result.Item1;
paymentIntentClientSecret = result.Item2;
try
{
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(
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;
}
@ -836,8 +822,7 @@ namespace Bit.Core.Services
await customerService.DeleteAsync(subscriber.GatewayCustomerId);
}
public async Task<Tuple<bool, string>> PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId,
List<InvoiceSubscriptionItemOptions> subItemOptions, int prorateThreshold = 500)
public async Task<string> PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, string invoiceId)
{
var customerService = new CustomerService();
var customerOptions = new CustomerGetOptions();
@ -852,34 +837,18 @@ namespace Bit.Core.Services
}
var invoiceService = new InvoiceService();
var invoiceItemService = new InvoiceItemService();
string paymentIntentClientSecret = null;
var pendingInvoiceItems = invoiceItemService.ListAutoPaging(new InvoiceItemListOptions
var invoice = await invoiceService.GetAsync(invoiceId, new InvoiceGetOptions());
if (invoice == null)
{
Customer = subscriber.GatewayCustomerId
}).ToList().Where(i => i.InvoiceId == null);
var pendingInvoiceItemsDict = pendingInvoiceItems.ToDictionary(pii => pii.Id);
throw new BadRequestException("Unable to locate draft invoice for subscription update.");
}
var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions
{
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.
// Invoice them and pay now instead of waiting until Stripe does this automatically.
string cardPaymentMethodId = null;
var invoiceAmountDue = upcomingPreview.StartingBalance + invoiceAmount;
if (invoiceAmountDue > 0 && !customer.Metadata.ContainsKey("btCustomerId"))
if (invoice?.AmountDue > 0 && !customer.Metadata.ContainsKey("btCustomerId"))
{
var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
var hasDefaultValidSource = customer.DefaultSource != null &&
@ -889,44 +858,26 @@ namespace Bit.Core.Services
cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
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.");
}
}
}
Invoice invoice = null;
var createdInvoiceItems = new List<InvoiceItem>();
Braintree.Transaction braintreeTransaction = null;
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))
{
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
AutoAdvance = false,
});
createdInvoiceItems.Add(invoiceItem);
}
invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions
var invoicePayOptions = new InvoicePayOptions()
{
CollectionMethod = "send_invoice",
DaysUntilDue = 1,
Customer = subscriber.GatewayCustomerId,
Subscription = subscriber.GatewaySubscriptionId,
DefaultPaymentMethod = cardPaymentMethodId
});
var invoicePayOptions = new InvoicePayOptions();
PaymentMethod = cardPaymentMethodId,
};
if (invoice.AmountDue > 0)
{
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
@ -958,26 +909,22 @@ namespace Bit.Core.Services
}
braintreeTransaction = transactionResult.Target;
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
invoice = await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
["btTransactionId"] = braintreeTransaction.Id,
["btPayPalTransactionId"] =
braintreeTransaction.PayPalDetails.AuthorizationId
}
},
});
}
else
{
invoicePayOptions.OffSession = true;
invoicePayOptions.PaymentMethod = cardPaymentMethodId;
invoicePayOptions.PaidOutOfBand = true;
}
}
try
{
await invoiceService.PayAsync(invoice.Id, invoicePayOptions);
invoice = await invoiceService.PayAsync(invoice.Id, invoicePayOptions);
}
catch (StripeException e)
{
@ -1004,38 +951,13 @@ namespace Bit.Core.Services
}
if (invoice != null)
{
await invoiceService.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions());
if (invoice.StartingBalance != 0)
if (invoice.Status == "paid")
{
await customerService.UpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance
});
// It's apparently paid, so we need to return w/o throwing an exception
return paymentIntentClientSecret;
}
// Restore invoice items that were brought in
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);
}
await invoiceService.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions());
}
if (e is StripeException strEx &&
@ -1044,10 +966,10 @@ namespace Bit.Core.Services
throw new GatewayException("Bank account is not yet verified.");
}
throw e;
// Let the caller perform any subscription change cleanup
throw;
}
}
return new Tuple<bool, string>(invoiceNow, paymentIntentClientSecret);
return paymentIntentClientSecret;
}
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,