mirror of
https://github.com/bitwarden/server.git
synced 2025-04-08 22:58:11 -05:00
preview and pay to invoice prior to sub change
This commit is contained in:
parent
d236bdd408
commit
87ee144edd
@ -340,37 +340,67 @@ namespace Bit.Core.Services
|
|||||||
{
|
{
|
||||||
throw new BadRequestException("Subscription not found.");
|
throw new BadRequestException("Subscription not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Func<bool, Task<SubscriptionItem>> subUpdateAction = null;
|
||||||
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,
|
||||||
|
PlanId = i.Plan.Id,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
if(additionalSeats > 0 && seatItem == null)
|
if(additionalSeats > 0 && seatItem == null)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.CreateAsync(new SubscriptionItemCreateOptions
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
{
|
{
|
||||||
PlanId = plan.StripeSeatPlanId,
|
PlanId = plan.StripeSeatPlanId,
|
||||||
Quantity = additionalSeats,
|
Quantity = additionalSeats,
|
||||||
Prorate = true,
|
|
||||||
SubscriptionId = sub.Id
|
|
||||||
});
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
|
||||||
|
new SubscriptionItemCreateOptions
|
||||||
|
{
|
||||||
|
PlanId = plan.StripeSeatPlanId,
|
||||||
|
Quantity = additionalSeats,
|
||||||
|
Prorate = prorate,
|
||||||
|
SubscriptionId = sub.Id
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else if(additionalSeats > 0 && seatItem != null)
|
else if(additionalSeats > 0 && seatItem != null)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.UpdateAsync(seatItem.Id, new SubscriptionItemUpdateOptions
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
{
|
{
|
||||||
|
Id = seatItem.Id,
|
||||||
PlanId = plan.StripeSeatPlanId,
|
PlanId = plan.StripeSeatPlanId,
|
||||||
Quantity = additionalSeats,
|
Quantity = additionalSeats,
|
||||||
Prorate = true
|
|
||||||
});
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(seatItem.Id,
|
||||||
|
new SubscriptionItemUpdateOptions
|
||||||
|
{
|
||||||
|
PlanId = plan.StripeSeatPlanId,
|
||||||
|
Quantity = additionalSeats,
|
||||||
|
Prorate = prorate
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else if(seatItem != null && additionalSeats == 0)
|
else if(seatItem != null && additionalSeats == 0)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.DeleteAsync(seatItem.Id);
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = seatItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(seatItem.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var invoicedNow = false;
|
||||||
if(additionalSeats > 0)
|
if(additionalSeats > 0)
|
||||||
{
|
{
|
||||||
await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500);
|
invoicedNow = await _stripePaymentService.PreviewUpcomingInvoiceAndPayAsync(
|
||||||
|
organization, plan.StripeSeatPlanId, subItemOptions, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await subUpdateAction(!invoicedNow);
|
||||||
organization.Seats = (short?)newSeatTotal;
|
organization.Seats = (short?)newSeatTotal;
|
||||||
await ReplaceAndUpdateCache(organization);
|
await ReplaceAndUpdateCache(organization);
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ namespace Bit.Core.Services
|
|||||||
SourceToken = stipeCustomerSourceToken,
|
SourceToken = stipeCustomerSourceToken,
|
||||||
Metadata = stripeCustomerMetadata
|
Metadata = stripeCustomerMetadata
|
||||||
});
|
});
|
||||||
|
|
||||||
var subCreateOptions = new SubscriptionCreateOptions
|
var subCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
CustomerId = customer.Id,
|
CustomerId = customer.Id,
|
||||||
@ -205,35 +205,66 @@ namespace Bit.Core.Services
|
|||||||
throw new GatewayException("Subscription not found.");
|
throw new GatewayException("Subscription not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var storageItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId);
|
Func<bool, Task<SubscriptionItem>> subUpdateAction = null;
|
||||||
|
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,
|
||||||
|
PlanId = i.Plan.Id,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
if(additionalStorage > 0 && storageItem == null)
|
if(additionalStorage > 0 && storageItem == null)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.CreateAsync(new SubscriptionItemCreateOptions
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
{
|
{
|
||||||
PlanId = storagePlanId,
|
PlanId = storagePlanId,
|
||||||
Quantity = additionalStorage,
|
Quantity = additionalStorage,
|
||||||
Prorate = true,
|
|
||||||
SubscriptionId = sub.Id
|
|
||||||
});
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.CreateAsync(
|
||||||
|
new SubscriptionItemCreateOptions
|
||||||
|
{
|
||||||
|
PlanId = storagePlanId,
|
||||||
|
Quantity = additionalStorage,
|
||||||
|
SubscriptionId = sub.Id,
|
||||||
|
Prorate = prorate
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else if(additionalStorage > 0 && storageItem != null)
|
else if(additionalStorage > 0 && storageItem != null)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.UpdateAsync(storageItem.Id, new SubscriptionItemUpdateOptions
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
{
|
{
|
||||||
|
Id = storageItem.Id,
|
||||||
PlanId = storagePlanId,
|
PlanId = storagePlanId,
|
||||||
Quantity = additionalStorage,
|
Quantity = additionalStorage,
|
||||||
Prorate = true
|
|
||||||
});
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.UpdateAsync(storageItem.Id,
|
||||||
|
new SubscriptionItemUpdateOptions
|
||||||
|
{
|
||||||
|
PlanId = storagePlanId,
|
||||||
|
Quantity = additionalStorage,
|
||||||
|
Prorate = prorate
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else if(additionalStorage == 0 && storageItem != null)
|
else if(additionalStorage == 0 && storageItem != null)
|
||||||
{
|
{
|
||||||
await subscriptionItemService.DeleteAsync(storageItem.Id);
|
subItemOptions.Add(new InvoiceSubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = storageItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
subUpdateAction = (prorate) => subscriptionItemService.DeleteAsync(storageItem.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var invoicedNow = false;
|
||||||
if(additionalStorage > 0)
|
if(additionalStorage > 0)
|
||||||
{
|
{
|
||||||
await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 400);
|
invoicedNow = await PreviewUpcomingInvoiceAndPayAsync(
|
||||||
|
storableSubscriber, storagePlanId, subItemOptions, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await subUpdateAction(!invoicedNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber)
|
public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber)
|
||||||
@ -269,92 +300,142 @@ namespace Bit.Core.Services
|
|||||||
await customerService.DeleteAsync(subscriber.GatewayCustomerId);
|
await customerService.DeleteAsync(subscriber.GatewayCustomerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId,
|
public async Task<bool> PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId,
|
||||||
int prorateThreshold = 500)
|
List<InvoiceSubscriptionItemOptions> subItemOptions, int prorateThreshold = 500)
|
||||||
{
|
{
|
||||||
var invoiceService = new InvoiceService();
|
var invoiceService = new InvoiceService();
|
||||||
|
var invoiceItemService = new InvoiceItemService();
|
||||||
|
|
||||||
|
var pendingInvoiceItems = invoiceItemService.ListAutoPaging(new InvoiceItemListOptions
|
||||||
|
{
|
||||||
|
CustomerId = subscriber.GatewayCustomerId
|
||||||
|
}).ToList().Where(i => i.InvoiceId == null);
|
||||||
|
var pendingInvoiceItemsDict = pendingInvoiceItems.ToDictionary(pii => pii.Id);
|
||||||
|
|
||||||
var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions
|
var upcomingPreview = await invoiceService.UpcomingAsync(new UpcomingInvoiceOptions
|
||||||
{
|
{
|
||||||
CustomerId = subscriber.GatewayCustomerId,
|
CustomerId = subscriber.GatewayCustomerId,
|
||||||
SubscriptionId = subscriber.GatewaySubscriptionId
|
SubscriptionId = subscriber.GatewaySubscriptionId,
|
||||||
|
SubscriptionItems = subItemOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
var prorationAmount = upcomingPreview.Lines?.Data?
|
var itemsForInvoice = upcomingPreview.Lines?.Data?
|
||||||
.TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount);
|
.Where(i => pendingInvoiceItemsDict.ContainsKey(i.Id) || (i.Plan.Id == planId && i.Proration));
|
||||||
if(prorationAmount.GetValueOrDefault() >= prorateThreshold)
|
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 invoice = null;
|
||||||
|
var createdInvoiceItems = new List<InvoiceItem>();
|
||||||
|
Braintree.Transaction braintreeTransaction = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Owes more than prorateThreshold on next invoice.
|
foreach(var ii in itemsForInvoice)
|
||||||
// Invoice them and pay now instead of waiting until next billing cycle.
|
|
||||||
var invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions
|
|
||||||
{
|
{
|
||||||
CustomerId = subscriber.GatewayCustomerId,
|
if(pendingInvoiceItemsDict.ContainsKey(ii.Id))
|
||||||
SubscriptionId = subscriber.GatewaySubscriptionId
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var invoiceItem = await invoiceItemService.CreateAsync(new InvoiceItemCreateOptions
|
||||||
|
{
|
||||||
|
Currency = ii.Currency,
|
||||||
|
Description = ii.Description,
|
||||||
|
CustomerId = subscriber.GatewayCustomerId,
|
||||||
|
SubscriptionId = ii.SubscriptionId,
|
||||||
|
Discountable = ii.Discountable,
|
||||||
|
Amount = ii.Amount
|
||||||
|
});
|
||||||
|
createdInvoiceItems.Add(invoiceItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice = await invoiceService.CreateAsync(new InvoiceCreateOptions
|
||||||
|
{
|
||||||
|
Billing = Billing.SendInvoice,
|
||||||
|
DaysUntilDue = 1,
|
||||||
|
CustomerId = subscriber.GatewayCustomerId
|
||||||
});
|
});
|
||||||
|
|
||||||
var invoicePayOptions = new InvoicePayOptions();
|
var invoicePayOptions = new InvoicePayOptions();
|
||||||
if(invoice.AmountDue > 0)
|
var customerService = new CustomerService();
|
||||||
|
var customer = await customerService.GetAsync(subscriber.GatewayCustomerId);
|
||||||
|
if(customer != null)
|
||||||
{
|
{
|
||||||
var customerService = new CustomerService();
|
if(customer.Metadata.ContainsKey("btCustomerId"))
|
||||||
var customer = await customerService.GetAsync(subscriber.GatewayCustomerId);
|
|
||||||
if(customer != null)
|
|
||||||
{
|
{
|
||||||
Braintree.Transaction braintreeTransaction = null;
|
invoicePayOptions.PaidOutOfBand = true;
|
||||||
if(customer.Metadata.ContainsKey("btCustomerId"))
|
var btInvoiceAmount = (invoiceAmount / 100M);
|
||||||
|
var transactionResult = await _btGateway.Transaction.SaleAsync(
|
||||||
|
new Braintree.TransactionRequest
|
||||||
|
{
|
||||||
|
Amount = btInvoiceAmount,
|
||||||
|
CustomerId = customer.Metadata["btCustomerId"],
|
||||||
|
Options = new Braintree.TransactionOptionsRequest
|
||||||
|
{
|
||||||
|
SubmitForSettlement = true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!transactionResult.IsSuccess())
|
||||||
{
|
{
|
||||||
invoicePayOptions.PaidOutOfBand = true;
|
throw new GatewayException("Failed to charge PayPal customer.");
|
||||||
try
|
|
||||||
{
|
|
||||||
var btInvoiceAmount = (invoice.AmountDue / 100M);
|
|
||||||
var transactionResult = await _btGateway.Transaction.SaleAsync(
|
|
||||||
new Braintree.TransactionRequest
|
|
||||||
{
|
|
||||||
Amount = btInvoiceAmount,
|
|
||||||
CustomerId = customer.Metadata["btCustomerId"],
|
|
||||||
Options = new Braintree.TransactionOptionsRequest
|
|
||||||
{
|
|
||||||
SubmitForSettlement = true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if(!transactionResult.IsSuccess())
|
|
||||||
{
|
|
||||||
throw new GatewayException("Failed to charge PayPal customer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
braintreeTransaction = transactionResult.Target;
|
|
||||||
if(transactionResult.Target.Amount != btInvoiceAmount)
|
|
||||||
{
|
|
||||||
throw new GatewayException("PayPal charge mismatch.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
|
|
||||||
{
|
|
||||||
Metadata = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["btTransactionId"] = braintreeTransaction.Id,
|
|
||||||
["btPayPalTransactionId"] =
|
|
||||||
braintreeTransaction.PayPalDetails.AuthorizationId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
if(braintreeTransaction != null)
|
|
||||||
{
|
|
||||||
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
braintreeTransaction = transactionResult.Target;
|
||||||
|
await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions
|
||||||
|
{
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["btTransactionId"] = braintreeTransaction.Id,
|
||||||
|
["btPayPalTransactionId"] =
|
||||||
|
braintreeTransaction.PayPalDetails.AuthorizationId
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await invoiceService.PayAsync(invoice.Id, invoicePayOptions);
|
await invoiceService.PayAsync(invoice.Id, invoicePayOptions);
|
||||||
}
|
}
|
||||||
catch(StripeException) { }
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
if(braintreeTransaction != null)
|
||||||
|
{
|
||||||
|
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
|
||||||
|
}
|
||||||
|
if(invoice != null)
|
||||||
|
{
|
||||||
|
await invoiceService.DeleteAsync(invoice.Id);
|
||||||
|
|
||||||
|
// Restore invoice items that were brought in
|
||||||
|
foreach(var item in pendingInvoiceItems)
|
||||||
|
{
|
||||||
|
var i = new InvoiceItemCreateOptions
|
||||||
|
{
|
||||||
|
Currency = item.Currency,
|
||||||
|
Description = item.Description,
|
||||||
|
CustomerId = item.CustomerId,
|
||||||
|
SubscriptionId = item.SubscriptionId,
|
||||||
|
Discountable = item.Discountable,
|
||||||
|
Metadata = item.Metadata,
|
||||||
|
Quantity = item.Quantity,
|
||||||
|
UnitAmount = item.UnitAmount
|
||||||
|
};
|
||||||
|
await invoiceItemService.CreateAsync(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach(var ii in createdInvoiceItems)
|
||||||
|
{
|
||||||
|
await invoiceItemService.DeleteAsync(ii.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return invoiceNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
|
public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user