1
0
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:
Matt Gibson
2021-11-08 17:01:09 -06:00
committed by Justin Baur
parent 143be4273b
commit 45f6ec1781
42 changed files with 1060 additions and 188 deletions

View File

@@ -22,5 +22,7 @@ namespace Bit.Core.Enums
GoogleInApp = 7,
[Display(Name = "Check")]
Check = 8,
[Display(Name = "None")]
None = 255,
}
}

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Enums
{

View File

@@ -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,
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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; }
}
}

View 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; }
}
}

View File

@@ -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]

View File

@@ -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; }

View File

@@ -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();
}
}
}
}

View File

@@ -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)
{
}

View File

@@ -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();
}

View File

@@ -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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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);
}
}