From fa565f46c61a9464b51384a2397d4672afb60531 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 10 Apr 2017 16:42:53 -0400 Subject: [PATCH] uncancel and manual prograte billing if add seats --- bitwarden-core.sln | 9 +- .../Controllers/OrganizationsController.cs | 15 +- src/Core/Core.csproj | 5 +- .../Api/Response/OrganizationResponseModel.cs | 2 + src/Core/Services/IOrganizationService.cs | 1 + .../Implementations/OrganizationService.cs | 148 +++++++++++++++--- 6 files changed, 157 insertions(+), 23 deletions(-) diff --git a/bitwarden-core.sln b/bitwarden-core.sln index fd4ae3c44..5bac72065 100644 --- a/bitwarden-core.sln +++ b/bitwarden-core.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26228.9 +VisualStudioVersion = 15.0.26403.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" EndProject @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mail", "src\Mail\Mail.cspro EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing", "src\Billing\Billing.csproj", "{02BC2982-ED8D-4A6D-A41E-092B3DAEB98A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stripe.net", "..\stripe.net\src\Stripe.net\Stripe.net.csproj", "{F9D4653F-1E61-44E3-953F-0D67D60195C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +52,10 @@ Global {02BC2982-ED8D-4A6D-A41E-092B3DAEB98A}.Debug|Any CPU.Build.0 = Debug|Any CPU {02BC2982-ED8D-4A6D-A41E-092B3DAEB98A}.Release|Any CPU.ActiveCfg = Release|Any CPU {02BC2982-ED8D-4A6D-A41E-092B3DAEB98A}.Release|Any CPU.Build.0 = Release|Any CPU + {F9D4653F-1E61-44E3-953F-0D67D60195C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9D4653F-1E61-44E3-953F-0D67D60195C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9D4653F-1E61-44E3-953F-0D67D60195C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9D4653F-1E61-44E3-953F-0D67D60195C7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -60,5 +66,6 @@ Global {58554E52-FDEC-4832-AFF9-302B01E08DCA} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} {B78A6C74-1A24-48C6-802A-13BE3E4DAFF1} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} {02BC2982-ED8D-4A6D-A41E-092B3DAEB98A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} + {F9D4653F-1E61-44E3-953F-0D67D60195C7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D} EndGlobalSection EndGlobal diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index fd6f3fa28..e7f01577c 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -156,7 +156,7 @@ namespace Bit.Api.Controllers [HttpPut("{id}/cancel")] [HttpPost("{id}/cancel")] - public async Task PutCancel(string id, [FromBody]OrganizationSeatRequestModel model) + public async Task PutCancel(string id) { var orgIdGuid = new Guid(id); if(!_currentContext.OrganizationOwner(orgIdGuid)) @@ -167,6 +167,19 @@ namespace Bit.Api.Controllers await _organizationService.CancelSubscriptionAsync(orgIdGuid, true); } + [HttpPut("{id}/uncancel")] + [HttpPost("{id}/uncancel")] + public async Task PutActivate(string id) + { + var orgIdGuid = new Guid(id); + if(!_currentContext.OrganizationOwner(orgIdGuid)) + { + throw new NotFoundException(); + } + + await _organizationService.UncancelSubscriptionAsync(orgIdGuid); + } + [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(string id) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 09f44db54..2af3ec39a 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -22,7 +22,6 @@ - @@ -34,4 +33,8 @@ + + + + diff --git a/src/Core/Models/Api/Response/OrganizationResponseModel.cs b/src/Core/Models/Api/Response/OrganizationResponseModel.cs index e3efa7ad5..57bd57580 100644 --- a/src/Core/Models/Api/Response/OrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationResponseModel.cs @@ -149,6 +149,7 @@ namespace Bit.Core.Models.Api FailureMessage = charge.FailureMessage; Refunded = charge.Refunded; Status = charge.Status; + InvoiceId = charge.InvoiceId; } public DateTime CreatedDate { get; set; } @@ -159,6 +160,7 @@ namespace Bit.Core.Models.Api public bool Refunded { get; set; } public bool PartiallyRefunded => !Refunded && RefundedAmount > 0; public decimal RefundedAmount { get; set; } + public string InvoiceId { get; set; } } } } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index 557f8f084..dc59e1512 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -12,6 +12,7 @@ namespace Bit.Core.Services Task GetBillingAsync(Organization organization); Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken); Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false); + Task UncancelSubscriptionAsync(Guid organizationId); Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task> SignUpAsync(OrganizationSignup organizationSignup); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index ad62ada2d..7f183dfff 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -72,20 +72,7 @@ namespace Bit.Core.Services CustomerId = customer.Id, Limit = 20 }); - orgBilling.Charges = charges.OrderByDescending(c => c.Created); - - if(!string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) - { - try - { - var upcomingInvoice = await invoiceService.UpcomingAsync(organization.StripeCustomerId); - if(upcomingInvoice != null) - { - orgBilling.UpcomingInvoice = upcomingInvoice; - } - } - catch(StripeException) { } - } + orgBilling.Charges = charges?.Data?.OrderByDescending(c => c.Created); } } @@ -96,6 +83,19 @@ namespace Bit.Core.Services { orgBilling.Subscription = sub; } + + if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(organization.StripeCustomerId)) + { + try + { + var upcomingInvoice = await invoiceService.UpcomingAsync(organization.StripeCustomerId); + if(upcomingInvoice != null) + { + orgBilling.UpcomingInvoice = upcomingInvoice; + } + } + catch(StripeException) { } + } } return orgBilling; @@ -156,25 +156,57 @@ namespace Bit.Core.Services } var subscriptionService = new StripeSubscriptionService(); - var sub = await subscriptionService.GetAsync(organization.StripeCustomerId); - + var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); if(sub == null) { throw new BadRequestException("Organization subscription was not found."); } - if(sub.Status == "canceled") + if(sub.CanceledAt.HasValue) { throw new BadRequestException("Organization subscription is already canceled."); } var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod); - if(canceledSub?.Status != "canceled") + if(!canceledSub.CanceledAt.HasValue) { throw new BadRequestException("Unable to cancel subscription."); } } + public async Task UncancelSubscriptionAsync(Guid organizationId) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if(organization == null) + { + throw new NotFoundException(); + } + + if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) + { + throw new BadRequestException("Organization has no subscription."); + } + + var subscriptionService = new StripeSubscriptionService(); + var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); + if(sub == null) + { + throw new BadRequestException("Organization subscription was not found."); + } + + if(sub.Status != "active" || !sub.CanceledAt.HasValue) + { + throw new BadRequestException("Organization subscription is not marked for cancellation."); + } + + // Just touch the subscription. + var updatedSub = await subscriptionService.UpdateAsync(sub.Id, new StripeSubscriptionUpdateOptions { }); + if(updatedSub.CanceledAt.HasValue) + { + throw new BadRequestException("Unable to activate subscription."); + } + } + public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) { var organization = await _organizationRepository.GetByIdAsync(organizationId); @@ -353,6 +385,8 @@ namespace Bit.Core.Services } var subscriptionService = new StripeSubscriptionService(); + var invoiceService = new StripeInvoiceService(); + var invoiceItemService = new StripeInvoiceItemService(); var subscriptionItemService = new StripeSubscriptionItemService(); var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId); if(sub == null) @@ -363,11 +397,48 @@ namespace Bit.Core.Services var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == plan.StripeSeatPlanId); if(seatItem == null) { + var upcomingPreview = await invoiceService.UpcomingAsync(organization.StripeCustomerId, + new StripeUpcomingInvoiceOptions + { + SubscriptionId = organization.StripeSubscriptionId, + SubscriptionItems = new List + { + new StripeUpcomingInvoiceSubscriptionItemOptions + { + PlanId = plan.StripeSeatPlanId, + Quantity = additionalSeats + } + } + }); + + var prorateSub = true; + var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data?.Last()?.Amount; + if(prorationAmount.GetValueOrDefault() > 0) + { + var invoiceItem = await invoiceItemService.CreateAsync(new StripeInvoiceItemCreateOptions + { + SubscriptionId = organization.StripeSubscriptionId, + CustomerId = organization.StripeCustomerId, + Amount = prorationAmount.Value, + Description = $"Prorated amount for ${additionalSeats} additional seats.", + Currency = "USD" + }); + + var invoice = await invoiceService.CreateAsync(organization.StripeCustomerId, + new StripeInvoiceCreateOptions + { + SubscriptionId = organization.StripeSubscriptionId + }); + + var paidInvoice = await invoiceService.PayAsync(invoice.Id); + prorateSub = !paidInvoice.Paid; + } + var subItemCreateOptions = new StripeSubscriptionItemCreateOptions { PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, - Prorate = true, + Prorate = prorateSub, SubscriptionId = sub.Id }; @@ -375,11 +446,48 @@ namespace Bit.Core.Services } else if(additionalSeats > 0) { + var upcomingPreview = await invoiceService.UpcomingAsync(organization.StripeCustomerId, + new StripeUpcomingInvoiceOptions + { + SubscriptionId = organization.StripeSubscriptionId, + SubscriptionItems = new List + { + new StripeUpcomingInvoiceSubscriptionItemOptions + { + Id = seatItem.Id, + Quantity = additionalSeats + } + } + }); + + var prorateSub = true; + var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data?.Take(2).Sum(i => i.Amount); + if(prorationAmount.GetValueOrDefault() > 0) + { + var invoiceItem = await invoiceItemService.CreateAsync(new StripeInvoiceItemCreateOptions + { + SubscriptionId = organization.StripeSubscriptionId, + CustomerId = organization.StripeCustomerId, + Amount = prorationAmount.Value, + Description = $"Prorated amount for ${additionalSeats} additional seats.", + Currency = "USD" + }); + + var invoice = await invoiceService.CreateAsync(organization.StripeCustomerId, + new StripeInvoiceCreateOptions + { + SubscriptionId = organization.StripeSubscriptionId + }); + + var paidInvoice = await invoiceService.PayAsync(invoice.Id); + prorateSub = !paidInvoice.Paid; + } + var subItemUpdateOptions = new StripeSubscriptionItemUpdateOptions { PlanId = plan.StripeSeatPlanId, Quantity = additionalSeats, - Prorate = true + Prorate = prorateSub }; await subscriptionItemService.UpdateAsync(seatItem.Id, subItemUpdateOptions);