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

APIs for premium. Billing helpers.

This commit is contained in:
Kyle Spearrin 2017-07-06 14:55:58 -04:00
parent 2afef85f85
commit d346ee5169
22 changed files with 789 additions and 313 deletions

View File

@ -10,6 +10,7 @@ using Bit.Core.Models.Table;
using Bit.Core.Enums; using Bit.Core.Enums;
using System.Linq; using System.Linq;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
namespace Bit.Api.Controllers namespace Bit.Api.Controllers
{ {
@ -339,5 +340,88 @@ namespace Bit.Api.Controllers
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[HttpPost("premium")]
public async Task<ProfileResponseModel> PostPremium([FromBody]PremiumRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.SignUpPremiumAsync(user, model.PaymentToken, model.AdditionalStorageGb.GetValueOrDefault(0));
return new ProfileResponseModel(user, null);
}
[HttpGet("billing")]
public async Task<BillingResponseModel> GetBilling()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
var billingInfo = await BillingHelpers.GetBillingAsync(user);
if(billingInfo == null)
{
throw new NotFoundException();
}
return new BillingResponseModel(billingInfo);
}
[HttpPut("payment")]
[HttpPost("payment")]
public async Task PutPayment([FromBody]PaymentRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.ReplacePaymentMethodAsync(user, model.PaymentToken);
}
[HttpPut("storage")]
[HttpPost("storage")]
public async Task PutStorage([FromBody]StorageRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.AdjustStorageAsync(user, model.StroageGbAdjustment.Value);
}
[HttpPut("cancel-premium")]
[HttpPost("cancel-premium")]
public async Task PutCancel()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.CancelPremiumAsync(user, true);
}
[HttpPut("reinstate-premium")]
[HttpPost("reinstate-premium")]
public async Task PutReinstate()
{
var user = await _userService.GetUserByPrincipalAsync(User);
if(user == null)
{
throw new UnauthorizedAccessException();
}
await _userService.ReinstatePremiumAsync(user);
}
} }
} }

View File

