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:
parent
2afef85f85
commit
d346ee5169
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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))
|
||||||
|
10
src/Core/Models/Api/Request/Accounts/PremiumRequestModel.cs
Normal file
10
src/Core/Models/Api/Request/Accounts/PremiumRequestModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
20
src/Core/Models/Api/Request/Accounts/StorageRequestModel.cs
Normal file
20
src/Core/Models/Api/Request/Accounts/StorageRequestModel.cs
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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; }
|
137
src/Core/Models/Api/Response/BillingResponseModel.cs
Normal file
137
src/Core/Models/Api/Response/BillingResponseModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
10
src/Core/Models/Table/IRevisable.cs
Normal file
10
src/Core/Models/Table/IRevisable.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Bit.Core.Models.Table
|
||||||
|
{
|
||||||
|
public interface IRevisable
|
||||||
|
{
|
||||||
|
DateTime CreationDate { get; }
|
||||||
|
DateTime RevisionDate { get; }
|
||||||
|
}
|
||||||
|
}
|
10
src/Core/Models/Table/IStorable.cs
Normal file
10
src/Core/Models/Table/IStorable.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
5
src/Core/Models/Table/IStorableSubscriber.cs
Normal file
5
src/Core/Models/Table/IStorableSubscriber.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace Bit.Core.Models.Table
|
||||||
|
{
|
||||||
|
public interface IStorableSubscriber : IStorable, ISubscriber
|
||||||
|
{ }
|
||||||
|
}
|
10
src/Core/Models/Table/ISubscriber.cs
Normal file
10
src/Core/Models/Table/ISubscriber.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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)
|
||||||
|
326
src/Core/Utilities/BillingHelpers.cs
Normal file
326
src/Core/Utilities/BillingHelpers.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user