mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 08:32:50 -05:00
Merge branch 'main' into ac/ac-1682/ef-migrations
This commit is contained in:
@ -76,14 +76,18 @@ public class JobsHostedService : BaseJobsHostedService
|
||||
{
|
||||
new Tuple<Type, ITrigger>(typeof(DeleteSendsJob), everyFiveMinutesTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DatabaseExpiredGrantsJob), everyFridayAt10pmTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DatabaseUpdateStatisticsJob), everySaturdayAtMidnightTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DatabaseRebuildlIndexesJob), everySundayAtMidnightTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DeleteCiphersJob), everyDayAtMidnightUtc),
|
||||
new Tuple<Type, ITrigger>(typeof(DatabaseExpiredSponsorshipsJob), everyMondayAtMidnightTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DeleteAuthRequestsJob), everyFifteenMinutesTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(DeleteUnverifiedOrganizationDomainsJob), everyDayAtTwoAmUtcTrigger),
|
||||
};
|
||||
|
||||
if (!(_globalSettings.SqlServer?.DisableDatabaseMaintenanceJobs ?? false))
|
||||
{
|
||||
jobs.Add(new Tuple<Type, ITrigger>(typeof(DatabaseUpdateStatisticsJob), everySaturdayAtMidnightTrigger));
|
||||
jobs.Add(new Tuple<Type, ITrigger>(typeof(DatabaseRebuildlIndexesJob), everySundayAtMidnightTrigger));
|
||||
}
|
||||
|
||||
if (!_globalSettings.SelfHosted)
|
||||
{
|
||||
jobs.Add(new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger));
|
||||
|
@ -88,7 +88,7 @@ public class Startup
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddScoped<IAccessControlService, AccessControlService>();
|
||||
services.AddBillingCommands();
|
||||
services.AddBillingOperations();
|
||||
|
||||
#if OSS
|
||||
services.AddOosServices();
|
||||
|
@ -30,10 +30,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
|
@ -66,7 +66,7 @@ public class OrganizationsController : Controller
|
||||
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
|
||||
|
||||
@ -93,7 +93,7 @@ public class OrganizationsController : Controller
|
||||
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
|
||||
IPushNotificationService pushNotificationService,
|
||||
ICancelSubscriptionCommand cancelSubscriptionCommand,
|
||||
IGetSubscriptionQuery getSubscriptionQuery,
|
||||
ISubscriberQueries subscriberQueries,
|
||||
IReferenceEventService referenceEventService,
|
||||
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand)
|
||||
{
|
||||
@ -119,7 +119,7 @@ public class OrganizationsController : Controller
|
||||
_addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_cancelSubscriptionCommand = cancelSubscriptionCommand;
|
||||
_getSubscriptionQuery = getSubscriptionQuery;
|
||||
_subscriberQueries = subscriberQueries;
|
||||
_referenceEventService = referenceEventService;
|
||||
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
|
||||
}
|
||||
@ -479,7 +479,7 @@ public class OrganizationsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var subscription = await _getSubscriptionQuery.GetSubscription(organization);
|
||||
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization);
|
||||
|
||||
await _cancelSubscriptionCommand.CancelSubscription(subscription,
|
||||
new OffboardingSurveyResponse
|
||||
|
@ -126,8 +126,14 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
if (hideSensitiveData)
|
||||
{
|
||||
BillingEmail = null;
|
||||
Subscription.Items = null;
|
||||
UpcomingInvoice.Amount = null;
|
||||
if (Subscription != null)
|
||||
{
|
||||
Subscription.Items = null;
|
||||
}
|
||||
if (UpcomingInvoice != null)
|
||||
{
|
||||
UpcomingInvoice.Amount = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,7 +69,7 @@ public class AccountsController : Controller
|
||||
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
|
||||
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
|
||||
private readonly ISubscriberQueries _subscriberQueries;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
@ -104,7 +104,7 @@ public class AccountsController : Controller
|
||||
IRotateUserKeyCommand rotateUserKeyCommand,
|
||||
IFeatureService featureService,
|
||||
ICancelSubscriptionCommand cancelSubscriptionCommand,
|
||||
IGetSubscriptionQuery getSubscriptionQuery,
|
||||
ISubscriberQueries subscriberQueries,
|
||||
IReferenceEventService referenceEventService,
|
||||
ICurrentContext currentContext,
|
||||
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
|
||||
@ -133,7 +133,7 @@ public class AccountsController : Controller
|
||||
_rotateUserKeyCommand = rotateUserKeyCommand;
|
||||
_featureService = featureService;
|
||||
_cancelSubscriptionCommand = cancelSubscriptionCommand;
|
||||
_getSubscriptionQuery = getSubscriptionQuery;
|
||||
_subscriberQueries = subscriberQueries;
|
||||
_referenceEventService = referenceEventService;
|
||||
_currentContext = currentContext;
|
||||
_cipherValidator = cipherValidator;
|
||||
@ -831,7 +831,7 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var subscription = await _getSubscriptionQuery.GetSubscription(user);
|
||||
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(user);
|
||||
|
||||
await _cancelSubscriptionCommand.CancelSubscription(subscription,
|
||||
new OffboardingSurveyResponse
|
||||
|
44
src/Api/Billing/Controllers/ProviderBillingController.cs
Normal file
44
src/Api/Billing/Controllers/ProviderBillingController.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using Bit.Api.Billing.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("providers/{providerId:guid}/billing")]
|
||||
[Authorize("Application")]
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingQueries providerBillingQueries) : Controller
|
||||
{
|
||||
[HttpGet("subscription")]
|
||||
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!currentContext.ProviderProviderAdmin(providerId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var subscriptionData = await providerBillingQueries.GetSubscriptionData(providerId);
|
||||
|
||||
if (subscriptionData == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var (providerPlans, subscription) = subscriptionData;
|
||||
|
||||
var providerSubscriptionDTO = ProviderSubscriptionDTO.From(providerPlans, subscription);
|
||||
|
||||
return TypedResults.Ok(providerSubscriptionDTO);
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
using Bit.Api.Billing.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("providers/{providerId:guid}/organizations")]
|
||||
public class ProviderOrganizationController(
|
||||
IAssignSeatsToClientOrganizationCommand assignSeatsToClientOrganizationCommand,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<ProviderOrganizationController> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository) : Controller
|
||||
{
|
||||
[HttpPut("{providerOrganizationId:guid}")]
|
||||
public async Task<IResult> UpdateAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromRoute] Guid providerOrganizationId,
|
||||
[FromBody] UpdateProviderOrganizationRequestBody requestBody)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!currentContext.ProviderProviderAdmin(providerId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
var providerOrganization = await providerOrganizationRepository.GetByIdAsync(providerOrganizationId);
|
||||
|
||||
if (provider == null || providerOrganization == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
logger.LogError("The organization ({OrganizationID}) represented by provider organization ({ProviderOrganizationID}) could not be found.", providerOrganization.OrganizationId, providerOrganization.Id);
|
||||
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization(
|
||||
provider,
|
||||
organization,
|
||||
requestBody.AssignedSeats);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
49
src/Api/Billing/Models/ProviderSubscriptionDTO.cs
Normal file
49
src/Api/Billing/Models/ProviderSubscriptionDTO.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Models;
|
||||
|
||||
public record ProviderSubscriptionDTO(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
IEnumerable<ProviderPlanDTO> Plans)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
|
||||
public static ProviderSubscriptionDTO From(
|
||||
IEnumerable<ConfiguredProviderPlan> providerPlans,
|
||||
Subscription subscription)
|
||||
{
|
||||
var providerPlansDTO = providerPlans
|
||||
.Select(providerPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.SeatPrice;
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanDTO(
|
||||
plan.Name,
|
||||
providerPlan.SeatMinimum,
|
||||
providerPlan.PurchasedSeats,
|
||||
providerPlan.AssignedSeats,
|
||||
cost,
|
||||
cadence);
|
||||
});
|
||||
|
||||
return new ProviderSubscriptionDTO(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
providerPlansDTO);
|
||||
}
|
||||
}
|
||||
|
||||
public record ProviderPlanDTO(
|
||||
string PlanName,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats,
|
||||
decimal Cost,
|
||||
string Cadence);
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Api.Billing.Models;
|
||||
|
||||
public class UpdateProviderOrganizationRequestBody
|
||||
{
|
||||
public int AssignedSeats { get; set; }
|
||||
}
|
@ -2,7 +2,6 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -11,7 +10,6 @@ using Bit.Core.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -322,7 +320,6 @@ public class CollectionsController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("bulk-access")]
|
||||
[RequireFeature(FeatureFlagKeys.BulkCollectionAccess)]
|
||||
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
|
||||
{
|
||||
// Authorization logic assumes flexible collections is enabled
|
||||
|
@ -75,6 +75,10 @@ public class BillingSubscription
|
||||
{
|
||||
Items = sub.Items.Select(i => new BillingSubscriptionItem(i));
|
||||
}
|
||||
CollectionMethod = sub.CollectionMethod;
|
||||
SuspensionDate = sub.SuspensionDate;
|
||||
UnpaidPeriodEndDate = sub.UnpaidPeriodEndDate;
|
||||
GracePeriod = sub.GracePeriod;
|
||||
}
|
||||
|
||||
public DateTime? TrialStartDate { get; set; }
|
||||
@ -86,6 +90,10 @@ public class BillingSubscription
|
||||
public string Status { get; set; }
|
||||
public bool Cancelled { get; set; }
|
||||
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
||||
public string CollectionMethod { get; set; }
|
||||
public DateTime? SuspensionDate { get; set; }
|
||||
public DateTime? UnpaidPeriodEndDate { get; set; }
|
||||
public int? GracePeriod { get; set; }
|
||||
|
||||
public class BillingSubscriptionItem
|
||||
{
|
||||
|
@ -170,8 +170,7 @@ public class Startup
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddOrganizationSubscriptionServices();
|
||||
services.AddCoreLocalizationServices();
|
||||
services.AddBillingCommands();
|
||||
services.AddBillingQueries();
|
||||
services.AddBillingOperations();
|
||||
|
||||
// Authorization Handlers
|
||||
services.AddAuthorizationHandlers();
|
||||
|
@ -32,10 +32,6 @@
|
||||
"send": {
|
||||
"connectionString": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
|
@ -868,7 +868,7 @@ public class StripeController : Controller
|
||||
private bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice)
|
||||
{
|
||||
return invoice.AmountDue > 0 && !invoice.Paid && invoice.CollectionMethod == "charge_automatically" &&
|
||||
invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null;
|
||||
invoice.BillingReason is "subscription_cycle" or "automatic_pending_invoice_item_invoice" && invoice.SubscriptionId != null;
|
||||
}
|
||||
|
||||
private async Task<Subscription> VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription)
|
||||
|
@ -30,10 +30,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
|
@ -6,7 +6,7 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
public class Provider : ITableObject<Guid>
|
||||
public class Provider : ITableObject<Guid>, ISubscriber
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
@ -34,6 +34,26 @@ public class Provider : ITableObject<Guid>
|
||||
public string GatewayCustomerId { get; set; }
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
|
||||
public string BillingEmailAddress() => BillingEmail?.ToLowerInvariant().Trim();
|
||||
|
||||
public string BillingName() => DisplayBusinessName();
|
||||
|
||||
public string SubscriberName() => DisplayName();
|
||||
|
||||
public string BraintreeCustomerIdPrefix() => "p";
|
||||
|
||||
public string BraintreeIdField() => "provider_id";
|
||||
|
||||
public string BraintreeCloudRegionField() => "region";
|
||||
|
||||
public bool IsOrganization() => false;
|
||||
|
||||
public bool IsUser() => false;
|
||||
|
||||
public string SubscriberType() => "Provider";
|
||||
|
||||
public bool IsExpired() => false;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
if (Id == default)
|
||||
|
@ -146,7 +146,8 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
OwnersNotifiedOfAutoscaling = OwnersNotifiedOfAutoscaling,
|
||||
LimitCollectionCreationDeletion = LimitCollectionCreationDeletion,
|
||||
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
|
||||
FlexibleCollections = FlexibleCollections
|
||||
FlexibleCollections = FlexibleCollections,
|
||||
Status = Status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
9
src/Core/Billing/BillingException.cs
Normal file
9
src/Core/Billing/BillingException.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace Bit.Core.Billing;
|
||||
|
||||
public class BillingException(
|
||||
string clientFriendlyMessage,
|
||||
string internalMessage = null,
|
||||
Exception innerException = null) : Exception(internalMessage, innerException)
|
||||
{
|
||||
public string ClientFriendlyMessage { get; set; } = clientFriendlyMessage;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
namespace Bit.Core.Billing.Commands;
|
||||
|
||||
public interface IAssignSeatsToClientOrganizationCommand
|
||||
{
|
||||
Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Commands;
|
||||
@ -17,7 +16,6 @@ public interface ICancelSubscriptionCommand
|
||||
/// <param name="subscription">The <see cref="User"/> or <see cref="Organization"/> with the subscription to cancel.</param>
|
||||
/// <param name="offboardingSurveyResponse">An <see cref="OffboardingSurveyResponse"/> DTO containing user-provided feedback on why they are cancelling the subscription.</param>
|
||||
/// <param name="cancelImmediately">A flag indicating whether to cancel the subscription immediately or at the end of the subscription period.</param>
|
||||
/// <exception cref="GatewayException">Thrown when the provided subscription is already in an inactive state.</exception>
|
||||
Task CancelSubscription(
|
||||
Subscription subscription,
|
||||
OffboardingSurveyResponse offboardingSurveyResponse,
|
||||
|
@ -4,5 +4,12 @@ namespace Bit.Core.Billing.Commands;
|
||||
|
||||
public interface IRemovePaymentMethodCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to remove an Organization's saved payment method. If the Stripe <see cref="Stripe.Customer"/> representing the
|
||||
/// <see cref="Organization"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
|
||||
/// this command will attempt to remove the Braintree <see cref="Braintree.PaymentMethod"/>. Otherwise, it will attempt to remove the
|
||||
/// Stripe <see cref="Stripe.PaymentMethod"/>.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to remove the saved payment method for.</param>
|
||||
Task RemovePaymentMethod(Organization organization);
|
||||
}
|
||||
|
@ -0,0 +1,174 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Queries;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Commands.Implementations;
|
||||
|
||||
public class AssignSeatsToClientOrganizationCommand(
|
||||
ILogger<AssignSeatsToClientOrganizationCommand> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IProviderBillingQueries providerBillingQueries,
|
||||
IProviderPlanRepository providerPlanRepository) : IAssignSeatsToClientOrganizationCommand
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Reseller-type provider ({ID}) cannot assign seats to client organizations", provider.Id);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
}
|
||||
|
||||
if (seats < 0)
|
||||
{
|
||||
throw new BillingException(
|
||||
"You cannot assign negative seats to a client.",
|
||||
"MSP cannot assign negative seats to a client organization");
|
||||
}
|
||||
|
||||
if (seats == organization.Seats)
|
||||
{
|
||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned", organization.Id, organization.Seats);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var providerPlan = await GetProviderPlanAsync(provider, organization);
|
||||
|
||||
var providerSeatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
||||
|
||||
// How many seats the provider has assigned to all their client organizations that have the specified plan type.
|
||||
var providerCurrentlyAssignedSeatTotal = await providerBillingQueries.GetAssignedSeatTotalForPlanOrThrow(provider.Id, providerPlan.PlanType);
|
||||
|
||||
// How many seats are being added to or subtracted from this client organization.
|
||||
var seatDifference = seats - (organization.Seats ?? 0);
|
||||
|
||||
// How many seats the provider will have assigned to all of their client organizations after the update.
|
||||
var providerNewlyAssignedSeatTotal = providerCurrentlyAssignedSeatTotal + seatDifference;
|
||||
|
||||
var update = CurryUpdateFunction(
|
||||
provider,
|
||||
providerPlan,
|
||||
organization,
|
||||
seats,
|
||||
providerNewlyAssignedSeatTotal);
|
||||
|
||||
/*
|
||||
* Below the limit => Below the limit:
|
||||
* No subscription update required. We can safely update the organization's seats.
|
||||
*/
|
||||
if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum &&
|
||||
providerNewlyAssignedSeatTotal <= providerSeatMinimum)
|
||||
{
|
||||
organization.Seats = seats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
providerPlan.AllocatedSeats = providerNewlyAssignedSeatTotal;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
/*
|
||||
* Below the limit => Above the limit:
|
||||
* We have to scale the subscription up from the seat minimum to the newly assigned seat total.
|
||||
*/
|
||||
else if (providerCurrentlyAssignedSeatTotal <= providerSeatMinimum &&
|
||||
providerNewlyAssignedSeatTotal > providerSeatMinimum)
|
||||
{
|
||||
await update(
|
||||
providerSeatMinimum,
|
||||
providerNewlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Above the limit:
|
||||
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total.
|
||||
*/
|
||||
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum &&
|
||||
providerNewlyAssignedSeatTotal > providerSeatMinimum)
|
||||
{
|
||||
await update(
|
||||
providerCurrentlyAssignedSeatTotal,
|
||||
providerNewlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Below the limit:
|
||||
* We have to scale the subscription down from the currently assigned seat total to the seat minimum.
|
||||
*/
|
||||
else if (providerCurrentlyAssignedSeatTotal > providerSeatMinimum &&
|
||||
providerNewlyAssignedSeatTotal <= providerSeatMinimum)
|
||||
{
|
||||
await update(
|
||||
providerCurrentlyAssignedSeatTotal,
|
||||
providerSeatMinimum);
|
||||
}
|
||||
}
|
||||
|
||||
// ReSharper disable once SuggestBaseTypeForParameter
|
||||
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, Organization organization)
|
||||
{
|
||||
if (!organization.PlanType.SupportsConsolidatedBilling())
|
||||
{
|
||||
logger.LogError("Cannot assign seats to a client organization ({ID}) with a plan type that does not support consolidated billing: {PlanType}", organization.Id, organization.PlanType);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == organization.PlanType);
|
||||
|
||||
if (providerPlan != null && providerPlan.IsConfigured())
|
||||
{
|
||||
return providerPlan;
|
||||
}
|
||||
|
||||
logger.LogError("Cannot assign seats to client organization ({ClientOrganizationID}) when provider's ({ProviderID}) matching plan is not configured", organization.Id, provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurryUpdateFunction(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
Organization organization,
|
||||
int organizationNewlyAssignedSeats,
|
||||
int providerNewlyAssignedSeats) => async (providerCurrentlySubscribedSeats, providerNewlySubscribedSeats) =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
|
||||
await paymentService.AdjustSeats(
|
||||
provider,
|
||||
plan,
|
||||
providerCurrentlySubscribedSeats,
|
||||
providerNewlySubscribedSeats);
|
||||
|
||||
organization.Seats = organizationNewlyAssignedSeats;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
var providerNewlyPurchasedSeats = providerNewlySubscribedSeats > providerPlan.SeatMinimum
|
||||
? providerNewlySubscribedSeats - providerPlan.SeatMinimum
|
||||
: 0;
|
||||
|
||||
providerPlan.PurchasedSeats = providerNewlyPurchasedSeats;
|
||||
providerPlan.AllocatedSeats = providerNewlyAssignedSeats;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
};
|
||||
}
|
@ -1,55 +1,41 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Commands.Implementations;
|
||||
|
||||
public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
|
||||
public class RemovePaymentMethodCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
ILogger<RemovePaymentMethodCommand> logger,
|
||||
IStripeAdapter stripeAdapter)
|
||||
: IRemovePaymentMethodCommand
|
||||
{
|
||||
private readonly IBraintreeGateway _braintreeGateway;
|
||||
private readonly ILogger<RemovePaymentMethodCommand> _logger;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
|
||||
public RemovePaymentMethodCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
ILogger<RemovePaymentMethodCommand> logger,
|
||||
IStripeAdapter stripeAdapter)
|
||||
{
|
||||
_braintreeGateway = braintreeGateway;
|
||||
_logger = logger;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
}
|
||||
|
||||
public async Task RemovePaymentMethod(Organization organization)
|
||||
{
|
||||
const string braintreeCustomerIdKey = "btCustomerId";
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(organization));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
|
||||
if (organization.Gateway is not GatewayType.Stripe || string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var stripeCustomer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions
|
||||
var stripeCustomer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions
|
||||
{
|
||||
Expand = new List<string> { "invoice_settings.default_payment_method", "sources" }
|
||||
Expand = ["invoice_settings.default_payment_method", "sources"]
|
||||
});
|
||||
|
||||
if (stripeCustomer == null)
|
||||
{
|
||||
_logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId);
|
||||
logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
if (stripeCustomer.Metadata?.TryGetValue(braintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
|
||||
if (stripeCustomer.Metadata?.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
|
||||
{
|
||||
await RemoveBraintreePaymentMethodAsync(braintreeCustomerId);
|
||||
}
|
||||
@ -61,11 +47,11 @@ public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
|
||||
|
||||
private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId)
|
||||
{
|
||||
var customer = await _braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||
var customer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
_logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
|
||||
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
@ -74,27 +60,27 @@ public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
|
||||
{
|
||||
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;
|
||||
|
||||
var updateCustomerResult = await _braintreeGateway.Customer.UpdateAsync(
|
||||
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
new CustomerRequest { DefaultPaymentMethodToken = null });
|
||||
|
||||
if (!updateCustomerResult.IsSuccess())
|
||||
{
|
||||
_logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
|
||||
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
|
||||
braintreeCustomerId, updateCustomerResult.Message);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var deletePaymentMethodResult = await _braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
|
||||
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
|
||||
|
||||
if (!deletePaymentMethodResult.IsSuccess())
|
||||
{
|
||||
await _braintreeGateway.Customer.UpdateAsync(
|
||||
await braintreeGateway.Customer.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token });
|
||||
|
||||
_logger.LogError(
|
||||
logger.LogError(
|
||||
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
|
||||
braintreeCustomerId, deletePaymentMethodResult.Message);
|
||||
|
||||
@ -103,7 +89,7 @@ public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
|
||||
logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,25 +102,23 @@ public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
|
||||
switch (source)
|
||||
{
|
||||
case Stripe.BankAccount:
|
||||
await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
|
||||
await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
|
||||
break;
|
||||
case Stripe.Card:
|
||||
await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
|
||||
await stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paymentMethods = _stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions
|
||||
var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions
|
||||
{
|
||||
Customer = customer.Id
|
||||
});
|
||||
|
||||
await foreach (var paymentMethod in paymentMethods)
|
||||
{
|
||||
await _stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions());
|
||||
await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions());
|
||||
}
|
||||
}
|
||||
|
||||
private static GatewayException ContactSupport() => new("Could not remove your payment method. Please contact support for assistance.");
|
||||
}
|
||||
|
@ -20,4 +20,6 @@ public class ProviderPlan : ITableObject<Guid>
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConfigured() => SeatMinimum.HasValue && PurchasedSeats.HasValue && AllocatedSeats.HasValue;
|
||||
}
|
||||
|
9
src/Core/Billing/Extensions/BillingExtensions.cs
Normal file
9
src/Core/Billing/Extensions/BillingExtensions.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class BillingExtensions
|
||||
{
|
||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
||||
}
|
@ -9,14 +9,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddBillingCommands(this IServiceCollection services)
|
||||
public static void AddBillingOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
|
||||
services.AddSingleton<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
|
||||
}
|
||||
// Queries
|
||||
services.AddTransient<IProviderBillingQueries, ProviderBillingQueries>();
|
||||
services.AddTransient<ISubscriberQueries, SubscriberQueries>();
|
||||
|
||||
public static void AddBillingQueries(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IGetSubscriptionQuery, GetSubscriptionQuery>();
|
||||
// Commands
|
||||
services.AddTransient<IAssignSeatsToClientOrganizationCommand, AssignSeatsToClientOrganizationCommand>();
|
||||
services.AddTransient<ICancelSubscriptionCommand, CancelSubscriptionCommand>();
|
||||
services.AddTransient<IRemovePaymentMethodCommand, RemovePaymentMethodCommand>();
|
||||
}
|
||||
}
|
||||
|
24
src/Core/Billing/Models/ConfiguredProviderPlan.cs
Normal file
24
src/Core/Billing/Models/ConfiguredProviderPlan.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ConfiguredProviderPlan(
|
||||
Guid Id,
|
||||
Guid ProviderId,
|
||||
PlanType PlanType,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats)
|
||||
{
|
||||
public static ConfiguredProviderPlan From(ProviderPlan providerPlan) =>
|
||||
providerPlan.IsConfigured()
|
||||
? new ConfiguredProviderPlan(
|
||||
providerPlan.Id,
|
||||
providerPlan.ProviderId,
|
||||
providerPlan.PlanType,
|
||||
providerPlan.SeatMinimum.GetValueOrDefault(0),
|
||||
providerPlan.PurchasedSeats.GetValueOrDefault(0),
|
||||
providerPlan.AllocatedSeats.GetValueOrDefault(0))
|
||||
: null;
|
||||
}
|
7
src/Core/Billing/Models/ProviderSubscriptionData.cs
Normal file
7
src/Core/Billing/Models/ProviderSubscriptionData.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ProviderSubscriptionData(
|
||||
List<ConfiguredProviderPlan> ProviderPlans,
|
||||
Subscription Subscription);
|
@ -1,18 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Queries;
|
||||
|
||||
public interface IGetSubscriptionQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The organization or user to retrieve the subscription for.</param>
|
||||
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the subscriber's <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the <see cref="Subscription"/> returned from Stripe's API is null.</exception>
|
||||
Task<Subscription> GetSubscription(ISubscriber subscriber);
|
||||
}
|
27
src/Core/Billing/Queries/IProviderBillingQueries.cs
Normal file
27
src/Core/Billing/Queries/IProviderBillingQueries.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Queries;
|
||||
|
||||
public interface IProviderBillingQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the number of seats an MSP has assigned to its client organizations with a specified <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the MSP to retrieve the assigned seat total for.</param>
|
||||
/// <param name="planType">The type of plan to retrieve the assigned seat total for.</param>
|
||||
/// <returns>An <see cref="int"/> representing the number of seats the provider has assigned to its client organizations with the specified <paramref name="planType"/>.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provider represented by the <paramref name="providerId"/> has <see cref="Provider.Type"/> <see cref="ProviderType.Reseller"/>.</exception>
|
||||
Task<int> GetAssignedSeatTotalForPlanOrThrow(Guid providerId, PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a provider's billing subscription data.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the provider to retrieve subscription data for.</param>
|
||||
/// <returns>A <see cref="ProviderSubscriptionData"/> object containing the provider's Stripe <see cref="Stripe.Subscription"/> and their <see cref="ConfiguredProviderPlan"/>s.</returns>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<ProviderSubscriptionData> GetSubscriptionData(Guid providerId);
|
||||
}
|
30
src/Core/Billing/Queries/ISubscriberQueries.cs
Normal file
30
src/Core/Billing/Queries/ISubscriberQueries.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Queries;
|
||||
|
||||
public interface ISubscriberQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The organization, provider or user to retrieve the subscription for.</param>
|
||||
/// <param name="subscriptionGetOptions">Optional parameters that can be passed to Stripe to expand or modify the <see cref="Subscription"/>.</param>
|
||||
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<Subscription> GetSubscription(
|
||||
ISubscriber subscriber,
|
||||
SubscriptionGetOptions subscriptionGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The organization or user to retrieve the subscription for.</param>
|
||||
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the subscriber's <see cref="ISubscriber.GatewaySubscriptionId"/> is <see langword="null"/> or empty.</exception>
|
||||
/// <exception cref="GatewayException">Thrown when the <see cref="Subscription"/> returned from Stripe's API is null.</exception>
|
||||
Task<Subscription> GetSubscriptionOrThrow(ISubscriber subscriber);
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Queries.Implementations;
|
||||
|
||||
public class GetSubscriptionQuery(
|
||||
ILogger<GetSubscriptionQuery> logger,
|
||||
IStripeAdapter stripeAdapter) : IGetSubscriptionQuery
|
||||
{
|
||||
public async Task<Subscription> GetSubscription(ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
logger.LogError("Cannot cancel subscription for subscriber ({ID}) with no GatewaySubscriptionId.", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
||||
|
||||
if (subscription != null)
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
logger.LogError("Could not find Stripe subscription ({ID}) to cancel.", subscriber.GatewaySubscriptionId);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Queries.Implementations;
|
||||
|
||||
public class ProviderBillingQueries(
|
||||
ILogger<ProviderBillingQueries> logger,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISubscriberQueries subscriberQueries) : IProviderBillingQueries
|
||||
{
|
||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
||||
providerId);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
}
|
||||
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
|
||||
return providerOrganizations
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name)
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
public async Task<ProviderSubscriptionData> GetSubscriptionData(Guid providerId)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Could not find provider ({ID}) when retrieving subscription data.",
|
||||
providerId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Subscription data cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
}
|
||||
|
||||
var subscription = await subscriberQueries.GetSubscription(provider, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(providerId);
|
||||
|
||||
var configuredProviderPlans = providerPlans
|
||||
.Where(providerPlan => providerPlan.IsConfigured())
|
||||
.Select(ConfiguredProviderPlan.From)
|
||||
.ToList();
|
||||
|
||||
return new ProviderSubscriptionData(
|
||||
configuredProviderPlans,
|
||||
subscription);
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Queries.Implementations;
|
||||
|
||||
public class SubscriberQueries(
|
||||
ILogger<SubscriberQueries> logger,
|
||||
IStripeAdapter stripeAdapter) : ISubscriberQueries
|
||||
{
|
||||
public async Task<Subscription> GetSubscription(
|
||||
ISubscriber subscriber,
|
||||
SubscriptionGetOptions subscriptionGetOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
logger.LogError("Cannot cancel subscription for subscriber ({ID}) with no GatewaySubscriptionId.", subscriber.Id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
|
||||
|
||||
if (subscription != null)
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
logger.LogError("Could not find Stripe subscription ({ID}) to cancel.", subscriber.GatewaySubscriptionId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<Subscription> GetSubscriptionOrThrow(ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
|
||||
{
|
||||
logger.LogError("Cannot cancel subscription for subscriber ({ID}) with no GatewaySubscriptionId.", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
||||
|
||||
if (subscription != null)
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
logger.LogError("Could not find Stripe subscription ({ID}) to cancel.", subscriber.GatewaySubscriptionId);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
}
|
@ -5,5 +5,5 @@ namespace Bit.Core.Billing.Repositories;
|
||||
|
||||
public interface IProviderPlanRepository : IRepository<ProviderPlan, Guid>
|
||||
{
|
||||
Task<ProviderPlan> GetByProviderId(Guid providerId);
|
||||
Task<ICollection<ProviderPlan>> GetByProviderId(Guid providerId);
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Billing;
|
||||
namespace Bit.Core.Billing;
|
||||
|
||||
public static class Utilities
|
||||
{
|
||||
public static GatewayException ContactSupport() => new("Something went wrong with your request. Please contact support.");
|
||||
public const string BraintreeCustomerIdKey = "btCustomerId";
|
||||
|
||||
public static BillingException ContactSupport(
|
||||
string internalMessage = null,
|
||||
Exception innerException = null) => new("Something went wrong with your request. Please contact support.",
|
||||
internalMessage, innerException);
|
||||
}
|
||||
|
@ -114,7 +114,6 @@ public static class FeatureFlagKeys
|
||||
/// </summary>
|
||||
public const string FlexibleCollections = "flexible-collections-disabled-do-not-use";
|
||||
public const string FlexibleCollectionsV1 = "flexible-collections-v-1"; // v-1 is intentional
|
||||
public const string BulkCollectionAccess = "bulk-collection-access";
|
||||
public const string ItemShare = "item-share";
|
||||
public const string KeyRotationImprovements = "key-rotation-improvements";
|
||||
public const string DuoRedirect = "duo-redirect";
|
||||
@ -131,6 +130,8 @@ public static class FeatureFlagKeys
|
||||
public const string PM5864DollarThreshold = "PM-5864-dollar-threshold";
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners";
|
||||
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
@ -21,8 +21,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.59" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.59" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.63" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.300.63" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.8.3" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.2" />
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.15.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.14.1" />
|
||||
@ -35,9 +36,8 @@
|
||||
<PackageReference Include="MailKit" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.25" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.38.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos.Table" Version="1.0.8" />
|
||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.5" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
@ -50,10 +50,10 @@
|
||||
<PackageReference Include="Sentry.Serilog" Version="3.41.4" />
|
||||
<PackageReference Include="Duende.IdentityServer" Version="6.3.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
||||
<PackageReference Include="Braintree" Version="5.23.0" />
|
||||
<PackageReference Include="Stripe.net" Version="43.7.0" />
|
||||
<PackageReference Include="Stripe.net" Version="43.20.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.25" />
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Stripe;
|
||||
|
||||
@ -279,25 +278,6 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
|
||||
};
|
||||
}
|
||||
|
||||
private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(planId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = subscription.Items.Data;
|
||||
|
||||
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
|
||||
|
||||
return subscriptionItem;
|
||||
}
|
||||
|
||||
private static string GetPasswordManagerPlanId(StaticStore.Plan plan)
|
||||
=> IsNonSeatBasedPlan(plan)
|
||||
? plan.PasswordManager.StripePlanId
|
||||
: plan.PasswordManager.StripeSeatPlanId;
|
||||
|
||||
private static SubscriptionData GetSubscriptionDataFor(Organization organization)
|
||||
{
|
||||
var plan = Utilities.StaticStore.GetPlan(organization.PlanType);
|
||||
@ -320,10 +300,4 @@ public class CompleteSubscriptionUpdate : SubscriptionUpdate
|
||||
0
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
||||
=> plan.Type is
|
||||
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
||||
or PlanType.FamiliesAnnually
|
||||
or PlanType.TeamsStarter;
|
||||
}
|
||||
|
61
src/Core/Models/Business/ProviderSubscriptionUpdate.cs
Normal file
61
src/Core/Models/Business/ProviderSubscriptionUpdate.cs
Normal file
@ -0,0 +1,61 @@
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Core.Models.Business;
|
||||
|
||||
public class ProviderSubscriptionUpdate : SubscriptionUpdate
|
||||
{
|
||||
private readonly string _planId;
|
||||
private readonly int _previouslyPurchasedSeats;
|
||||
private readonly int _newlyPurchasedSeats;
|
||||
|
||||
protected override List<string> PlanIds => [_planId];
|
||||
|
||||
public ProviderSubscriptionUpdate(
|
||||
PlanType planType,
|
||||
int previouslyPurchasedSeats,
|
||||
int newlyPurchasedSeats)
|
||||
{
|
||||
if (!planType.SupportsConsolidatedBilling())
|
||||
{
|
||||
throw ContactSupport($"Cannot create a {nameof(ProviderSubscriptionUpdate)} for {nameof(PlanType)} that doesn't support consolidated billing");
|
||||
}
|
||||
|
||||
_planId = GetPasswordManagerPlanId(Utilities.StaticStore.GetPlan(planType));
|
||||
_previouslyPurchasedSeats = previouslyPurchasedSeats;
|
||||
_newlyPurchasedSeats = newlyPurchasedSeats;
|
||||
}
|
||||
|
||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
||||
{
|
||||
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
|
||||
|
||||
return
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = _planId,
|
||||
Quantity = _previouslyPurchasedSeats
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var subscriptionItem = FindSubscriptionItem(subscription, _planId);
|
||||
|
||||
return
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = _planId,
|
||||
Quantity = _newlyPurchasedSeats
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
@ -18,7 +18,7 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
|
||||
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
@ -34,7 +34,7 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
|
||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
||||
{
|
||||
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
|
@ -19,7 +19,7 @@ public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
|
||||
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
_prevServiceAccounts = item?.Quantity ?? 0;
|
||||
return new()
|
||||
{
|
||||
@ -35,7 +35,7 @@ public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
|
||||
|
||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
||||
{
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
|
@ -19,7 +19,7 @@ public class SmSeatSubscriptionUpdate : SubscriptionUpdate
|
||||
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
@ -35,7 +35,7 @@ public class SmSeatSubscriptionUpdate : SubscriptionUpdate
|
||||
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
|
||||
{
|
||||
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
|
@ -74,10 +74,10 @@ public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate
|
||||
private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId;
|
||||
private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) =>
|
||||
_applySponsorship ?
|
||||
SubscriptionItem(subscription, _existingPlanStripeId) :
|
||||
SubscriptionItem(subscription, _sponsoredPlanStripeId);
|
||||
FindSubscriptionItem(subscription, _existingPlanStripeId) :
|
||||
FindSubscriptionItem(subscription, _sponsoredPlanStripeId);
|
||||
private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) =>
|
||||
_applySponsorship ?
|
||||
SubscriptionItem(subscription, _sponsoredPlanStripeId) :
|
||||
SubscriptionItem(subscription, _existingPlanStripeId);
|
||||
FindSubscriptionItem(subscription, _sponsoredPlanStripeId) :
|
||||
FindSubscriptionItem(subscription, _existingPlanStripeId);
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ public class StorageSubscriptionUpdate : SubscriptionUpdate
|
||||
|
||||
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
|
||||
{
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
_prevStorage = item?.Quantity ?? 0;
|
||||
return new()
|
||||
{
|
||||
@ -38,7 +38,7 @@ public class StorageSubscriptionUpdate : SubscriptionUpdate
|
||||
throw new Exception("Unknown previous value, must first call UpgradeItemsOptions");
|
||||
}
|
||||
|
||||
var item = SubscriptionItem(subscription, PlanIds.Single());
|
||||
var item = FindSubscriptionItem(subscription, PlanIds.Single());
|
||||
return new()
|
||||
{
|
||||
new SubscriptionItemOptions
|
||||
|
@ -43,6 +43,9 @@ public class SubscriptionInfo
|
||||
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
|
||||
}
|
||||
CollectionMethod = sub.CollectionMethod;
|
||||
GracePeriod = sub.CollectionMethod == "charge_automatically"
|
||||
? 14
|
||||
: 30;
|
||||
}
|
||||
|
||||
public DateTime? TrialStartDate { get; set; }
|
||||
@ -56,6 +59,9 @@ public class SubscriptionInfo
|
||||
public bool Cancelled { get; set; }
|
||||
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
||||
public string CollectionMethod { get; set; }
|
||||
public DateTime? SuspensionDate { get; set; }
|
||||
public DateTime? UnpaidPeriodEndDate { get; set; }
|
||||
public int GracePeriod { get; set; }
|
||||
|
||||
public class BillingSubscriptionItem
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Stripe;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Models.Business;
|
||||
|
||||
@ -15,7 +16,7 @@ public abstract class SubscriptionUpdate
|
||||
foreach (var upgradeItemOptions in upgradeItemsOptions)
|
||||
{
|
||||
var upgradeQuantity = upgradeItemOptions.Quantity ?? 0;
|
||||
var existingQuantity = SubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0;
|
||||
var existingQuantity = FindSubscriptionItem(subscription, upgradeItemOptions.Plan)?.Quantity ?? 0;
|
||||
if (upgradeQuantity != existingQuantity)
|
||||
{
|
||||
return true;
|
||||
@ -24,6 +25,28 @@ public abstract class SubscriptionUpdate
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static SubscriptionItem SubscriptionItem(Subscription subscription, string planId) =>
|
||||
planId == null ? null : subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == planId);
|
||||
protected static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(planId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = subscription.Items.Data;
|
||||
|
||||
var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId);
|
||||
|
||||
return subscriptionItem;
|
||||
}
|
||||
|
||||
protected static string GetPasswordManagerPlanId(StaticStore.Plan plan)
|
||||
=> IsNonSeatBasedPlan(plan)
|
||||
? plan.PasswordManager.StripePlanId
|
||||
: plan.PasswordManager.StripeSeatPlanId;
|
||||
|
||||
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
||||
=> plan.Type is
|
||||
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
||||
or PlanType.FamiliesAnnually
|
||||
or PlanType.TeamsStarter;
|
||||
}
|
||||
|
@ -1,134 +0,0 @@
|
||||
using System.Collections;
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
|
||||
namespace Bit.Core.Models.Data;
|
||||
|
||||
public class DictionaryEntity : TableEntity, IDictionary<string, EntityProperty>
|
||||
{
|
||||
private IDictionary<string, EntityProperty> _properties = new Dictionary<string, EntityProperty>();
|
||||
|
||||
public ICollection<EntityProperty> Values => _properties.Values;
|
||||
|
||||
public EntityProperty this[string key]
|
||||
{
|
||||
get => _properties[key];
|
||||
set => _properties[key] = value;
|
||||
}
|
||||
|
||||
public int Count => _properties.Count;
|
||||
|
||||
public bool IsReadOnly => _properties.IsReadOnly;
|
||||
|
||||
public ICollection<string> Keys => _properties.Keys;
|
||||
|
||||
public override void ReadEntity(IDictionary<string, EntityProperty> properties,
|
||||
OperationContext operationContext)
|
||||
{
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
public override IDictionary<string, EntityProperty> WriteEntity(OperationContext operationContext)
|
||||
{
|
||||
return _properties;
|
||||
}
|
||||
|
||||
public void Add(string key, EntityProperty value)
|
||||
{
|
||||
_properties.Add(key, value);
|
||||
}
|
||||
|
||||
public void Add(string key, bool value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, byte[] value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, DateTime? value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, DateTimeOffset? value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, double value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, Guid value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, int value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, long value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(string key, string value)
|
||||
{
|
||||
_properties.Add(key, new EntityProperty(value));
|
||||
}
|
||||
|
||||
public void Add(KeyValuePair<string, EntityProperty> item)
|
||||
{
|
||||
_properties.Add(item);
|
||||
}
|
||||
|
||||
public bool ContainsKey(string key)
|
||||
{
|
||||
return _properties.ContainsKey(key);
|
||||
}
|
||||
|
||||
public bool Remove(string key)
|
||||
{
|
||||
return _properties.Remove(key);
|
||||
}
|
||||
|
||||
public bool TryGetValue(string key, out EntityProperty value)
|
||||
{
|
||||
return _properties.TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_properties.Clear();
|
||||
}
|
||||
|
||||
public bool Contains(KeyValuePair<string, EntityProperty> item)
|
||||
{
|
||||
return _properties.Contains(item);
|
||||
}
|
||||
|
||||
public void CopyTo(KeyValuePair<string, EntityProperty>[] array, int arrayIndex)
|
||||
{
|
||||
_properties.CopyTo(array, arrayIndex);
|
||||
}
|
||||
|
||||
public bool Remove(KeyValuePair<string, EntityProperty> item)
|
||||
{
|
||||
return _properties.Remove(item);
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<string, EntityProperty>> GetEnumerator()
|
||||
{
|
||||
return _properties.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return _properties.GetEnumerator();
|
||||
}
|
||||
}
|
@ -1,10 +1,73 @@
|
||||
using Bit.Core.Enums;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
|
||||
namespace Bit.Core.Models.Data;
|
||||
|
||||
public class EventTableEntity : TableEntity, IEvent
|
||||
// used solely for interaction with Azure Table Storage
|
||||
public class AzureEvent : ITableEntity
|
||||
{
|
||||
public string PartitionKey { get; set; }
|
||||
public string RowKey { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public ETag ETag { get; set; }
|
||||
|
||||
public DateTime Date { get; set; }
|
||||
public int Type { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public Guid? InstallationId { get; set; }
|
||||
public Guid? ProviderId { get; set; }
|
||||
public Guid? CipherId { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? PolicyId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public Guid? OrganizationUserId { get; set; }
|
||||
public Guid? ProviderUserId { get; set; }
|
||||
public Guid? ProviderOrganizationId { get; set; }
|
||||
public int? DeviceType { get; set; }
|
||||
public string IpAddress { get; set; }
|
||||
public Guid? ActingUserId { get; set; }
|
||||
public int? SystemUser { get; set; }
|
||||
public string DomainName { get; set; }
|
||||
public Guid? SecretId { get; set; }
|
||||
public Guid? ServiceAccountId { get; set; }
|
||||
|
||||
public EventTableEntity ToEventTableEntity()
|
||||
{
|
||||
return new EventTableEntity
|
||||
{
|
||||
PartitionKey = PartitionKey,
|
||||
RowKey = RowKey,
|
||||
Timestamp = Timestamp,
|
||||
ETag = ETag,
|
||||
|
||||
Date = Date,
|
||||
Type = (EventType)Type,
|
||||
UserId = UserId,
|
||||
OrganizationId = OrganizationId,
|
||||
InstallationId = InstallationId,
|
||||
ProviderId = ProviderId,
|
||||
CipherId = CipherId,
|
||||
CollectionId = CollectionId,
|
||||
PolicyId = PolicyId,
|
||||
GroupId = GroupId,
|
||||
OrganizationUserId = OrganizationUserId,
|
||||
ProviderUserId = ProviderUserId,
|
||||
ProviderOrganizationId = ProviderOrganizationId,
|
||||
DeviceType = DeviceType.HasValue ? (DeviceType)DeviceType.Value : null,
|
||||
IpAddress = IpAddress,
|
||||
ActingUserId = ActingUserId,
|
||||
SystemUser = SystemUser.HasValue ? (EventSystemUser)SystemUser.Value : null,
|
||||
DomainName = DomainName,
|
||||
SecretId = SecretId,
|
||||
ServiceAccountId = ServiceAccountId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class EventTableEntity : IEvent
|
||||
{
|
||||
public EventTableEntity() { }
|
||||
|
||||
@ -32,6 +95,11 @@ public class EventTableEntity : TableEntity, IEvent
|
||||
ServiceAccountId = e.ServiceAccountId;
|
||||
}
|
||||
|
||||
public string PartitionKey { get; set; }
|
||||
public string RowKey { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public ETag ETag { get; set; }
|
||||
|
||||
public DateTime Date { get; set; }
|
||||
public EventType Type { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
@ -53,65 +121,36 @@ public class EventTableEntity : TableEntity, IEvent
|
||||
public Guid? SecretId { get; set; }
|
||||
public Guid? ServiceAccountId { get; set; }
|
||||
|
||||
public override IDictionary<string, EntityProperty> WriteEntity(OperationContext operationContext)
|
||||
public AzureEvent ToAzureEvent()
|
||||
{
|
||||
var result = base.WriteEntity(operationContext);
|
||||
return new AzureEvent
|
||||
{
|
||||
PartitionKey = PartitionKey,
|
||||
RowKey = RowKey,
|
||||
Timestamp = Timestamp,
|
||||
ETag = ETag,
|
||||
|
||||
var typeName = nameof(Type);
|
||||
if (result.ContainsKey(typeName))
|
||||
{
|
||||
result[typeName] = new EntityProperty((int)Type);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(typeName, new EntityProperty((int)Type));
|
||||
}
|
||||
|
||||
var deviceTypeName = nameof(DeviceType);
|
||||
if (result.ContainsKey(deviceTypeName))
|
||||
{
|
||||
result[deviceTypeName] = new EntityProperty((int?)DeviceType);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(deviceTypeName, new EntityProperty((int?)DeviceType));
|
||||
}
|
||||
|
||||
var systemUserTypeName = nameof(SystemUser);
|
||||
if (result.ContainsKey(systemUserTypeName))
|
||||
{
|
||||
result[systemUserTypeName] = new EntityProperty((int?)SystemUser);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(systemUserTypeName, new EntityProperty((int?)SystemUser));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void ReadEntity(IDictionary<string, EntityProperty> properties,
|
||||
OperationContext operationContext)
|
||||
{
|
||||
base.ReadEntity(properties, operationContext);
|
||||
|
||||
var typeName = nameof(Type);
|
||||
if (properties.ContainsKey(typeName) && properties[typeName].Int32Value.HasValue)
|
||||
{
|
||||
Type = (EventType)properties[typeName].Int32Value.Value;
|
||||
}
|
||||
|
||||
var deviceTypeName = nameof(DeviceType);
|
||||
if (properties.ContainsKey(deviceTypeName) && properties[deviceTypeName].Int32Value.HasValue)
|
||||
{
|
||||
DeviceType = (DeviceType)properties[deviceTypeName].Int32Value.Value;
|
||||
}
|
||||
|
||||
var systemUserTypeName = nameof(SystemUser);
|
||||
if (properties.ContainsKey(systemUserTypeName) && properties[systemUserTypeName].Int32Value.HasValue)
|
||||
{
|
||||
SystemUser = (EventSystemUser)properties[systemUserTypeName].Int32Value.Value;
|
||||
}
|
||||
Date = Date,
|
||||
Type = (int)Type,
|
||||
UserId = UserId,
|
||||
OrganizationId = OrganizationId,
|
||||
InstallationId = InstallationId,
|
||||
ProviderId = ProviderId,
|
||||
CipherId = CipherId,
|
||||
CollectionId = CollectionId,
|
||||
PolicyId = PolicyId,
|
||||
GroupId = GroupId,
|
||||
OrganizationUserId = OrganizationUserId,
|
||||
ProviderUserId = ProviderUserId,
|
||||
ProviderOrganizationId = ProviderOrganizationId,
|
||||
DeviceType = DeviceType.HasValue ? (int)DeviceType.Value : null,
|
||||
IpAddress = IpAddress,
|
||||
ActingUserId = ActingUserId,
|
||||
SystemUser = SystemUser.HasValue ? (int)SystemUser.Value : null,
|
||||
DomainName = DomainName,
|
||||
SecretId = SecretId,
|
||||
ServiceAccountId = ServiceAccountId
|
||||
};
|
||||
}
|
||||
|
||||
public static List<EventTableEntity> IndexEvent(EventMessage e)
|
||||
|
@ -1,8 +1,9 @@
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace Bit.Core.Models.Data;
|
||||
|
||||
public class InstallationDeviceEntity : TableEntity
|
||||
public class InstallationDeviceEntity : ITableEntity
|
||||
{
|
||||
public InstallationDeviceEntity() { }
|
||||
|
||||
@ -27,6 +28,11 @@ public class InstallationDeviceEntity : TableEntity
|
||||
RowKey = parts[1];
|
||||
}
|
||||
|
||||
public string PartitionKey { get; set; }
|
||||
public string RowKey { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public ETag ETag { get; set; }
|
||||
|
||||
public static bool IsInstallationDeviceId(string deviceId)
|
||||
{
|
||||
return deviceId != null && deviceId.Length == 73 && deviceId[36] == '_';
|
||||
|
@ -1,14 +1,14 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Azure.Data.Tables;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
|
||||
namespace Bit.Core.Repositories.TableStorage;
|
||||
|
||||
public class EventRepository : IEventRepository
|
||||
{
|
||||
private readonly CloudTable _table;
|
||||
private readonly TableClient _tableClient;
|
||||
|
||||
public EventRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.Events.ConnectionString)
|
||||
@ -16,9 +16,8 @@ public class EventRepository : IEventRepository
|
||||
|
||||
public EventRepository(string storageConnectionString)
|
||||
{
|
||||
var storageAccount = CloudStorageAccount.Parse(storageConnectionString);
|
||||
var tableClient = storageAccount.CreateCloudTableClient();
|
||||
_table = tableClient.GetTableReference("event");
|
||||
var tableClient = new TableServiceClient(storageConnectionString);
|
||||
_tableClient = tableClient.GetTableClient("event");
|
||||
}
|
||||
|
||||
public async Task<PagedResult<IEvent>> GetManyByUserAsync(Guid userId, DateTime startDate, DateTime endDate,
|
||||
@ -76,7 +75,7 @@ public class EventRepository : IEventRepository
|
||||
throw new ArgumentException(nameof(e));
|
||||
}
|
||||
|
||||
await CreateEntityAsync(entity);
|
||||
await CreateEventAsync(entity);
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> e)
|
||||
@ -99,7 +98,7 @@ public class EventRepository : IEventRepository
|
||||
var groupEntities = group.ToList();
|
||||
if (groupEntities.Count == 1)
|
||||
{
|
||||
await CreateEntityAsync(groupEntities.First());
|
||||
await CreateEventAsync(groupEntities.First());
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -107,7 +106,7 @@ public class EventRepository : IEventRepository
|
||||
var iterations = groupEntities.Count / 100;
|
||||
for (var i = 0; i <= iterations; i++)
|
||||
{
|
||||
var batch = new TableBatchOperation();
|
||||
var batch = new List<TableTransactionAction>();
|
||||
var batchEntities = groupEntities.Skip(i * 100).Take(100);
|
||||
if (!batchEntities.Any())
|
||||
{
|
||||
@ -116,19 +115,15 @@ public class EventRepository : IEventRepository
|
||||
|
||||
foreach (var entity in batchEntities)
|
||||
{
|
||||
batch.InsertOrReplace(entity);
|
||||
batch.Add(new TableTransactionAction(TableTransactionActionType.Add,
|
||||
entity.ToAzureEvent()));
|
||||
}
|
||||
|
||||
await _table.ExecuteBatchAsync(batch);
|
||||
await _tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateEntityAsync(ITableEntity entity)
|
||||
{
|
||||
await _table.ExecuteAsync(TableOperation.InsertOrReplace(entity));
|
||||
}
|
||||
|
||||
public async Task<PagedResult<IEvent>> GetManyAsync(string partitionKey, string rowKey,
|
||||
DateTime startDate, DateTime endDate, PageOptions pageOptions)
|
||||
{
|
||||
@ -136,60 +131,28 @@ public class EventRepository : IEventRepository
|
||||
var end = CoreHelpers.DateTimeToTableStorageKey(endDate);
|
||||
var filter = MakeFilter(partitionKey, string.Format(rowKey, start), string.Format(rowKey, end));
|
||||
|
||||
var query = new TableQuery<EventTableEntity>().Where(filter).Take(pageOptions.PageSize);
|
||||
var result = new PagedResult<IEvent>();
|
||||
var continuationToken = DeserializeContinuationToken(pageOptions?.ContinuationToken);
|
||||
var query = _tableClient.QueryAsync<AzureEvent>(filter, pageOptions.PageSize);
|
||||
|
||||
var queryResults = await _table.ExecuteQuerySegmentedAsync(query, continuationToken);
|
||||
result.ContinuationToken = SerializeContinuationToken(queryResults.ContinuationToken);
|
||||
result.Data.AddRange(queryResults.Results);
|
||||
await using (var enumerator = query.AsPages(pageOptions?.ContinuationToken,
|
||||
pageOptions.PageSize).GetAsyncEnumerator())
|
||||
{
|
||||
await enumerator.MoveNextAsync();
|
||||
|
||||
result.ContinuationToken = enumerator.Current.ContinuationToken;
|
||||
result.Data.AddRange(enumerator.Current.Values.Select(e => e.ToEventTableEntity()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task CreateEventAsync(EventTableEntity entity)
|
||||
{
|
||||
await _tableClient.UpsertEntityAsync(entity.ToAzureEvent());
|
||||
}
|
||||
|
||||
private string MakeFilter(string partitionKey, string rowStart, string rowEnd)
|
||||
{
|
||||
var rowFilter = TableQuery.CombineFilters(
|
||||
TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.LessThanOrEqual, $"{rowStart}`"),
|
||||
TableOperators.And,
|
||||
TableQuery.GenerateFilterCondition("RowKey", QueryComparisons.GreaterThanOrEqual, $"{rowEnd}_"));
|
||||
|
||||
return TableQuery.CombineFilters(
|
||||
TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, partitionKey),
|
||||
TableOperators.And,
|
||||
rowFilter);
|
||||
}
|
||||
|
||||
private string SerializeContinuationToken(TableContinuationToken token)
|
||||
{
|
||||
if (token == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Format("{0}__{1}__{2}__{3}", (int)token.TargetLocation, token.NextTableName,
|
||||
token.NextPartitionKey, token.NextRowKey);
|
||||
}
|
||||
|
||||
private TableContinuationToken DeserializeContinuationToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenParts = token.Split(new string[] { "__" }, StringSplitOptions.None);
|
||||
if (tokenParts.Length < 4 || !Enum.TryParse(tokenParts[0], out StorageLocation tLoc))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TableContinuationToken
|
||||
{
|
||||
TargetLocation = tLoc,
|
||||
NextTableName = string.IsNullOrWhiteSpace(tokenParts[1]) ? null : tokenParts[1],
|
||||
NextPartitionKey = string.IsNullOrWhiteSpace(tokenParts[2]) ? null : tokenParts[2],
|
||||
NextRowKey = string.IsNullOrWhiteSpace(tokenParts[3]) ? null : tokenParts[3]
|
||||
};
|
||||
return $"PartitionKey eq '{partitionKey}' and RowKey le '{rowStart}' and RowKey ge '{rowEnd}'";
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
using System.Net;
|
||||
using Azure.Data.Tables;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Azure.Cosmos.Table;
|
||||
|
||||
namespace Bit.Core.Repositories.TableStorage;
|
||||
|
||||
public class InstallationDeviceRepository : IInstallationDeviceRepository
|
||||
{
|
||||
private readonly CloudTable _table;
|
||||
private readonly TableClient _tableClient;
|
||||
|
||||
public InstallationDeviceRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.Events.ConnectionString)
|
||||
@ -15,14 +14,13 @@ public class InstallationDeviceRepository : IInstallationDeviceRepository
|
||||
|
||||
public InstallationDeviceRepository(string storageConnectionString)
|
||||
{
|
||||
var storageAccount = CloudStorageAccount.Parse(storageConnectionString);
|
||||
var tableClient = storageAccount.CreateCloudTableClient();
|
||||
_table = tableClient.GetTableReference("installationdevice");
|
||||
var tableClient = new TableServiceClient(storageConnectionString);
|
||||
_tableClient = tableClient.GetTableClient("installationdevice");
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(InstallationDeviceEntity entity)
|
||||
{
|
||||
await _table.ExecuteAsync(TableOperation.InsertOrReplace(entity));
|
||||
await _tableClient.UpsertEntityAsync(entity);
|
||||
}
|
||||
|
||||
public async Task UpsertManyAsync(IList<InstallationDeviceEntity> entities)
|
||||
@ -52,7 +50,7 @@ public class InstallationDeviceRepository : IInstallationDeviceRepository
|
||||
var iterations = groupEntities.Count / 100;
|
||||
for (var i = 0; i <= iterations; i++)
|
||||
{
|
||||
var batch = new TableBatchOperation();
|
||||
var batch = new List<TableTransactionAction>();
|
||||
var batchEntities = groupEntities.Skip(i * 100).Take(100);
|
||||
if (!batchEntities.Any())
|
||||
{
|
||||
@ -61,24 +59,16 @@ public class InstallationDeviceRepository : IInstallationDeviceRepository
|
||||
|
||||
foreach (var entity in batchEntities)
|
||||
{
|
||||
batch.InsertOrReplace(entity);
|
||||
batch.Add(new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity));
|
||||
}
|
||||
|
||||
await _table.ExecuteBatchAsync(batch);
|
||||
await _tableClient.SubmitTransactionAsync(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(InstallationDeviceEntity entity)
|
||||
{
|
||||
try
|
||||
{
|
||||
entity.ETag = "*";
|
||||
await _table.ExecuteAsync(TableOperation.Delete(entity));
|
||||
}
|
||||
catch (StorageException e) when (e.RequestInformation.HttpStatusCode != (int)HttpStatusCode.NotFound)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -28,6 +29,12 @@ public interface IPaymentService
|
||||
int newlyPurchasedAdditionalStorage,
|
||||
DateTime? prorationDate = null);
|
||||
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustSeats(
|
||||
Provider provider,
|
||||
Plan plan,
|
||||
int currentlySubscribedSeats,
|
||||
int newlySubscribedSeats,
|
||||
DateTime? prorationDate = null);
|
||||
Task<string> AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
|
||||
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
@ -16,6 +17,7 @@ public interface IStripeAdapter
|
||||
Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
|
||||
Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
|
||||
Task<List<Stripe.Invoice>> InvoiceListAsync(StripeInvoiceListOptions options);
|
||||
Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options);
|
||||
Task<Stripe.Invoice> InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options);
|
||||
Task<Stripe.Invoice> InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options);
|
||||
Task<Stripe.Invoice> InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options);
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
@ -103,6 +104,9 @@ public class StripeAdapter : IStripeAdapter
|
||||
return invoices;
|
||||
}
|
||||
|
||||
public async Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options)
|
||||
=> (await _invoiceService.SearchAsync(options)).Data;
|
||||
|
||||
public Task<Stripe.Invoice> InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options)
|
||||
{
|
||||
return _invoiceService.UpdateAsync(id, options);
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -757,14 +758,14 @@ public class StripePaymentService : IPaymentService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<string> FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber,
|
||||
private async Task<string> FinalizeSubscriptionChangeAsync(ISubscriber subscriber,
|
||||
SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false)
|
||||
{
|
||||
// remember, when in doubt, throw
|
||||
var subGetOptions = new SubscriptionGetOptions();
|
||||
// subGetOptions.AddExpand("customer");
|
||||
subGetOptions.AddExpand("customer.tax");
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId, subGetOptions);
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions);
|
||||
if (sub == null)
|
||||
{
|
||||
throw new GatewayException("Subscription not found.");
|
||||
@ -776,6 +777,7 @@ public class StripePaymentService : IPaymentService
|
||||
var chargeNow = collectionMethod == "charge_automatically";
|
||||
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
|
||||
var isPm5864DollarThresholdEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5864DollarThreshold);
|
||||
var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year";
|
||||
|
||||
var subUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
@ -787,25 +789,10 @@ public class StripePaymentService : IPaymentService
|
||||
CollectionMethod = "send_invoice",
|
||||
ProrationDate = prorationDate,
|
||||
};
|
||||
var immediatelyInvoice = false;
|
||||
if (!invoiceNow && isPm5864DollarThresholdEnabled && sub.Status.Trim() != "trialing")
|
||||
if (!invoiceNow && isAnnualPlan && isPm5864DollarThresholdEnabled && sub.Status.Trim() != "trialing")
|
||||
{
|
||||
var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions
|
||||
{
|
||||
Customer = storableSubscriber.GatewayCustomerId,
|
||||
Subscription = storableSubscriber.GatewaySubscriptionId,
|
||||
SubscriptionItems = ToInvoiceSubscriptionItemOptions(updatedItemOptions),
|
||||
SubscriptionProrationBehavior = Constants.CreateProrations,
|
||||
SubscriptionProrationDate = prorationDate,
|
||||
SubscriptionBillingCycleAnchor = SubscriptionBillingCycleAnchor.Now
|
||||
});
|
||||
|
||||
var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year";
|
||||
immediatelyInvoice = isAnnualPlan && upcomingInvoiceWithChanges.AmountRemaining >= 50000;
|
||||
|
||||
subUpdateOptions.BillingCycleAnchor = immediatelyInvoice
|
||||
? SubscriptionBillingCycleAnchor.Now
|
||||
: SubscriptionBillingCycleAnchor.Unchanged;
|
||||
subUpdateOptions.PendingInvoiceItemInterval =
|
||||
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
|
||||
}
|
||||
|
||||
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
|
||||
@ -858,21 +845,16 @@ public class StripePaymentService : IPaymentService
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!isPm5864DollarThresholdEnabled || immediatelyInvoice || invoiceNow)
|
||||
if (chargeNow)
|
||||
{
|
||||
if (chargeNow)
|
||||
{
|
||||
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(storableSubscriber, invoice);
|
||||
}
|
||||
else
|
||||
{
|
||||
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions
|
||||
{
|
||||
AutoAdvance = false,
|
||||
});
|
||||
await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions());
|
||||
paymentIntentClientSecret = null;
|
||||
}
|
||||
paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(subscriber, invoice);
|
||||
}
|
||||
else
|
||||
{
|
||||
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId,
|
||||
new InvoiceFinalizeOptions { AutoAdvance = false, });
|
||||
await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions());
|
||||
paymentIntentClientSecret = null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
@ -943,6 +925,17 @@ public class StripePaymentService : IPaymentService
|
||||
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
|
||||
}
|
||||
|
||||
public Task<string> AdjustSeats(
|
||||
Provider provider,
|
||||
StaticStore.Plan plan,
|
||||
int currentlySubscribedSeats,
|
||||
int newlySubscribedSeats,
|
||||
DateTime? prorationDate = null)
|
||||
=> FinalizeSubscriptionChangeAsync(
|
||||
provider,
|
||||
new ProviderSubscriptionUpdate(plan.Type, currentlySubscribedSeats, newlySubscribedSeats),
|
||||
prorationDate);
|
||||
|
||||
public Task<string> AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null)
|
||||
{
|
||||
return FinalizeSubscriptionChangeAsync(organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
|
||||
@ -1610,10 +1603,25 @@ public class StripePaymentService : IPaymentService
|
||||
return subscriptionInfo;
|
||||
}
|
||||
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId);
|
||||
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["test_clock"]
|
||||
});
|
||||
|
||||
if (sub != null)
|
||||
{
|
||||
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AC1795_UpdatedSubscriptionStatusSection))
|
||||
{
|
||||
var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(sub);
|
||||
|
||||
if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue)
|
||||
{
|
||||
subscriptionInfo.Subscription.SuspensionDate = suspensionDate;
|
||||
subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
@ -1930,4 +1938,45 @@ public class StripePaymentService : IPaymentService
|
||||
? subscriberName
|
||||
: subscriberName[..30];
|
||||
}
|
||||
|
||||
private async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription)
|
||||
{
|
||||
if (subscription.Status is not "past_due" && subscription.Status is not "unpaid")
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
|
||||
{
|
||||
Query = $"subscription:'{subscription.Id}' status:'open'"
|
||||
});
|
||||
|
||||
if (openInvoices.Count == 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var currentDate = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
switch (subscription.CollectionMethod)
|
||||
{
|
||||
case "charge_automatically":
|
||||
{
|
||||
var firstOverdueInvoice = openInvoices
|
||||
.Where(invoice => invoice.PeriodEnd < currentDate && invoice.Attempted)
|
||||
.MinBy(invoice => invoice.Created);
|
||||
|
||||
return (firstOverdueInvoice?.Created.AddDays(14), firstOverdueInvoice?.PeriodEnd);
|
||||
}
|
||||
case "send_invoice":
|
||||
{
|
||||
var firstOverdueInvoice = openInvoices
|
||||
.Where(invoice => invoice.DueDate < currentDate)
|
||||
.MinBy(invoice => invoice.Created);
|
||||
|
||||
return (firstOverdueInvoice?.DueDate?.AddDays(30), firstOverdueInvoice?.PeriodEnd);
|
||||
}
|
||||
default: return (null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -221,6 +221,8 @@ public class GlobalSettings : IGlobalSettings
|
||||
private string _connectionString;
|
||||
private string _readOnlyConnectionString;
|
||||
private string _jobSchedulerConnectionString;
|
||||
public bool SkipDatabasePreparation { get; set; }
|
||||
public bool DisableDatabaseMaintenanceJobs { get; set; }
|
||||
|
||||
public string ConnectionString
|
||||
{
|
||||
|
@ -32,6 +32,10 @@ public static class CoreHelpers
|
||||
private static readonly Random _random = new Random();
|
||||
private static readonly string RealConnectingIp = "X-Connecting-IP";
|
||||
private static readonly Regex _whiteSpaceRegex = new Regex(@"\s+");
|
||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Generate sequential Guid for Sql Server.
|
||||
@ -778,22 +782,12 @@ public static class CoreHelpers
|
||||
return new T();
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
return System.Text.Json.JsonSerializer.Deserialize<T>(jsonData, options);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<T>(jsonData, _jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public static string ClassToJsonData<T>(T data)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
return System.Text.Json.JsonSerializer.Serialize(data, options);
|
||||
return System.Text.Json.JsonSerializer.Serialize(data, _jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public static ICollection<T> AddIfNotExists<T>(this ICollection<T> list, T item)
|
||||
|
@ -147,7 +147,6 @@ public static class StaticStore
|
||||
|
||||
public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType);
|
||||
|
||||
|
||||
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
|
||||
SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType);
|
||||
|
||||
|
@ -14,10 +14,6 @@
|
||||
"events": {
|
||||
"connectionString": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
|
@ -30,6 +30,7 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Starting service.");
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_executingTask = ExecuteAsync(_cts.Token);
|
||||
|
||||
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
|
||||
}
|
||||
|
||||
@ -39,8 +40,10 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Stopping service.");
|
||||
_cts.Cancel();
|
||||
|
||||
await _cts.CancelAsync();
|
||||
await Task.WhenAny(_executingTask, Task.Delay(-1, cancellationToken));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
@ -64,13 +67,15 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = await _queueClient.ReceiveMessagesAsync(32);
|
||||
var messages = await _queueClient.ReceiveMessagesAsync(32,
|
||||
cancellationToken: cancellationToken);
|
||||
if (messages.Value?.Any() ?? false)
|
||||
{
|
||||
foreach (var message in messages.Value)
|
||||
{
|
||||
await ProcessQueueMessageAsync(message.DecodeMessageText(), cancellationToken);
|
||||
await _queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt);
|
||||
await _queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -78,14 +83,15 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(e, "Exception occurred: " + e.Message);
|
||||
_logger.LogError(ex, "Error occurred processing message block.");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Done processing.");
|
||||
_logger.LogWarning("Done processing messages.");
|
||||
}
|
||||
|
||||
public async Task ProcessQueueMessageAsync(string message, CancellationToken cancellationToken)
|
||||
@ -98,14 +104,14 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Processing message.");
|
||||
var events = new List<IEvent>();
|
||||
|
||||
var events = new List<IEvent>();
|
||||
using var jsonDocument = JsonDocument.Parse(message);
|
||||
var root = jsonDocument.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var indexedEntities = root.Deserialize<List<EventMessage>>()
|
||||
.SelectMany(e => EventTableEntity.IndexEvent(e));
|
||||
.SelectMany(EventTableEntity.IndexEvent);
|
||||
events.AddRange(indexedEntities);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
@ -114,12 +120,15 @@ public class AzureQueueHostedService : IHostedService, IDisposable
|
||||
events.AddRange(EventTableEntity.IndexEvent(eventMessage));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await _eventWriteService.CreateManyAsync(events);
|
||||
|
||||
_logger.LogInformation("Processed message.");
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError("JsonReaderException: Unable to parse message.");
|
||||
_logger.LogError(ex, "Unable to parse message.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,10 +2,6 @@
|
||||
"azureStorageConnectionString": "SECRET",
|
||||
"globalSettings": {
|
||||
"selfHosted": false,
|
||||
"projectName": "Events Processor",
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
}
|
||||
"projectName": "Events Processor"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
{
|
||||
"globalSettings": {
|
||||
"projectName": "Icons",
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
}
|
||||
"projectName": "Icons"
|
||||
},
|
||||
"iconsSettings": {
|
||||
"cacheEnabled": true,
|
||||
|
@ -27,10 +27,6 @@
|
||||
"events": {
|
||||
"connectionString": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
|
@ -14,7 +14,7 @@ public class ProviderPlanRepository(
|
||||
globalSettings.SqlServer.ConnectionString,
|
||||
globalSettings.SqlServer.ReadOnlyConnectionString), IProviderPlanRepository
|
||||
{
|
||||
public async Task<ProviderPlan> GetByProviderId(Guid providerId)
|
||||
public async Task<ICollection<ProviderPlan>> GetByProviderId(Guid providerId)
|
||||
{
|
||||
var sqlConnection = new SqlConnection(ConnectionString);
|
||||
|
||||
@ -23,6 +23,6 @@ public class ProviderPlanRepository(
|
||||
new { ProviderId = providerId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.FirstOrDefault();
|
||||
return results.ToArray();
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.28" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -16,14 +16,17 @@ public class ProviderPlanRepository(
|
||||
mapper,
|
||||
context => context.ProviderPlans), IProviderPlanRepository
|
||||
{
|
||||
public async Task<ProviderPlan> GetByProviderId(Guid providerId)
|
||||
public async Task<ICollection<ProviderPlan>> GetByProviderId(Guid providerId)
|
||||
{
|
||||
using var serviceScope = ServiceScopeFactory.CreateScope();
|
||||
|
||||
var databaseContext = GetDatabaseContext(serviceScope);
|
||||
|
||||
var query =
|
||||
from providerPlan in databaseContext.ProviderPlans
|
||||
where providerPlan.ProviderId == providerId
|
||||
select providerPlan;
|
||||
return await query.FirstOrDefaultAsync();
|
||||
|
||||
return await query.ToArrayAsync();
|
||||
}
|
||||
}
|
||||
|
@ -18,10 +18,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"documentDb": {
|
||||
"uri": "SECRET",
|
||||
"key": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
|
Reference in New Issue
Block a user