@ -10,6 +10,7 @@ using Bit.Core.Services;
using Bit.Core; using Bit.Core;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Bit.Core.Models.Table; using Bit.Core.Models.Table;
using Bit.Core.Utilities;
namespace Bit.Api.Controllers namespace Bit.Api.Controllers
{ {
@ -73,7 +74,7 @@ namespace Bit.Api.Controllers
throw new NotFoundException(); throw new NotFoundException();
} }
var billingInfo = await _organizationService.GetBillingAsync(organization); var billingInfo = await BillingHelpers.GetBillingAsync(organization);
if(billingInfo == null) if(billingInfo == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
@ -130,7 +131,7 @@ namespace Bit.Api.Controllers
[HttpPut("{id}/payment")] [HttpPut("{id}/payment")]
[HttpPost("{id}/payment")] [HttpPost("{id}/payment")]
public async Task PutPayment(string id, [FromBody]OrganizationPaymentRequestModel model) public async Task PutPayment(string id, [FromBody]PaymentRequestModel model)
{ {
var orgIdGuid = new Guid(id); var orgIdGuid = new Guid(id);
if(!_currentContext.OrganizationOwner(orgIdGuid)) if(!_currentContext.OrganizationOwner(orgIdGuid))

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Models.Api
{
public class PremiumRequestModel : PaymentRequestModel
{
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
}
}

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Models.Api
{
public class StorageRequestModel : IValidatableObject
{
[Required]
public short? StroageGbAdjustment { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if(StroageGbAdjustment == 0)
{
yield return new ValidationResult("Storage adjustment cannot be 0.",
new string[] { nameof(StroageGbAdjustment) });
}
}
}
}

View File

@ -2,7 +2,7 @@
namespace Bit.Core.Models.Api namespace Bit.Core.Models.Api
{ {
public class OrganizationPaymentRequestModel public class PaymentRequestModel
{ {
[Required] [Required]
public string PaymentToken { get; set; } public string PaymentToken { get; set; }

View File

@ -0,0 +1,137 @@
using System;
using System.Linq;
using System.Collections.Generic;
using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Models.Api
{
public class BillingResponseModel : ResponseModel
{
public BillingResponseModel(BillingInfo billing)
: base("billing")
{
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
Subscription = billing.Subscription != null ? new BillingSubscription(billing.Subscription) : null;
Charges = billing.Charges.Select(c => new BillingCharge(c));
UpcomingInvoice = billing.UpcomingInvoice != null ? new BillingInvoice(billing.UpcomingInvoice) : null;
}
public BillingSource PaymentSource { get; set; }
public BillingSubscription Subscription { get; set; }
public BillingInvoice UpcomingInvoice { get; set; }
public IEnumerable<BillingCharge> Charges { get; set; }
}
public class BillingSource
{
public BillingSource(Source source)
{
Type = source.Type;
switch(source.Type)
{
case SourceType.Card:
Description = $"{source.Card.Brand}, *{source.Card.Last4}, " +
string.Format("{0}/{1}",
string.Concat(source.Card.ExpirationMonth.Length == 1 ?
"0" : string.Empty, source.Card.ExpirationMonth),
source.Card.ExpirationYear);
CardBrand = source.Card.Brand;
break;
case SourceType.BankAccount:
Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4}";
break;
// bitcoin/alipay?
default:
break;
}
}
public SourceType Type { get; set; }
public string CardBrand { get; set; }
public string Description { get; set; }
}
public class BillingSubscription
{
public BillingSubscription(StripeSubscription sub)
{
Status = sub.Status;
TrialStartDate = sub.TrialStart;
TrialEndDate = sub.TrialEnd;
EndDate = sub.CurrentPeriodEnd;
CancelledDate = sub.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd;
if(sub.Items?.Data != null)
{
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
}
}
public DateTime? TrialStartDate { get; set; }
public DateTime? TrialEndDate { get; set; }
public DateTime? EndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public class BillingSubscriptionItem
{
public BillingSubscriptionItem(StripeSubscriptionItem item)
{
if(item.Plan != null)
{
Name = item.Plan.Name;
Amount = item.Plan.Amount / 100M;
Interval = item.Plan.Interval;
}
Quantity = item.Quantity;
}
public string Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
}
}
public class BillingInvoice
{
public BillingInvoice(StripeInvoice inv)
{
Amount = inv.AmountDue / 100M;
Date = inv.Date.Value;
}
public decimal Amount { get; set; }
public DateTime? Date { get; set; }
}
public class BillingCharge
{
public BillingCharge(StripeCharge charge)
{
Amount = charge.Amount / 100M;
RefundedAmount = charge.AmountRefunded / 100M;
PaymentSource = charge.Source != null ? new BillingSource(charge.Source) : null;
CreatedDate = charge.Created;
FailureMessage = charge.FailureMessage;
Refunded = charge.Refunded;
Status = charge.Status;
InvoiceId = charge.InvoiceId;
}
public DateTime CreatedDate { get; set; }
public decimal Amount { get; set; }
public BillingSource PaymentSource { get; set; }
public string Status { get; set; }
public string FailureMessage { get; set; }
public bool Refunded { get; set; }
public bool PartiallyRefunded => !Refunded && RefundedAmount > 0;
public decimal RefundedAmount { get; set; }
public string InvoiceId { get; set; }
}
}

View File

@ -3,7 +3,6 @@ using System.Linq;
using Bit.Core.Models.Table; using Bit.Core.Models.Table;
using System.Collections.Generic; using System.Collections.Generic;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Models.Api namespace Bit.Core.Models.Api
{ {
@ -43,7 +42,7 @@ namespace Bit.Core.Models.Api
public class OrganizationBillingResponseModel : OrganizationResponseModel public class OrganizationBillingResponseModel : OrganizationResponseModel
{ {
public OrganizationBillingResponseModel(Organization organization, OrganizationBilling billing) public OrganizationBillingResponseModel(Organization organization, BillingInfo billing)
: base(organization, "organizationBilling") : base(organization, "organizationBilling")
{ {
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
@ -56,117 +55,5 @@ namespace Bit.Core.Models.Api
public BillingSubscription Subscription { get; set; } public BillingSubscription Subscription { get; set; }
public BillingInvoice UpcomingInvoice { get; set; } public BillingInvoice UpcomingInvoice { get; set; }
public IEnumerable<BillingCharge> Charges { get; set; } public IEnumerable<BillingCharge> Charges { get; set; }
public class BillingSource
{
public BillingSource(Source source)
{
Type = source.Type;
switch(source.Type)
{
case SourceType.Card:
Description = $"{source.Card.Brand}, *{source.Card.Last4}, " +
string.Format("{0}/{1}",
string.Concat(source.Card.ExpirationMonth.Length == 1 ?
"0" : string.Empty, source.Card.ExpirationMonth),
source.Card.ExpirationYear);
CardBrand = source.Card.Brand;
break;
case SourceType.BankAccount:
Description = $"{source.BankAccount.BankName}, *{source.BankAccount.Last4}";
break;
// bitcoin/alipay?
default:
break;
}
}
public SourceType Type { get; set; }
public string CardBrand { get; set; }
public string Description { get; set; }
}
public class BillingSubscription
{
public BillingSubscription(StripeSubscription sub)
{
Status = sub.Status;
TrialStartDate = sub.TrialStart;
TrialEndDate = sub.TrialEnd;
EndDate = sub.CurrentPeriodEnd;
CancelledDate = sub.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd;
if(sub.Items?.Data != null)
{
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
}
}
public DateTime? TrialStartDate { get; set; }
public DateTime? TrialEndDate { get; set; }
public DateTime? EndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public class BillingSubscriptionItem
{
public BillingSubscriptionItem(StripeSubscriptionItem item)
{
if(item.Plan != null)
{
Name = item.Plan.Name;
Amount = item.Plan.Amount / 100M;
Interval = item.Plan.Interval;
}
Quantity = item.Quantity;
}
public string Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
}
}
public class BillingInvoice
{
public BillingInvoice(StripeInvoice inv)
{
Amount = inv.AmountDue / 100M;
Date = inv.Date.Value;
}
public decimal Amount { get; set; }
public DateTime? Date { get; set; }
}
public class BillingCharge
{
public BillingCharge(StripeCharge charge)
{
Amount = charge.Amount / 100M;
RefundedAmount = charge.AmountRefunded / 100M;
PaymentSource = charge.Source != null ? new BillingSource(charge.Source) : null;
CreatedDate = charge.Created;
FailureMessage = charge.FailureMessage;
Refunded = charge.Refunded;
Status = charge.Status;
InvoiceId = charge.InvoiceId;
}
public DateTime CreatedDate { get; set; }
public decimal Amount { get; set; }
public BillingSource PaymentSource { get; set; }
public string Status { get; set; }
public string FailureMessage { get; set; }
public bool Refunded { get; set; }
public bool PartiallyRefunded => !Refunded && RefundedAmount > 0;
public decimal RefundedAmount { get; set; }
public string InvoiceId { get; set; }
}
} }
} }

View File

@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace Bit.Core.Models.Business namespace Bit.Core.Models.Business
{ {
public class OrganizationBilling public class BillingInfo
{ {
public Source PaymentSource { get; set; } public Source PaymentSource { get; set; }
public StripeSubscription Subscription { get; set; } public StripeSubscription Subscription { get; set; }

View File

@ -0,0 +1,10 @@
using System;
namespace Bit.Core.Models.Table
{
public interface IRevisable
{
DateTime CreationDate { get; }
DateTime RevisionDate { get; }
}
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Models.Table
{
public interface IStorable
{
long? Storage { get; set; }
short? MaxStorageGb { get; set; }
long StorageBytesRemaining();
long StorageBytesRemaining(short maxStorageGb);
}
}

View File

@ -0,0 +1,5 @@
namespace Bit.Core.Models.Table
{
public interface IStorableSubscriber : IStorable, ISubscriber
{ }
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Models.Table
{
public interface ISubscriber
{
string StripeCustomerId { get; set; }
string StripeSubscriptionId { get; set; }
string BillingEmailAddress();
string BillingName();
}
}

View File

@ -4,7 +4,7 @@ using Bit.Core.Enums;
namespace Bit.Core.Models.Table namespace Bit.Core.Models.Table
{ {
public class Organization : IDataObject<Guid> public class Organization : IDataObject<Guid>, ISubscriber, IStorable, IStorableSubscriber, IRevisable
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
@ -32,6 +32,16 @@ namespace Bit.Core.Models.Table
} }
} }
public string BillingEmailAddress()
{
return BillingEmail;
}
public string BillingName()
{
return BusinessName;
}
public long StorageBytesRemaining() public long StorageBytesRemaining()
{ {
if(!MaxStorageGb.HasValue) if(!MaxStorageGb.HasValue)
@ -39,7 +49,12 @@ namespace Bit.Core.Models.Table
return 0; return 0;
} }
var maxStorageBytes = MaxStorageGb.Value * 1073741824L; return StorageBytesRemaining(MaxStorageGb.Value);
}
public long StorageBytesRemaining(short maxStorageGb)
{
var maxStorageBytes = maxStorageGb * 1073741824L;
if(!Storage.HasValue) if(!Storage.HasValue)
{ {
return maxStorageBytes; return maxStorageBytes;

View File

@ -7,7 +7,7 @@ using System.Linq;
namespace Bit.Core.Models.Table namespace Bit.Core.Models.Table
{ {
public class User : IDataObject<Guid> public class User : IDataObject<Guid>, ISubscriber, IStorable, IStorableSubscriber, IRevisable
{ {
private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders; private Dictionary<TwoFactorProviderType, TwoFactorProvider> _twoFactorProviders;
@ -30,6 +30,8 @@ namespace Bit.Core.Models.Table
public bool Premium { get; set; } public bool Premium { get; set; }
public long? Storage { get; set; } public long? Storage { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public string StripeCustomerId { get; set; }
public string StripeSubscriptionId { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
@ -38,6 +40,16 @@ namespace Bit.Core.Models.Table
Id = CoreHelpers.GenerateComb(); Id = CoreHelpers.GenerateComb();
} }
public string BillingEmailAddress()
{
return Email;
}
public string BillingName()
{
return Name;
}
public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders() public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders()
{ {
if(string.IsNullOrWhiteSpace(TwoFactorProviders)) if(string.IsNullOrWhiteSpace(TwoFactorProviders))
@ -110,7 +122,12 @@ namespace Bit.Core.Models.Table
return 0; return 0;
} }
var maxStorageBytes = MaxStorageGb.Value * 1073741824L; return StorageBytesRemaining(MaxStorageGb.Value);
}
public long StorageBytesRemaining(short maxStorageGb)
{
var maxStorageBytes = maxStorageGb * 1073741824L;
if(!Storage.HasValue) if(!Storage.HasValue)
{ {
return maxStorageBytes; return maxStorageBytes;

View File

@ -10,7 +10,6 @@ namespace Bit.Core.Services
{ {
public interface IOrganizationService public interface IOrganizationService
{ {
Task<OrganizationBilling> GetBillingAsync(Organization organization);
Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken); Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken);
Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false); Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false);
Task ReinstateSubscriptionAsync(Guid organizationId); Task ReinstateSubscriptionAsync(Guid organizationId);

View File

@ -38,5 +38,10 @@ namespace Bit.Core.Services
Task<bool> RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); Task<bool> RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode);
Task<string> GenerateUserTokenAsync(User user, string tokenProvider, string purpose); Task<string> GenerateUserTokenAsync(User user, string tokenProvider, string purpose);
Task<IdentityResult> DeleteAsync(User user); Task<IdentityResult> DeleteAsync(User user);
Task SignUpPremiumAsync(User user, string paymentToken, short additionalStorageGb);
Task AdjustStorageAsync(User user, short storageAdjustmentGb);
Task ReplacePaymentMethodAsync(User user, string paymentToken);
Task CancelPremiumAsync(User user, bool endOfPeriod = false);
Task ReinstatePremiumAsync(User user);
} }
} }

View File

@ -10,7 +10,6 @@ using System.Collections.Generic;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Stripe; using Stripe;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.StaticStore;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Core.Services namespace Bit.Core.Services
@ -48,66 +47,6 @@ namespace Bit.Core.Services
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_pushRegistrationService = pushRegistrationService; _pushRegistrationService = pushRegistrationService;
} }
public async Task<OrganizationBilling> GetBillingAsync(Organization organization)
{
var orgBilling = new OrganizationBilling();
var customerService = new StripeCustomerService();
var subscriptionService = new StripeSubscriptionService();
var chargeService = new StripeChargeService();
var invoiceService = new StripeInvoiceService();
if(!string.IsNullOrWhiteSpace(organization.StripeCustomerId))
{
var customer = await customerService.GetAsync(organization.StripeCustomerId);
if(customer != null)
{
if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null)
{
if(customer.DefaultSourceId.StartsWith("card_"))
{
orgBilling.PaymentSource =
customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId);
}
else if(customer.DefaultSourceId.StartsWith("ba_"))
{
orgBilling.PaymentSource =
customer.Sources.Data.FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId);
}
}
var charges = await chargeService.ListAsync(new StripeChargeListOptions
{
CustomerId = customer.Id,
Limit = 20
});
orgBilling.Charges = charges?.Data?.OrderByDescending(c => c.Created);
}
}
if(!string.IsNullOrWhiteSpace(organization.StripeSubscriptionId))
{
var sub = await subscriptionService.GetAsync(organization.StripeSubscriptionId);
if(sub != null)
{
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;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken) public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken)
{ {
@ -117,37 +56,11 @@ namespace Bit.Core.Services
throw new NotFoundException(); throw new NotFoundException();
} }
var cardService = new StripeCardService(); var updated = await BillingHelpers.UpdatePaymentMethodAsync(organization, paymentToken);
var customerService = new StripeCustomerService(); if(updated)
StripeCustomer customer = null;
if(!string.IsNullOrWhiteSpace(organization.StripeCustomerId))
{ {
customer = await customerService.GetAsync(organization.StripeCustomerId);
}
if(customer == null)
{
customer = await customerService.CreateAsync(new StripeCustomerCreateOptions
{
Description = organization.BusinessName,
Email = organization.BillingEmail,
SourceToken = paymentToken
});
organization.StripeCustomerId = customer.Id;
await _organizationRepository.ReplaceAsync(organization); await _organizationRepository.ReplaceAsync(organization);
} }
await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions
{
SourceToken = paymentToken
});
if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId))
{
await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId);
}
} }
public async Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false) public async Task CancelSubscriptionAsync(Guid organizationId, bool endOfPeriod = false)
@ -158,28 +71,7 @@ namespace Bit.Core.Services
throw new NotFoundException(); throw new NotFoundException();
} }
if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) await BillingHelpers.CancelSubscriptionAsync(organization, endOfPeriod);
{
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.CanceledAt.HasValue)
{
throw new BadRequestException("Organization subscription is already canceled.");
}
var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod);
if(!canceledSub.CanceledAt.HasValue)
{
throw new BadRequestException("Unable to cancel subscription.");
}
} }
public async Task ReinstateSubscriptionAsync(Guid organizationId) public async Task ReinstateSubscriptionAsync(Guid organizationId)
@ -190,29 +82,7 @@ namespace Bit.Core.Services
throw new NotFoundException(); throw new NotFoundException();
} }
if(string.IsNullOrWhiteSpace(organization.StripeSubscriptionId)) await BillingHelpers.ReinstateSubscriptionAsync(organization);
{
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 reinstate subscription.");
}
} }
public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats) public async Task UpgradePlanAsync(Guid organizationId, PlanType plan, int additionalSeats)
@ -427,8 +297,6 @@ namespace Bit.Core.Services
Prorate = true, Prorate = true,
SubscriptionId = sub.Id SubscriptionId = sub.Id
}); });
await PreviewUpcomingAndPayAsync(organization, plan);
} }
else if(additionalSeats > 0) else if(additionalSeats > 0)
{ {
@ -438,49 +306,21 @@ namespace Bit.Core.Services
Quantity = additionalSeats, Quantity = additionalSeats,
Prorate = true Prorate = true
}); });
await PreviewUpcomingAndPayAsync(organization, plan);
} }
else if(additionalSeats == 0) else if(additionalSeats == 0)
{ {
await subscriptionItemService.DeleteAsync(seatItem.Id); await subscriptionItemService.DeleteAsync(seatItem.Id);
} }
if(additionalSeats > 0)
{
await BillingHelpers.PreviewUpcomingInvoiceAndPayAsync(organization, plan.StripeSeatPlanId, 500);
}
organization.Seats = (short?)newSeatTotal; organization.Seats = (short?)newSeatTotal;
await _organizationRepository.ReplaceAsync(organization); await _organizationRepository.ReplaceAsync(organization);
} }
private async Task PreviewUpcomingAndPayAsync(Organization org, Plan plan)
{
var invoiceService = new StripeInvoiceService();
var upcomingPreview = await invoiceService.UpcomingAsync(org.StripeCustomerId,
new StripeUpcomingInvoiceOptions
{
SubscriptionId = org.StripeSubscriptionId
});
var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data?
.TakeWhile(i => i.Plan.Id == plan.StripeSeatPlanId && i.Proration).Sum(i => i.Amount);
if(prorationAmount.GetValueOrDefault() >= 500)
{
try
{
// Owes more than $5.00 on next invoice. Invoice them and pay now instead of waiting until next month.
var invoice = await invoiceService.CreateAsync(org.StripeCustomerId,
new StripeInvoiceCreateOptions
{
SubscriptionId = org.StripeSubscriptionId
});
if(invoice.AmountDue > 0)
{
await invoiceService.PayAsync(invoice.Id);
}
}
catch(StripeException) { }
}
}
public async Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup signup) public async Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup signup)
{ {
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan && !p.Disabled); var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan && !p.Disabled);
@ -620,27 +460,7 @@ namespace Bit.Core.Services
} }
catch catch
{ {
if(subscription != null) await BillingHelpers.CancelAndRecoverChargesAsync(subscription?.Id, customer?.Id);
{
await subscriptionService.CancelAsync(subscription.Id, false);
}
if(customer != null)
{
var chargeService = new StripeChargeService();
var charges = await chargeService.ListAsync(new StripeChargeListOptions { CustomerId = customer.Id });
if(charges?.Data != null)
{
var refundService = new StripeRefundService();
foreach(var charge in charges.Data.Where(c => !c.Refunded))
{
await refundService.CreateAsync(charge.Id);
}
}
await customerService.DeleteAsync(customer.Id);
}
if(organization.Id != default(Guid)) if(organization.Id != default(Guid))
{ {
await _organizationRepository.DeleteAsync(organization); await _organizationRepository.DeleteAsync(organization);

View File

@ -16,11 +16,16 @@ using U2fLib = U2F.Core.Crypto.U2F;
using U2F.Core.Models; using U2F.Core.Models;
using U2F.Core.Utils; using U2F.Core.Utils;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Stripe;
using Bit.Core.Utilities;
namespace Bit.Core.Services namespace Bit.Core.Services
{ {
public class UserService : UserManager<User>, IUserService, IDisposable public class UserService : UserManager<User>, IUserService, IDisposable
{ {
private const string PremiumPlanId = "premium-annually";
private const string StoragePlanId = "storage-gb-annually";
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ICipherRepository _cipherRepository; private readonly ICipherRepository _cipherRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
@ -492,6 +497,109 @@ namespace Bit.Core.Services
return true; return true;
} }
public async Task SignUpPremiumAsync(User user, string paymentToken, short additionalStorageGb)
{
if(user.Premium)
{
throw new BadRequestException("Already a premium user.");
}
var customerService = new StripeCustomerService();
var customer = await customerService.CreateAsync(new StripeCustomerCreateOptions
{
Description = user.Name,
Email = user.Email,
SourceToken = paymentToken
});
var subCreateOptions = new StripeSubscriptionCreateOptions
{
Items = new List<StripeSubscriptionItemOption>(),
Metadata = new Dictionary<string, string>
{
["userId"] = user.Id.ToString()
}
};
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
{
PlanId = PremiumPlanId,
Quantity = 1
});
if(additionalStorageGb > 0)
{
subCreateOptions.Items.Add(new StripeSubscriptionItemOption
{
PlanId = StoragePlanId,
Quantity = additionalStorageGb
});
}
StripeSubscription subscription = null;
try
{
var subscriptionService = new StripeSubscriptionService();
subscription = await subscriptionService.CreateAsync(customer.Id, subCreateOptions);
}
catch(StripeException)
{
await customerService.DeleteAsync(customer.Id);
throw;
}
user.Premium = true;
user.MaxStorageGb = (short)(1 + additionalStorageGb);
user.RevisionDate = DateTime.UtcNow;
user.StripeCustomerId = customer.Id;
user.StripeSubscriptionId = subscription.Id;
try
{
await SaveUserAsync(user);
}
catch
{
await BillingHelpers.CancelAndRecoverChargesAsync(subscription.Id, customer.Id);
throw;
}
}
public async Task AdjustStorageAsync(User user, short storageAdjustmentGb)
{
if(user == null)
{
throw new ArgumentNullException(nameof(user));
}
if(!user.Premium)
{
throw new BadRequestException("Not a premium user.");
}
await BillingHelpers.AdjustStorageAsync(user, storageAdjustmentGb, StoragePlanId);
await SaveUserAsync(user);
}
public async Task ReplacePaymentMethodAsync(User user, string paymentToken)
{
var updated = await BillingHelpers.UpdatePaymentMethodAsync(user, paymentToken);
if(updated)
{
await SaveUserAsync(user);
}
}
public async Task CancelPremiumAsync(User user, bool endOfPeriod = false)
{
await BillingHelpers.CancelSubscriptionAsync(user, endOfPeriod);
}
public async Task ReinstatePremiumAsync(User user)
{
await BillingHelpers.ReinstateSubscriptionAsync(user);
}
private async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword, bool validatePassword = true) private async Task<IdentityResult> UpdatePasswordHash(User user, string newPassword, bool validatePassword = true)
{ {
if(validatePassword) if(validatePassword)

View File

@ -0,0 +1,326 @@
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Table;
using Stripe;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Bit.Core.Utilities
{
public static class BillingHelpers
{
internal static async Task CancelAndRecoverChargesAsync(string subscriptionId, string customerId)
{
if(!string.IsNullOrWhiteSpace(subscriptionId))
{
var subscriptionService = new StripeSubscriptionService();
await subscriptionService.CancelAsync(subscriptionId, false);
}
if(string.IsNullOrWhiteSpace(customerId))
{
return;
}
var chargeService = new StripeChargeService();
var charges = await chargeService.ListAsync(new StripeChargeListOptions { CustomerId = customerId });
if(charges?.Data != null)
{
var refundService = new StripeRefundService();
foreach(var charge in charges.Data.Where(c => !c.Refunded))
{
await refundService.CreateAsync(charge.Id);
}
}
var customerService = new StripeCustomerService();
await customerService.DeleteAsync(customerId);
}
public static async Task<BillingInfo> GetBillingAsync(ISubscriber subscriber)
{
var orgBilling = new BillingInfo();
var customerService = new StripeCustomerService();
var subscriptionService = new StripeSubscriptionService();
var chargeService = new StripeChargeService();
var invoiceService = new StripeInvoiceService();
if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId))
{
var customer = await customerService.GetAsync(subscriber.StripeCustomerId);
if(customer != null)
{
if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId) && customer.Sources?.Data != null)
{
if(customer.DefaultSourceId.StartsWith("card_"))
{
orgBilling.PaymentSource =
customer.Sources.Data.FirstOrDefault(s => s.Card?.Id == customer.DefaultSourceId);
}
else if(customer.DefaultSourceId.StartsWith("ba_"))
{
orgBilling.PaymentSource =
customer.Sources.Data.FirstOrDefault(s => s.BankAccount?.Id == customer.DefaultSourceId);
}
}
var charges = await chargeService.ListAsync(new StripeChargeListOptions
{
CustomerId = customer.Id,
Limit = 20
});
orgBilling.Charges = charges?.Data?.OrderByDescending(c => c.Created);
}
}
if(!string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId))
{
var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId);
if(sub != null)
{
orgBilling.Subscription = sub;
}
if(!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.StripeCustomerId))
{
try
{
var upcomingInvoice = await invoiceService.UpcomingAsync(subscriber.StripeCustomerId);
if(upcomingInvoice != null)
{
orgBilling.UpcomingInvoice = upcomingInvoice;
}
}
catch(StripeException) { }
}
}
return orgBilling;
}
internal static async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, string planId,
int prorateThreshold = 500)
{
var invoiceService = new StripeInvoiceService();
var upcomingPreview = await invoiceService.UpcomingAsync(subscriber.StripeCustomerId,
new StripeUpcomingInvoiceOptions
{
SubscriptionId = subscriber.StripeSubscriptionId
});
var prorationAmount = upcomingPreview.StripeInvoiceLineItems?.Data?
.TakeWhile(i => i.Plan.Id == planId && i.Proration).Sum(i => i.Amount);
if(prorationAmount.GetValueOrDefault() >= prorateThreshold)
{
try
{
// Owes more than prorateThreshold on next invoice.
// Invoice them and pay now instead of waiting until next month.
var invoice = await invoiceService.CreateAsync(subscriber.StripeCustomerId,
new StripeInvoiceCreateOptions
{
SubscriptionId = subscriber.StripeSubscriptionId
});
if(invoice.AmountDue > 0)
{
await invoiceService.PayAsync(invoice.Id);
}
}
catch(StripeException) { }
}
}
internal static async Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, short storageAdjustmentGb,
string storagePlanId)
{
if(storableSubscriber == null)
{
throw new ArgumentNullException(nameof(storableSubscriber));
}
if(string.IsNullOrWhiteSpace(storableSubscriber.StripeCustomerId))
{
throw new BadRequestException("No payment method found.");
}
if(string.IsNullOrWhiteSpace(storableSubscriber.StripeSubscriptionId))
{
throw new BadRequestException("No subscription found.");
}
if(!storableSubscriber.MaxStorageGb.HasValue)
{
throw new BadRequestException("No access to storage.");
}
var newStorageGb = (short)(storableSubscriber.MaxStorageGb.Value + storageAdjustmentGb);
if(newStorageGb < 1)
{
newStorageGb = 1;
}
if(newStorageGb > 100)
{
throw new BadRequestException("Maximum storage is 100 GB.");
}
var remainingStorage = storableSubscriber.StorageBytesRemaining(newStorageGb);
if(remainingStorage < 0)
{
throw new BadRequestException("You are currently using " +
$"{CoreHelpers.ReadableBytesSize(storableSubscriber.Storage.GetValueOrDefault(0))} of storage. " +
"Delete some stored data first.");
}
var additionalStorage = newStorageGb - 1;
var subscriptionItemService = new StripeSubscriptionItemService();
var subscriptionService = new StripeSubscriptionService();
var sub = await subscriptionService.GetAsync(storableSubscriber.StripeSubscriptionId);
if(sub == null)
{
throw new BadRequestException("Subscription not found.");
}
var seatItem = sub.Items?.Data?.FirstOrDefault(i => i.Plan.Id == storagePlanId);
if(seatItem == null)
{
await subscriptionItemService.CreateAsync(new StripeSubscriptionItemCreateOptions
{
PlanId = storagePlanId,
Quantity = additionalStorage,
Prorate = true,
SubscriptionId = sub.Id
});
}
else if(additionalStorage > 0)
{
await subscriptionItemService.UpdateAsync(seatItem.Id, new StripeSubscriptionItemUpdateOptions
{
PlanId = storagePlanId,
Quantity = additionalStorage,
Prorate = true
});
}
else if(additionalStorage == 0)
{
await subscriptionItemService.DeleteAsync(storagePlanId);
}
if(additionalStorage > 0)
{
await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, storagePlanId, 300);
}
storableSubscriber.MaxStorageGb = newStorageGb;
}
public static async Task<bool> UpdatePaymentMethodAsync(ISubscriber subscriber, string paymentToken)
{
if(subscriber == null)
{
throw new ArgumentNullException(nameof(subscriber));
}
var updatedSubscriber = false;
var cardService = new StripeCardService();
var customerService = new StripeCustomerService();
StripeCustomer customer = null;
if(!string.IsNullOrWhiteSpace(subscriber.StripeCustomerId))
{
customer = await customerService.GetAsync(subscriber.StripeCustomerId);
}
if(customer == null)
{
customer = await customerService.CreateAsync(new StripeCustomerCreateOptions
{
Description = subscriber.BillingName(),
Email = subscriber.BillingEmailAddress(),
SourceToken = paymentToken
});
subscriber.StripeCustomerId = customer.Id;
updatedSubscriber = true;
}
await cardService.CreateAsync(customer.Id, new StripeCardCreateOptions
{
SourceToken = paymentToken
});
if(!string.IsNullOrWhiteSpace(customer.DefaultSourceId))
{
await cardService.DeleteAsync(customer.Id, customer.DefaultSourceId);
}
return updatedSubscriber;
}
public static async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false)
{
if(subscriber == null)
{
throw new ArgumentNullException(nameof(subscriber));
}
if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId))
{
throw new BadRequestException("No subscription.");
}
var subscriptionService = new StripeSubscriptionService();
var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId);
if(sub == null)
{
throw new BadRequestException("Subscription was not found.");
}
if(sub.CanceledAt.HasValue)
{
throw new BadRequestException("Subscription is already canceled.");
}
var canceledSub = await subscriptionService.CancelAsync(sub.Id, endOfPeriod);
if(!canceledSub.CanceledAt.HasValue)
{
throw new BadRequestException("Unable to cancel subscription.");
}
}
public static async Task ReinstateSubscriptionAsync(ISubscriber subscriber)
{
if(subscriber == null)
{
throw new ArgumentNullException(nameof(subscriber));
}
if(string.IsNullOrWhiteSpace(subscriber.StripeSubscriptionId))
{
throw new BadRequestException("No subscription.");
}
var subscriptionService = new StripeSubscriptionService();
var sub = await subscriptionService.GetAsync(subscriber.StripeSubscriptionId);
if(sub == null)
{
throw new BadRequestException("Subscription was not found.");
}
if(sub.Status != "active" || !sub.CanceledAt.HasValue)
{
throw new BadRequestException("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 reinstate subscription.");
}
}
}
}

