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:
parent
00af142d63
commit
a9a7003bfc
@ -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;
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user