mirror of
https://github.com/bitwarden/server
synced 2025-12-23 03:33:35 +00:00
Families for enterprise/stripe integrations (#1699)
* Add PlanSponsorshipType to static store * Add sponsorship type to token and creates sponsorship * PascalCase properties * Require sponsorship for remove * Create subscription sponsorship helper class * Handle Sponsored subscription changes * Add sponsorship id to subscription metadata * Make sponsoring references nullable This state indicates that a sponsorship has lapsed, but was not able to be reverted for billing reasons * WIP: Validate and remove subscriptions * Update sponsorships on organization and org user delete * Add friendly name to organization sponsorship
This commit is contained in:
@@ -22,5 +22,7 @@ namespace Bit.Core.Enums
|
||||
GoogleInApp = 7,
|
||||
[Display(Name = "Check")]
|
||||
Check = 8,
|
||||
[Display(Name = "None")]
|
||||
None = 255,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Core.Enums
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Bit.Core.Models.Table;
|
||||
|
||||
namespace Bit.Core.Enums
|
||||
{
|
||||
@@ -29,26 +27,6 @@ namespace Bit.Core.Enums
|
||||
[Display(Name = "Enterprise (Monthly)")]
|
||||
EnterpriseMonthly = 10,
|
||||
[Display(Name = "Enterprise (Annually)")]
|
||||
EnterpriseAnnually= 11,
|
||||
}
|
||||
|
||||
public static class PlanTypeHelper
|
||||
{
|
||||
private static readonly PlanType[] _freePlans = new[] { PlanType.Free };
|
||||
private static readonly PlanType[] _familiesPlans = new[] { PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
|
||||
private static readonly PlanType[] _teamsPlans = new[] { PlanType.TeamsAnnually, PlanType.TeamsAnnually2019,
|
||||
PlanType.TeamsMonthly, PlanType.TeamsMonthly2019};
|
||||
private static readonly PlanType[] _enterprisePlans = new[] { PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseAnnually2019, PlanType.EnterpriseMonthly, PlanType.EnterpriseMonthly2019 };
|
||||
|
||||
private static bool HasPlan(PlanType[] planTypes, PlanType planType) => planTypes.Any(p => p == planType);
|
||||
public static bool HasFreePlan(Organization org) => IsFree(org.PlanType);
|
||||
public static bool IsFree(PlanType planType) => HasPlan(_freePlans, planType);
|
||||
public static bool HasFamiliesPlan(Organization org) => IsFamilies(org.PlanType);
|
||||
public static bool IsFamilies(PlanType planType) => HasPlan(_familiesPlans, planType);
|
||||
public static bool HasTeamsPlan(Organization org) => IsTeams(org.PlanType);
|
||||
public static bool IsTeams(PlanType planType) => HasPlan(_teamsPlans, planType);
|
||||
public static bool HasEnterprisePlan(Organization org) => IsEnterprise(org.PlanType);
|
||||
public static bool IsEnterprise(PlanType planType) => HasPlan(_enterprisePlans, planType);
|
||||
EnterpriseAnnually = 11,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Models.Api
|
||||
{
|
||||
public class OrganizationSponsorshipRedeemRequestModel
|
||||
{
|
||||
[Required]
|
||||
public PlanSponsorshipType PlanSponsorshipType { get; set; }
|
||||
[Required]
|
||||
public Guid SponsoredOrganizationId { get; set; }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace Bit.Core.Models.Api.Request
|
||||
[Required]
|
||||
[StringLength(256)]
|
||||
[StrictEmailAddress]
|
||||
public string sponsoredEmail { get; set; }
|
||||
public string SponsoredEmail { get; set; }
|
||||
|
||||
[StringLength(256)]
|
||||
public string FriendlyName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace Bit.Core.Models.Api
|
||||
UserId = organization.UserId?.ToString();
|
||||
ProviderId = organization.ProviderId?.ToString();
|
||||
ProviderName = organization.ProviderName;
|
||||
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
@@ -68,5 +69,6 @@ namespace Bit.Core.Models.Api
|
||||
public bool HasPublicAndPrivateKeys { get; set; }
|
||||
public string ProviderId { get; set; }
|
||||
public string ProviderName { get; set; }
|
||||
public string FamilySponsorshipFriendlyName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using Bit.Core.Models.Table;
|
||||
|
||||
namespace Bit.Core.Models.Business
|
||||
{
|
||||
public class SponsoredOrganizationSubscription
|
||||
{
|
||||
public const string OrganizationSponsorhipIdMetadataKey = "OrganizationSponsorshipId";
|
||||
private readonly string _customerId;
|
||||
private readonly Organization _org;
|
||||
private readonly StaticStore.Plan _plan;
|
||||
private readonly List<Stripe.TaxRate> _taxRates;
|
||||
|
||||
public SponsoredOrganizationSubscription(Organization org, Stripe.Subscription existingSubscription)
|
||||
{
|
||||
_org = org;
|
||||
_customerId = org.GatewayCustomerId;
|
||||
_plan = Utilities.StaticStore.GetPlan(org.PlanType);
|
||||
_taxRates = existingSubscription.DefaultTaxRates;
|
||||
}
|
||||
|
||||
public SponsorOrganizationSubscriptionOptions GetSponsorSubscriptionOptions(OrganizationSponsorship sponsorship,
|
||||
int additionalSeats = 0, int additionalStorageGb = 0, bool premiumAccessAddon = false)
|
||||
{
|
||||
var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value);
|
||||
|
||||
var subCreateOptions = new SponsorOrganizationSubscriptionOptions(_customerId, _org, _plan,
|
||||
sponsoredPlan, _taxRates, additionalSeats, additionalStorageGb, premiumAccessAddon);
|
||||
|
||||
subCreateOptions.Metadata.Add(OrganizationSponsorhipIdMetadataKey, sponsorship.Id.ToString());
|
||||
return subCreateOptions;
|
||||
}
|
||||
|
||||
public OrganizationUpgradeSubscriptionOptions RemoveOrganizationSubscriptionOptions(int additionalSeats = 0,
|
||||
int additionalStorageGb = 0, bool premiumAccessAddon = false) =>
|
||||
new OrganizationUpgradeSubscriptionOptions(_customerId, _org, _plan, _taxRates,
|
||||
additionalSeats, additionalStorageGb, premiumAccessAddon);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using Bit.Core.Models.Table;
|
||||
using Stripe;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Bit.Core.Models.Business
|
||||
{
|
||||
public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions
|
||||
{
|
||||
public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon)
|
||||
public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan,
|
||||
int additionalSeats, int additionalStorageGb, bool premiumAccessAddon)
|
||||
{
|
||||
Items = new List<SubscriptionItemOptions>();
|
||||
Metadata = new Dictionary<string, string>
|
||||
@@ -14,15 +16,6 @@ namespace Bit.Core.Models.Business
|
||||
[org.GatewayIdField()] = org.Id.ToString()
|
||||
};
|
||||
|
||||
if (plan.StripePlanId != null)
|
||||
{
|
||||
Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Plan = plan.StripePlanId,
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
|
||||
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
|
||||
{
|
||||
Items.Add(new SubscriptionItemOptions
|
||||
@@ -49,15 +42,53 @@ namespace Bit.Core.Models.Business
|
||||
Quantity = 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId))
|
||||
protected void AddPlanItem(StaticStore.Plan plan) => AddPlanItem(plan.StripePlanId);
|
||||
protected void AddPlanItem(StaticStore.SponsoredPlan sponsoredPlan) => AddPlanItem(sponsoredPlan.StripePlanId);
|
||||
protected void AddPlanItem(string stripePlanId)
|
||||
{
|
||||
if (stripePlanId != null)
|
||||
{
|
||||
DefaultTaxRates = new List<string>{ taxInfo.StripeTaxRateId };
|
||||
Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Plan = stripePlanId,
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected void AddTaxRateItem(TaxInfo taxInfo) => AddTaxRateItem(new List<string> { taxInfo.StripeTaxRateId });
|
||||
protected void AddTaxRateItem(List<Stripe.TaxRate> taxRates) => AddTaxRateItem(taxRates?.Select(t => t.Id).ToList());
|
||||
protected void AddTaxRateItem(List<string> taxRateIds)
|
||||
{
|
||||
if (taxRateIds != null && taxRateIds.Any())
|
||||
{
|
||||
DefaultTaxRates = taxRateIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase
|
||||
public abstract class UnsponsoredOrganizationSubscriptionOptionsBase : OrganizationSubscriptionOptionsBase
|
||||
{
|
||||
public UnsponsoredOrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo,
|
||||
int additionalSeats, int additionalStorage, bool premiumAccessAddon) :
|
||||
base(org, plan, additionalSeats, additionalStorage, premiumAccessAddon)
|
||||
{
|
||||
AddPlanItem(plan);
|
||||
AddTaxRateItem(taxInfo);
|
||||
}
|
||||
public UnsponsoredOrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, List<Stripe.TaxRate> taxInfo,
|
||||
int additionalSeats, int additionalStorage, bool premiumAccessAddon) :
|
||||
base(org, plan, additionalSeats, additionalStorage, premiumAccessAddon)
|
||||
{
|
||||
AddPlanItem(plan);
|
||||
AddTaxRateItem(taxInfo);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class OrganizationPurchaseSubscriptionOptions : UnsponsoredOrganizationSubscriptionOptionsBase
|
||||
{
|
||||
public OrganizationPurchaseSubscriptionOptions(
|
||||
Organization org, StaticStore.Plan plan,
|
||||
@@ -70,7 +101,7 @@ namespace Bit.Core.Models.Business
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOptionsBase
|
||||
public class OrganizationUpgradeSubscriptionOptions : UnsponsoredOrganizationSubscriptionOptionsBase
|
||||
{
|
||||
public OrganizationUpgradeSubscriptionOptions(
|
||||
string customerId, Organization org,
|
||||
@@ -81,5 +112,43 @@ namespace Bit.Core.Models.Business
|
||||
{
|
||||
Customer = customerId;
|
||||
}
|
||||
public OrganizationUpgradeSubscriptionOptions(
|
||||
string customerId, Organization org,
|
||||
StaticStore.Plan plan, List<Stripe.TaxRate> taxInfo,
|
||||
int additionalSeats = 0, int additionalStorageGb = 0,
|
||||
bool premiumAccessAddon = false) :
|
||||
base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon)
|
||||
{
|
||||
Customer = customerId;
|
||||
}
|
||||
}
|
||||
|
||||
public class RemoveOrganizationSubscriptionOptions : OrganizationSubscriptionOptionsBase
|
||||
{
|
||||
public RemoveOrganizationSubscriptionOptions(string customerId, Organization org,
|
||||
StaticStore.Plan plan, List<string> existingTaxRateStripeIds,
|
||||
int additionalSeats = 0, int additionalStorageGb = 0,
|
||||
bool premiumAccessAddon = false) :
|
||||
base(org, plan, additionalSeats, additionalStorageGb, premiumAccessAddon)
|
||||
{
|
||||
Customer = customerId;
|
||||
AddPlanItem(plan);
|
||||
AddTaxRateItem(existingTaxRateStripeIds);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class SponsorOrganizationSubscriptionOptions : OrganizationSubscriptionOptionsBase
|
||||
{
|
||||
public SponsorOrganizationSubscriptionOptions(
|
||||
string customerId, Organization org, StaticStore.Plan existingPlan,
|
||||
StaticStore.SponsoredPlan sponsorshipPlan, List<Stripe.TaxRate> existingTaxRates, int additionalSeats = 0,
|
||||
int additionalStorageGb = 0, bool premiumAccessAddon = false) :
|
||||
base(org, existingPlan, additionalSeats, additionalStorageGb, premiumAccessAddon)
|
||||
{
|
||||
Customer = customerId;
|
||||
AddPlanItem(sponsorshipPlan);
|
||||
AddTaxRateItem(existingTaxRates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,6 @@ namespace Bit.Core.Models.Data
|
||||
public string PrivateKey { get; set; }
|
||||
public Guid? ProviderId { get; set; }
|
||||
public string ProviderName { get; set; }
|
||||
public string FamilySponsorshipFriendlyName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
12
src/Core/Models/StaticStore/SponsoredPlan.cs
Normal file
12
src/Core/Models/StaticStore/SponsoredPlan.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Models.StaticStore
|
||||
{
|
||||
public class SponsoredPlan
|
||||
{
|
||||
public PlanSponsorshipType PlanSponsorshipType { get; set; }
|
||||
public ProductType SponsoredProductType { get; set; }
|
||||
public ProductType SponsoringProductType { get; set; }
|
||||
public string StripePlanId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ namespace Bit.Core.Models.Table
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid? InstallationId { get; set; }
|
||||
[Required]
|
||||
public Guid SponsoringOrganizationId { get; set; }
|
||||
[Required]
|
||||
public Guid SponsoringOrganizationUserId { get; set; }
|
||||
public Guid? SponsoringOrganizationId { get; set; }
|
||||
public Guid? SponsoringOrganizationUserId { get; set; }
|
||||
public Guid? SponsoredOrganizationId { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string FriendlyName { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string OfferedToEmail { get; set; }
|
||||
public PlanSponsorshipType? PlanSponsorshipType { get; set; }
|
||||
[Required]
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
public DbSet<GroupUser> GroupUsers { get; set; }
|
||||
public DbSet<Installation> Installations { get; set; }
|
||||
public DbSet<Organization> Organizations { get; set; }
|
||||
public DbSet<OrganizationSponsorship> organizationSponsorships { get; set; }
|
||||
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
|
||||
public DbSet<OrganizationUser> OrganizationUsers { get; set; }
|
||||
public DbSet<Policy> Policies { get; set; }
|
||||
public DbSet<Provider> Providers { get; set; }
|
||||
|
||||
@@ -95,5 +95,29 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
{
|
||||
await OrganizationUpdateStorage(id);
|
||||
}
|
||||
|
||||
public override async Task DeleteAsync(Organization organization)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var orgUser = dbContext.FindAsync<EFModel.Organization>(organization.Id);
|
||||
var sponsorships = dbContext.OrganizationSponsorships
|
||||
.Where(os =>
|
||||
os.SponsoringOrganizationId == organization.Id ||
|
||||
os.SponsoredOrganizationId == organization.Id);
|
||||
dbContext.RemoveRange(sponsorships.Where(os => os.CloudSponsor));
|
||||
|
||||
Guid? UpdatedOrgId(Guid? orgId) => orgId == organization.Id ? null : organization.Id;
|
||||
foreach (var sponsorship in sponsorships.Where(os => !os.CloudSponsor))
|
||||
{
|
||||
sponsorship.SponsoredOrganizationId = UpdatedOrgId(sponsorship.SponsoredOrganizationId);
|
||||
sponsorship.SponsoringOrganizationId = UpdatedOrgId(sponsorship.SponsoringOrganizationId);
|
||||
}
|
||||
|
||||
dbContext.Remove(orgUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
public class OrganizationSponsorshipRepository : Repository<TableModel.OrganizationSponsorship, EFModel.OrganizationSponsorship, Guid>, IOrganizationSponsorshipRepository
|
||||
{
|
||||
public OrganizationSponsorshipRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) :
|
||||
base(serviceScopeFactory, mapper, (DatabaseContext context) => context.organizationSponsorships)
|
||||
base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationSponsorships)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,32 @@ namespace Bit.Core.Repositories.EntityFramework
|
||||
return organizationUsers.Select(u => u.Id).ToList();
|
||||
}
|
||||
|
||||
public override async Task DeleteAsync(OrganizationUser organizationUser) => await DeleteAsync(organizationUser.Id);
|
||||
public async Task DeleteAsync(Guid organizationUserId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var orgUser = dbContext.FindAsync<EfModel.OrganizationUser>(organizationUserId);
|
||||
var sponsorships = dbContext.OrganizationSponsorships
|
||||
.Where(os => os.SponsoringOrganizationUserId != default &&
|
||||
os.SponsoringOrganizationUserId.Value == organizationUserId);
|
||||
dbContext.RemoveRange(sponsorships);
|
||||
dbContext.Remove(orgUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var entities = dbContext.FindAsync<EfModel.OrganizationUser>(organizationUserIds);
|
||||
var sponsorships = dbContext.OrganizationSponsorships
|
||||
.Where(os => os.SponsoringOrganizationUserId != default &&
|
||||
organizationUserIds.Contains(os.SponsoringOrganizationUserId ?? default));
|
||||
dbContext.RemoveRange(sponsorships);
|
||||
dbContext.RemoveRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ namespace Bit.Core.Repositories.EntityFramework.Queries
|
||||
from po in po_g.DefaultIfEmpty()
|
||||
join p in dbContext.Providers on po.ProviderId equals p.Id into p_g
|
||||
from p in p_g.DefaultIfEmpty()
|
||||
join os in dbContext.OrganizationSponsorships on ou.Id equals os.SponsoringOrganizationUserId into os_g
|
||||
from os in os_g.DefaultIfEmpty()
|
||||
where ((su == null || !su.OrganizationId.HasValue) || su.OrganizationId == ou.OrganizationId)
|
||||
select new { ou, o, su, p };
|
||||
select new { ou, o, su, p, os };
|
||||
return query.Select(x => new OrganizationUserOrganizationDetails
|
||||
{
|
||||
OrganizationId = x.ou.OrganizationId,
|
||||
@@ -48,6 +50,7 @@ namespace Bit.Core.Repositories.EntityFramework.Queries
|
||||
PrivateKey = x.o.PrivateKey,
|
||||
ProviderId = x.p.Id,
|
||||
ProviderName = x.p.Name,
|
||||
FamilySponsorshipFriendlyName = x.os.FriendlyName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Table;
|
||||
@@ -7,8 +8,10 @@ namespace Bit.Core.Services
|
||||
public interface IOrganizationSponsorshipService
|
||||
{
|
||||
Task<bool> ValidateRedemptionTokenAsync(string encryptedToken);
|
||||
Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, PlanSponsorshipType sponsorshipType, string sponsoredEmail);
|
||||
Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
|
||||
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName);
|
||||
Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization);
|
||||
Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship);
|
||||
Task<bool> ValidateSponsorshipAsync(Guid sponsoredOrganizationId);
|
||||
Task RemoveSponsorshipAsync(Organization sponsoredOrganization, OrganizationSponsorship sponsorship);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Services
|
||||
@@ -10,13 +11,15 @@ namespace Bit.Core.Services
|
||||
{
|
||||
Task CancelAndRecoverChargesAsync(ISubscriber subscriber);
|
||||
Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
|
||||
string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, int additionalSeats,
|
||||
string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats,
|
||||
bool premiumAccessAddon, TaxInfo taxInfo);
|
||||
Task<string> UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan,
|
||||
Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship);
|
||||
Task<bool> RemoveOrganizationSponsorshipAsync(Organization org);
|
||||
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan,
|
||||
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo);
|
||||
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
|
||||
short additionalStorageGb, TaxInfo taxInfo);
|
||||
Task<string> AdjustSeatsAsync(Organization organization, Models.StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
|
||||
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
|
||||
bool skipInAppPurchaseCheck = false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Table;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -13,12 +14,18 @@ namespace Bit.Core.Services
|
||||
private const string TokenClearTextPrefix = "BWOrganizationSponsorship_";
|
||||
|
||||
private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IDataProtector _dataProtector;
|
||||
|
||||
public OrganizationSponsorshipService(IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IDataProtector dataProtector)
|
||||
{
|
||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_paymentService = paymentService;
|
||||
_dataProtector = dataProtector;
|
||||
}
|
||||
|
||||
@@ -63,13 +70,16 @@ namespace Bit.Core.Services
|
||||
_dataProtector.Protect($"{FamiliesForEnterpriseTokenName} {sponsorshipId} {sponsorshipType}")
|
||||
);
|
||||
|
||||
public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, PlanSponsorshipType sponsorshipType, string sponsoredEmail)
|
||||
public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
|
||||
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName)
|
||||
{
|
||||
var sponsorship = new OrganizationSponsorship
|
||||
{
|
||||
SponsoringOrganizationId = sponsoringOrg.Id,
|
||||
SponsoringOrganizationUserId = sponsoringOrgUser.Id,
|
||||
FriendlyName = friendlyName,
|
||||
OfferedToEmail = sponsoredEmail,
|
||||
PlanSponsorshipType = sponsorshipType,
|
||||
CloudSponsor = true,
|
||||
};
|
||||
|
||||
@@ -78,6 +88,7 @@ namespace Bit.Core.Services
|
||||
sponsorship = await _organizationSponsorshipRepository.CreateAsync(sponsorship);
|
||||
|
||||
// TODO: send email to sponsoredEmail w/ redemption token link
|
||||
var _ = RedemptionToken(sponsorship.Id, sponsorshipType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -91,14 +102,117 @@ namespace Bit.Core.Services
|
||||
|
||||
public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization)
|
||||
{
|
||||
// TODO: set up sponsorship, remember remove offeredToEmail from sponsorship
|
||||
throw new NotImplementedException();
|
||||
if (sponsorship.PlanSponsorshipType == null)
|
||||
{
|
||||
throw new BadRequestException("Cannot set up sponsorship without a known sponsorship type.");
|
||||
}
|
||||
|
||||
// TODO: rollback?
|
||||
await _paymentService.SponsorOrganizationAsync(sponsoredOrganization, sponsorship);
|
||||
await _organizationRepository.UpsertAsync(sponsoredOrganization);
|
||||
|
||||
sponsorship.SponsoredOrganizationId = sponsoredOrganization.Id;
|
||||
sponsorship.OfferedToEmail = null;
|
||||
await _organizationSponsorshipRepository.UpsertAsync(sponsorship);
|
||||
}
|
||||
|
||||
public async Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship)
|
||||
public async Task<bool> ValidateSponsorshipAsync(Guid sponsoredOrganizationId)
|
||||
{
|
||||
// TODO: remove sponsorship
|
||||
throw new NotImplementedException();
|
||||
var sponsoredOrganization = await _organizationRepository.GetByIdAsync(sponsoredOrganizationId);
|
||||
var existingSponsorship = await _organizationSponsorshipRepository
|
||||
.GetBySponsoredOrganizationIdAsync(sponsoredOrganizationId);
|
||||
|
||||
if (existingSponsorship == null)
|
||||
{
|
||||
await RemoveSponsorshipAsync(sponsoredOrganization);
|
||||
// TODO on fail, mark org as disabled.
|
||||
return false;
|
||||
}
|
||||
|
||||
var validated = true;
|
||||
if (existingSponsorship.SponsoringOrganizationId == null || existingSponsorship.SponsoringOrganizationUserId == null)
|
||||
{
|
||||
await RemoveSponsorshipAsync(sponsoredOrganization);
|
||||
validated = false;
|
||||
}
|
||||
|
||||
var sponsoringOrganization = await _organizationRepository
|
||||
.GetByIdAsync(existingSponsorship.SponsoringOrganizationId.Value);
|
||||
if (!sponsoringOrganization.Enabled)
|
||||
{
|
||||
await RemoveSponsorshipAsync(sponsoredOrganization);
|
||||
validated = false;
|
||||
}
|
||||
|
||||
if (!validated && existingSponsorship.SponsoredOrganizationId != null)
|
||||
{
|
||||
existingSponsorship.TimesRenewedWithoutValidation += 1;
|
||||
existingSponsorship.SponsorshipLapsedDate ??= DateTime.UtcNow;
|
||||
|
||||
await _organizationSponsorshipRepository.UpsertAsync(existingSponsorship);
|
||||
if (existingSponsorship.TimesRenewedWithoutValidation >= 6)
|
||||
{
|
||||
sponsoredOrganization.Enabled = false;
|
||||
await _organizationRepository.UpsertAsync(sponsoredOrganization);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task RemoveSponsorshipAsync(Organization sponsoredOrganization, OrganizationSponsorship sponsorship = null)
|
||||
{
|
||||
var success = await _paymentService.RemoveOrganizationSponsorshipAsync(sponsoredOrganization);
|
||||
await _organizationRepository.UpsertAsync(sponsoredOrganization);
|
||||
|
||||
if (sponsorship == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Initialize the record as available
|
||||
sponsorship.SponsoredOrganizationId = null;
|
||||
sponsorship.FriendlyName = null;
|
||||
sponsorship.OfferedToEmail = null;
|
||||
sponsorship.PlanSponsorshipType = null;
|
||||
sponsorship.TimesRenewedWithoutValidation = 0;
|
||||
sponsorship.SponsorshipLapsedDate = null;
|
||||
|
||||
if (sponsorship.CloudSponsor || sponsorship.SponsorshipLapsedDate.HasValue)
|
||||
{
|
||||
await _organizationSponsorshipRepository.DeleteAsync(sponsorship);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _organizationSponsorshipRepository.UpsertAsync(sponsorship);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sponsorship.SponsoringOrganizationId = null;
|
||||
sponsorship.SponsoringOrganizationUserId = null;
|
||||
|
||||
if (!sponsorship.CloudSponsor)
|
||||
{
|
||||
// Sef-hosted sponsorship record
|
||||
// we need to make the existing sponsorship available, and add
|
||||
// a new sponsorship record to record the lapsed sponsorship
|
||||
var cleanSponsorship = new OrganizationSponsorship
|
||||
{
|
||||
InstallationId = sponsorship.InstallationId,
|
||||
SponsoringOrganizationId = sponsorship.SponsoringOrganizationId,
|
||||
SponsoringOrganizationUserId = sponsorship.SponsoringOrganizationUserId,
|
||||
CloudSponsor = sponsorship.CloudSponsor,
|
||||
};
|
||||
await _organizationSponsorshipRepository.UpsertAsync(cleanSponsorship);
|
||||
}
|
||||
|
||||
sponsorship.SponsorshipLapsedDate ??= DateTime.UtcNow;
|
||||
await _organizationSponsorshipRepository.UpsertAsync(sponsorship);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -192,6 +192,44 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship)
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId);
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId);
|
||||
|
||||
var sponsoredSubscription = new SponsoredOrganizationSubscription(org, sub);
|
||||
|
||||
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
|
||||
false, PaymentMethodType.None, sponsoredSubscription.GetSponsorSubscriptionOptions(sponsorship), null);
|
||||
org.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveOrganizationSponsorshipAsync(Organization org)
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId);
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId);
|
||||
|
||||
var sponsoredSubscription = new SponsoredOrganizationSubscription(org, sub);
|
||||
var subCreateOptions = sponsoredSubscription.RemoveOrganizationSubscriptionOptions();
|
||||
|
||||
var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions);
|
||||
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
|
||||
stripePaymentMethod, paymentMethodType, subCreateOptions, null);
|
||||
|
||||
if (subscription.Status == "incomplete")
|
||||
{
|
||||
// TODO: revert
|
||||
return false;
|
||||
}
|
||||
org.GatewaySubscriptionId = subscription.Id;
|
||||
org.Enabled = true;
|
||||
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan,
|
||||
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
|
||||
{
|
||||
@@ -227,6 +265,29 @@ namespace Bit.Core.Services
|
||||
}
|
||||
|
||||
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
|
||||
var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions);
|
||||
|
||||
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
|
||||
stripePaymentMethod, paymentMethodType, subCreateOptions, null);
|
||||
org.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
if (subscription.Status == "incomplete" &&
|
||||
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
|
||||
{
|
||||
org.Enabled = false;
|
||||
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
|
||||
}
|
||||
else
|
||||
{
|
||||
org.Enabled = true;
|
||||
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private (bool stripePaymentMethod, PaymentMethodType PaymentMethodType) IdentifyPaymentMethod(
|
||||
Stripe.Customer customer, Stripe.SubscriptionCreateOptions subCreateOptions)
|
||||
{
|
||||
var stripePaymentMethod = false;
|
||||
var paymentMethodType = PaymentMethodType.Credit;
|
||||
var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId");
|
||||
@@ -265,23 +326,7 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
|
||||
stripePaymentMethod, paymentMethodType, subCreateOptions, null);
|
||||
org.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
if (subscription.Status == "incomplete" &&
|
||||
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
|
||||
{
|
||||
org.Enabled = false;
|
||||
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
|
||||
}
|
||||
else
|
||||
{
|
||||
org.Enabled = true;
|
||||
org.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
return null;
|
||||
}
|
||||
return (stripePaymentMethod, paymentMethodType);
|
||||
}
|
||||
|
||||
public async Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
using Bit.Core.Models.Table;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Bit.Core.Utilities
|
||||
{
|
||||
@@ -475,5 +477,19 @@ namespace Bit.Core.Utilities
|
||||
|
||||
public static IDictionary<GlobalEquivalentDomainsType, IEnumerable<string>> GlobalDomains { get; set; }
|
||||
public static IEnumerable<Plan> Plans { get; set; }
|
||||
public static IEnumerable<SponsoredPlan> SponsoredPlans { get; set; } = new[]
|
||||
{
|
||||
new SponsoredPlan
|
||||
{
|
||||
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
SponsoredProductType = ProductType.Families,
|
||||
SponsoringProductType = ProductType.Enterprise,
|
||||
StripePlanId = "2021-enterprise-sponsored-families-org-monthly"
|
||||
}
|
||||
};
|
||||
public static Plan GetPlan(PlanType planType) =>
|
||||
Plans.FirstOrDefault(p => p.Type == planType);
|
||||
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
|
||||
SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user