View File

@ -18,6 +18,8 @@
@Premium BIT, @Premium BIT,
@Storage BIGINT, @Storage BIGINT,
@MaxStorageGb SMALLINT, @MaxStorageGb SMALLINT,
@StripeCustomerId VARCHAR(50),
@StripeSubscriptionId VARCHAR(50),
@CreationDate DATETIME2(7), @CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7)
AS AS
@ -45,6 +47,8 @@ BEGIN
[Premium], [Premium],
[Storage], [Storage],
[MaxStorageGb], [MaxStorageGb],
[StripeCustomerId],
[StripeSubscriptionId],
[CreationDate], [CreationDate],
[RevisionDate] [RevisionDate]
) )
@ -69,6 +73,8 @@ BEGIN
@Premium, @Premium,
@Storage, @Storage,
@MaxStorageGb, @MaxStorageGb,
@StripeCustomerId,
@StripeSubscriptionId,
@CreationDate, @CreationDate,
@RevisionDate @RevisionDate
) )

View File

@ -18,6 +18,8 @@
@Premium BIT, @Premium BIT,
@Storage BIGINT, @Storage BIGINT,
@MaxStorageGb SMALLINT, @MaxStorageGb SMALLINT,
@StripeCustomerId VARCHAR(50),
@StripeSubscriptionId VARCHAR(50),
@CreationDate DATETIME2(7), @CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7)
AS AS
@ -45,6 +47,8 @@ BEGIN
[Premium] = @Premium, [Premium] = @Premium,
[Storage] = @Storage, [Storage] = @Storage,
[MaxStorageGb] = @MaxStorageGb, [MaxStorageGb] = @MaxStorageGb,
[StripeCustomerId] = @StripeCustomerId,
[StripeSubscriptionId] = @StripeSubscriptionId,
[CreationDate] = @CreationDate, [CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate [RevisionDate] = @RevisionDate
WHERE WHERE

View File

@ -18,6 +18,8 @@
[Premium] BIT NOT NULL, [Premium] BIT NOT NULL,
[Storage] BIGINT NULL, [Storage] BIGINT NULL,
[MaxStorageGb] SMALLINT NULL, [MaxStorageGb] SMALLINT NULL,
[StripeCustomerId] VARCHAR (50) NULL,
[StripeSubscriptionId] VARCHAR (50) NULL,
[CreationDate] DATETIME2 (7) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC) CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC)