mirror of
https://github.com/bitwarden/server
synced 2025-12-17 16:53:23 +00:00
[PM-22145] Tax ID notifications for Organizations and Providers (#6185)
* Add TaxRegistrationsListAsync to StripeAdapter * Update GetOrganizationWarningsQuery, add GetProviderWarningsQuery to support tax ID warning * Add feature flag to control web display * Run dotnet format'
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Providers.Models;
|
||||||
|
using Bit.Core.Billing.Providers.Queries;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Stripe;
|
||||||
|
using Stripe.Tax;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Billing.Providers.Queries;
|
||||||
|
|
||||||
|
using static StripeConstants;
|
||||||
|
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
|
||||||
|
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
|
||||||
|
|
||||||
|
public class GetProviderWarningsQuery(
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
|
ISubscriberService subscriberService) : IGetProviderWarningsQuery
|
||||||
|
{
|
||||||
|
public async Task<ProviderWarnings?> Run(Provider provider)
|
||||||
|
{
|
||||||
|
var warnings = new ProviderWarnings();
|
||||||
|
|
||||||
|
var subscription =
|
||||||
|
await subscriberService.GetSubscription(provider,
|
||||||
|
new SubscriptionGetOptions { Expand = ["customer.tax_ids"] });
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.Suspension = GetSuspensionWarning(provider, subscription);
|
||||||
|
|
||||||
|
warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer);
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SuspensionWarning? GetSuspensionWarning(
|
||||||
|
Provider provider,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (provider.Enabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription.Status switch
|
||||||
|
{
|
||||||
|
SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id)
|
||||||
|
? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt }
|
||||||
|
: new SuspensionWarning { Resolution = "contact_administrator" },
|
||||||
|
_ => new SuspensionWarning { Resolution = "contact_support" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
|
||||||
|
Provider provider,
|
||||||
|
Customer customer)
|
||||||
|
{
|
||||||
|
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Potentially DRY this out with the GetOrganizationWarningsQuery
|
||||||
|
|
||||||
|
// Get active and scheduled registrations
|
||||||
|
var registrations = (await Task.WhenAll(
|
||||||
|
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
|
||||||
|
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
|
||||||
|
.SelectMany(registrations => registrations.Data);
|
||||||
|
|
||||||
|
// Find the matching registration for the customer
|
||||||
|
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country);
|
||||||
|
|
||||||
|
// If we're not registered in their country, we don't need a warning
|
||||||
|
if (registration == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taxId = customer.TaxIds.FirstOrDefault();
|
||||||
|
|
||||||
|
return taxId switch
|
||||||
|
{
|
||||||
|
// Customer's tax ID is missing
|
||||||
|
null => new TaxIdWarning { Type = "tax_id_missing" },
|
||||||
|
// Not sure if this case is valid, but Stripe says this property is nullable
|
||||||
|
not null when taxId.Verification == null => null,
|
||||||
|
// Customer's tax ID is pending verification
|
||||||
|
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
|
||||||
|
// Customer's tax ID failed verification
|
||||||
|
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||||
using Bit.Commercial.Core.AdminConsole.Services;
|
using Bit.Commercial.Core.AdminConsole.Services;
|
||||||
|
using Bit.Commercial.Core.Billing.Providers.Queries;
|
||||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Billing.Providers.Queries;
|
||||||
using Bit.Core.Billing.Providers.Services;
|
using Bit.Core.Billing.Providers.Services;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||||
|
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
using Bit.Commercial.Core.Billing.Providers.Queries;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
|
using Stripe;
|
||||||
|
using Stripe.Tax;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Commercial.Core.Test.Billing.Providers.Queries;
|
||||||
|
|
||||||
|
using static StripeConstants;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class GetProviderWarningsQueryTests
|
||||||
|
{
|
||||||
|
private static readonly string[] _requiredExpansions = ["customer.tax_ids"];
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_NoSubscription_NoWarnings(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.ReturnsNull();
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
Suspension: null,
|
||||||
|
TaxId: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_ProviderEnabled_NoSuspensionWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration> { Data = [] });
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.Null(response!.Suspension);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_SuspensionWarning_AddPaymentMethod(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = false;
|
||||||
|
var cancelAt = DateTime.UtcNow.AddDays(7);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
CancelAt = cancelAt,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration> { Data = [] });
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
Suspension.Resolution: "add_payment_method"
|
||||||
|
});
|
||||||
|
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_SuspensionWarning_ContactAdministrator(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration> { Data = [] });
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
Suspension.Resolution: "contact_administrator"
|
||||||
|
});
|
||||||
|
Assert.Null(response.Suspension.SubscriptionCancelsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_SuspensionWarning_ContactSupport(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = false;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Canceled,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration> { Data = [] });
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
Suspension.Resolution: "contact_support"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_NotProviderAdmin_NoTaxIdWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.Null(response!.TaxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_NoTaxRegistrationForCountry_NoTaxIdWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "CA" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.Null(response!.TaxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_TaxIdMissingWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
TaxId.Type: "tax_id_missing"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_TaxIdVerificationIsNull_NoTaxIdWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = [new TaxId { Verification = null }]
|
||||||
|
},
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.Null(response!.TaxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_TaxIdPendingVerificationWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = [new TaxId
|
||||||
|
{
|
||||||
|
Verification = new TaxIdVerification
|
||||||
|
{
|
||||||
|
Status = TaxIdVerificationStatus.Pending
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
TaxId.Type: "tax_id_pending_verification"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_Has_TaxIdFailedVerificationWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = [new TaxId
|
||||||
|
{
|
||||||
|
Verification = new TaxIdVerification
|
||||||
|
{
|
||||||
|
Status = TaxIdVerificationStatus.Unverified
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
TaxId.Type: "tax_id_failed_verification"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_TaxIdVerified_NoTaxIdWarning(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId>
|
||||||
|
{
|
||||||
|
Data = [new TaxId
|
||||||
|
{
|
||||||
|
Verification = new TaxIdVerification
|
||||||
|
{
|
||||||
|
Status = TaxIdVerificationStatus.Verified
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.Null(response!.TaxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_MultipleRegistrations_MatchesCorrectCountry(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = true;
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Active,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "DE" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [
|
||||||
|
new Registration { Country = "US" },
|
||||||
|
new Registration { Country = "DE" },
|
||||||
|
new Registration { Country = "FR" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
|
||||||
|
.Returns(new StripeList<Registration> { Data = [] });
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
TaxId.Type: "tax_id_missing"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task Run_CombinesBothWarningTypes(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<GetProviderWarningsQuery> sutProvider)
|
||||||
|
{
|
||||||
|
provider.Enabled = false;
|
||||||
|
var cancelAt = DateTime.UtcNow.AddDays(5);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISubscriberService>()
|
||||||
|
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
|
||||||
|
options.Expand.SequenceEqual(_requiredExpansions)
|
||||||
|
))
|
||||||
|
.Returns(new Subscription
|
||||||
|
{
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
CancelAt = cancelAt,
|
||||||
|
Customer = new Customer
|
||||||
|
{
|
||||||
|
TaxIds = new StripeList<TaxId> { Data = [] },
|
||||||
|
Address = new Address { Country = "US" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
|
||||||
|
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
|
||||||
|
.Returns(new StripeList<Registration>
|
||||||
|
{
|
||||||
|
Data = [new Registration { Country = "US" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await sutProvider.Sut.Run(provider);
|
||||||
|
|
||||||
|
Assert.True(response is
|
||||||
|
{
|
||||||
|
Suspension.Resolution: "add_payment_method",
|
||||||
|
TaxId.Type: "tax_id_missing"
|
||||||
|
});
|
||||||
|
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ using Bit.Api.Billing.Models.Responses;
|
|||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Organizations.Models;
|
using Bit.Core.Billing.Organizations.Models;
|
||||||
using Bit.Core.Billing.Organizations.Queries;
|
|
||||||
using Bit.Core.Billing.Organizations.Services;
|
using Bit.Core.Billing.Organizations.Services;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Providers.Services;
|
using Bit.Core.Billing.Providers.Services;
|
||||||
@@ -28,7 +27,6 @@ public class OrganizationBillingController(
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationBillingService organizationBillingService,
|
IOrganizationBillingService organizationBillingService,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
|
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
ISubscriberService subscriberService,
|
ISubscriberService subscriberService,
|
||||||
@@ -359,31 +357,6 @@ public class OrganizationBillingController(
|
|||||||
return TypedResults.Ok(providerId);
|
return TypedResults.Ok(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("warnings")]
|
|
||||||
public async Task<IResult> GetWarningsAsync([FromRoute] Guid organizationId)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* We'll keep these available at the User level because we're hiding any pertinent information, and
|
|
||||||
* we want to throw as few errors as possible since these are not core features.
|
|
||||||
*/
|
|
||||||
if (!await currentContext.OrganizationUser(organizationId))
|
|
||||||
{
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
|
||||||
|
|
||||||
if (organization == null)
|
|
||||||
{
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var warnings = await getOrganizationWarningsQuery.Run(organization);
|
|
||||||
|
|
||||||
return TypedResults.Ok(warnings);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[HttpPost("change-frequency")]
|
[HttpPost("change-frequency")]
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(
|
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#nullable enable
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Bit.Api.AdminConsole.Authorization;
|
using Bit.Api.AdminConsole.Authorization.Requirements;
|
||||||
using Bit.Api.Billing.Attributes;
|
using Bit.Api.Billing.Attributes;
|
||||||
using Bit.Api.Billing.Models.Requests.Payment;
|
using Bit.Api.Billing.Models.Requests.Payment;
|
||||||
using Bit.Api.Billing.Models.Requirements;
|
using Bit.Api.Billing.Models.Requirements;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing.Organizations.Queries;
|
||||||
using Bit.Core.Billing.Payment.Commands;
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
using Bit.Core.Billing.Payment.Queries;
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@@ -21,6 +22,7 @@ public class OrganizationBillingVNextController(
|
|||||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||||
IGetBillingAddressQuery getBillingAddressQuery,
|
IGetBillingAddressQuery getBillingAddressQuery,
|
||||||
IGetCreditQuery getCreditQuery,
|
IGetCreditQuery getCreditQuery,
|
||||||
|
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
|
||||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
||||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||||
@@ -104,4 +106,14 @@ public class OrganizationBillingVNextController(
|
|||||||
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
|
var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode);
|
||||||
return Handle(result);
|
return Handle(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize<MemberOrProviderRequirement>]
|
||||||
|
[HttpGet("warnings")]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> GetWarningsAsync(
|
||||||
|
[BindNever] Organization organization)
|
||||||
|
{
|
||||||
|
var warnings = await getOrganizationWarningsQuery.Run(organization);
|
||||||
|
return TypedResults.Ok(warnings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
|||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Billing.Payment.Commands;
|
using Bit.Core.Billing.Payment.Commands;
|
||||||
using Bit.Core.Billing.Payment.Queries;
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
|
using Bit.Core.Billing.Providers.Queries;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
@@ -19,6 +20,7 @@ public class ProviderBillingVNextController(
|
|||||||
IGetBillingAddressQuery getBillingAddressQuery,
|
IGetBillingAddressQuery getBillingAddressQuery,
|
||||||
IGetCreditQuery getCreditQuery,
|
IGetCreditQuery getCreditQuery,
|
||||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
|
IGetProviderWarningsQuery getProviderWarningsQuery,
|
||||||
IProviderService providerService,
|
IProviderService providerService,
|
||||||
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
IUpdateBillingAddressCommand updateBillingAddressCommand,
|
||||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||||
@@ -104,4 +106,13 @@ public class ProviderBillingVNextController(
|
|||||||
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
|
var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode);
|
||||||
return Handle(result);
|
return Handle(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("warnings")]
|
||||||
|
[InjectProvider(ProviderUserType.ServiceUser)]
|
||||||
|
public async Task<IResult> GetWarningsAsync(
|
||||||
|
[BindNever] Provider provider)
|
||||||
|
{
|
||||||
|
var warnings = await getProviderWarningsQuery.Run(provider);
|
||||||
|
return TypedResults.Ok(warnings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#nullable enable
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Bit.Api.AdminConsole.Authorization;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ public static class StripeConstants
|
|||||||
public const string SpanishNIF = "es_cif";
|
public const string SpanishNIF = "es_cif";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class TaxIdVerificationStatus
|
||||||
|
{
|
||||||
|
public const string Pending = "pending";
|
||||||
|
public const string Unavailable = "unavailable";
|
||||||
|
public const string Unverified = "unverified";
|
||||||
|
public const string Verified = "verified";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaxRegistrationStatus
|
||||||
|
{
|
||||||
|
public const string Active = "active";
|
||||||
|
public const string Expired = "expired";
|
||||||
|
public const string Scheduled = "scheduled";
|
||||||
|
}
|
||||||
|
|
||||||
public static class ValidateTaxLocationTiming
|
public static class ValidateTaxLocationTiming
|
||||||
{
|
{
|
||||||
public const string Deferred = "deferred";
|
public const string Deferred = "deferred";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public record OrganizationWarnings
|
|||||||
public FreeTrialWarning? FreeTrial { get; set; }
|
public FreeTrialWarning? FreeTrial { get; set; }
|
||||||
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
|
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
|
||||||
public ResellerRenewalWarning? ResellerRenewal { get; set; }
|
public ResellerRenewalWarning? ResellerRenewal { get; set; }
|
||||||
|
public TaxIdWarning? TaxId { get; set; }
|
||||||
|
|
||||||
public record FreeTrialWarning
|
public record FreeTrialWarning
|
||||||
{
|
{
|
||||||
@@ -39,4 +40,9 @@ public record OrganizationWarnings
|
|||||||
public required DateTime SuspensionDate { get; set; }
|
public required DateTime SuspensionDate { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TaxIdWarning
|
||||||
|
{
|
||||||
|
public required string Type { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
// ReSharper disable InconsistentNaming
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Caches;
|
using Bit.Core.Billing.Caches;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Organizations.Models;
|
using Bit.Core.Billing.Organizations.Models;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning;
|
using Stripe.Tax;
|
||||||
using InactiveSubscriptionWarning =
|
|
||||||
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning;
|
|
||||||
using ResellerRenewalWarning =
|
|
||||||
Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning;
|
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Organizations.Queries;
|
namespace Bit.Core.Billing.Organizations.Queries;
|
||||||
|
|
||||||
using static StripeConstants;
|
using static StripeConstants;
|
||||||
|
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
|
||||||
|
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
|
||||||
|
using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning;
|
||||||
|
using TaxIdWarning = OrganizationWarnings.TaxIdWarning;
|
||||||
|
|
||||||
public interface IGetOrganizationWarningsQuery
|
public interface IGetOrganizationWarningsQuery
|
||||||
{
|
{
|
||||||
@@ -38,29 +37,31 @@ public class GetOrganizationWarningsQuery(
|
|||||||
public async Task<OrganizationWarnings> Run(
|
public async Task<OrganizationWarnings> Run(
|
||||||
Organization organization)
|
Organization organization)
|
||||||
{
|
{
|
||||||
var response = new OrganizationWarnings();
|
var warnings = new OrganizationWarnings();
|
||||||
|
|
||||||
var subscription =
|
var subscription =
|
||||||
await subscriberService.GetSubscription(organization,
|
await subscriberService.GetSubscription(organization,
|
||||||
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
|
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] });
|
||||||
|
|
||||||
if (subscription == null)
|
if (subscription == null)
|
||||||
{
|
{
|
||||||
return response;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
|
warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription);
|
||||||
|
|
||||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
|
warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription);
|
||||||
|
|
||||||
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
|
warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription);
|
||||||
|
|
||||||
return response;
|
warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider);
|
||||||
|
|
||||||
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
|
private async Task<FreeTrialWarning?> GetFreeTrialWarningAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
Subscription subscription)
|
Subscription subscription)
|
||||||
{
|
{
|
||||||
@@ -81,7 +82,7 @@ public class GetOrganizationWarningsQuery(
|
|||||||
|
|
||||||
var customer = subscription.Customer;
|
var customer = subscription.Customer;
|
||||||
|
|
||||||
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
|
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
|
||||||
|
|
||||||
var hasPaymentMethod =
|
var hasPaymentMethod =
|
||||||
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||||
@@ -101,41 +102,29 @@ public class GetOrganizationWarningsQuery(
|
|||||||
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
|
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
|
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarningAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
Provider? provider,
|
Provider? provider,
|
||||||
Subscription subscription)
|
Subscription subscription)
|
||||||
{
|
{
|
||||||
var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id);
|
// If the organization is enabled or the subscription is active, don't return a warning.
|
||||||
|
if (organization.Enabled || subscription is not
|
||||||
switch (organization.Enabled)
|
|
||||||
{
|
{
|
||||||
// Member of an enabled, trialing organization.
|
Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled
|
||||||
case true when subscription.Status is SubscriptionStatus.Trialing:
|
})
|
||||||
{
|
{
|
||||||
var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization);
|
return null;
|
||||||
|
|
||||||
var hasPaymentMethod =
|
|
||||||
!string.IsNullOrEmpty(subscription.Customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
|
||||||
!string.IsNullOrEmpty(subscription.Customer.DefaultSourceId) ||
|
|
||||||
hasUnverifiedBankAccount ||
|
|
||||||
subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
|
|
||||||
|
|
||||||
// If this member is the owner and there's no payment method on file, ask them to add one.
|
|
||||||
return isOrganizationOwner && !hasPaymentMethod
|
|
||||||
? new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
// Member of disabled and unpaid or canceled organization.
|
|
||||||
case false when subscription.Status is SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled:
|
|
||||||
{
|
|
||||||
// If the organization is managed by a provider, return a warning asking them to contact the provider.
|
// If the organization is managed by a provider, return a warning asking them to contact the provider.
|
||||||
if (provider != null)
|
if (provider != null)
|
||||||
{
|
{
|
||||||
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
|
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If the organization is not managed by a provider and this user is the owner, return an action warning based
|
var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id);
|
||||||
|
|
||||||
|
/* If the organization is not managed by a provider and this user is the owner, return a warning based
|
||||||
on the subscription status. */
|
on the subscription status. */
|
||||||
if (isOrganizationOwner)
|
if (isOrganizationOwner)
|
||||||
{
|
{
|
||||||
@@ -153,14 +142,11 @@ public class GetOrganizationWarningsQuery(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, this member is not the owner, and we need to ask them to contact the owner.
|
// Otherwise, return a warning asking them to contact the owner.
|
||||||
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
|
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
|
||||||
}
|
}
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
|
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarningAsync(
|
||||||
Provider? provider,
|
Provider? provider,
|
||||||
Subscription subscription)
|
Subscription subscription)
|
||||||
{
|
{
|
||||||
@@ -241,7 +227,62 @@ public class GetOrganizationWarningsQuery(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> HasUnverifiedBankAccount(
|
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
|
||||||
|
Organization organization,
|
||||||
|
Customer customer,
|
||||||
|
Provider? provider)
|
||||||
|
{
|
||||||
|
var productTier = organization.PlanType.GetProductTier();
|
||||||
|
|
||||||
|
// Only business tier customers can have tax IDs
|
||||||
|
if (productTier is not ProductTierType.Teams and not ProductTierType.Enterprise)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only an organization owner can update a tax ID
|
||||||
|
if (!await currentContext.OrganizationOwner(organization.Id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider != null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active and scheduled registrations
|
||||||
|
var registrations = (await Task.WhenAll(
|
||||||
|
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
|
||||||
|
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
|
||||||
|
.SelectMany(registrations => registrations.Data);
|
||||||
|
|
||||||
|
// Find the matching registration for the customer
|
||||||
|
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
|
||||||
|
|
||||||
|
// If we're not registered in their country, we don't need a warning
|
||||||
|
if (registration == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taxId = customer.TaxIds.FirstOrDefault();
|
||||||
|
|
||||||
|
return taxId switch
|
||||||
|
{
|
||||||
|
// Customer's tax ID is missing
|
||||||
|
null => new TaxIdWarning { Type = "tax_id_missing" },
|
||||||
|
// Not sure if this case is valid, but Stripe says this property is nullable
|
||||||
|
not null when taxId.Verification == null => null,
|
||||||
|
// Customer's tax ID is pending verification
|
||||||
|
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
|
||||||
|
// Customer's tax ID failed verification
|
||||||
|
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> HasUnverifiedBankAccountAsync(
|
||||||
Organization organization)
|
Organization organization)
|
||||||
{
|
{
|
||||||
var setupIntentId = await setupIntentCache.Get(organization.Id);
|
var setupIntentId = await setupIntentCache.Get(organization.Id);
|
||||||
|
|||||||
18
src/Core/Billing/Providers/Models/ProviderWarnings.cs
Normal file
18
src/Core/Billing/Providers/Models/ProviderWarnings.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Bit.Core.Billing.Providers.Models;
|
||||||
|
|
||||||
|
public class ProviderWarnings
|
||||||
|
{
|
||||||
|
public SuspensionWarning? Suspension { get; set; }
|
||||||
|
public TaxIdWarning? TaxId { get; set; }
|
||||||
|
|
||||||
|
public record SuspensionWarning
|
||||||
|
{
|
||||||
|
public required string Resolution { get; set; }
|
||||||
|
public DateTime? SubscriptionCancelsAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TaxIdWarning
|
||||||
|
{
|
||||||
|
public required string Type { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Providers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.Billing.Providers.Queries;
|
||||||
|
|
||||||
|
public interface IGetProviderWarningsQuery
|
||||||
|
{
|
||||||
|
Task<ProviderWarnings?> Run(Provider provider);
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
|
public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe";
|
||||||
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
|
public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout";
|
||||||
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
|
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
|
||||||
|
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
|
||||||
|
|
||||||
/* Key Management Team */
|
/* Key Management Team */
|
||||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public interface IStripeAdapter
|
|||||||
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
|
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
|
||||||
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
|
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
|
||||||
Task<Stripe.TaxId> TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null);
|
Task<Stripe.TaxId> TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null);
|
||||||
|
Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null);
|
||||||
Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options);
|
Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options);
|
||||||
Task<Stripe.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options);
|
Task<Stripe.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options);
|
||||||
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);
|
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class StripeAdapter : IStripeAdapter
|
|||||||
private readonly Stripe.SetupIntentService _setupIntentService;
|
private readonly Stripe.SetupIntentService _setupIntentService;
|
||||||
private readonly Stripe.TestHelpers.TestClockService _testClockService;
|
private readonly Stripe.TestHelpers.TestClockService _testClockService;
|
||||||
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
|
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
|
||||||
|
private readonly Stripe.Tax.RegistrationService _taxRegistrationService;
|
||||||
|
|
||||||
public StripeAdapter()
|
public StripeAdapter()
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,7 @@ public class StripeAdapter : IStripeAdapter
|
|||||||
_setupIntentService = new SetupIntentService();
|
_setupIntentService = new SetupIntentService();
|
||||||
_testClockService = new Stripe.TestHelpers.TestClockService();
|
_testClockService = new Stripe.TestHelpers.TestClockService();
|
||||||
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
|
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
|
||||||
|
_taxRegistrationService = new Stripe.Tax.RegistrationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
|
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
|
||||||
@@ -208,6 +210,11 @@ public class StripeAdapter : IStripeAdapter
|
|||||||
return _taxIdService.DeleteAsync(customerId, taxIdId);
|
return _taxIdService.DeleteAsync(customerId, taxIdId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null)
|
||||||
|
{
|
||||||
|
return _taxRegistrationService.ListAsync(options);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options)
|
public Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options)
|
||||||
{
|
{
|
||||||
return _chargeService.ListAsync(options);
|
return _chargeService.ListAsync(options);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ namespace Bit.Core.Test.Billing.Organizations.Queries;
|
|||||||
[SutProviderCustomize]
|
[SutProviderCustomize]
|
||||||
public class GetOrganizationWarningsQueryTests
|
public class GetOrganizationWarningsQueryTests
|
||||||
{
|
{
|
||||||
private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"];
|
private static readonly string[] _requiredExpansions = ["customer.tax_ids", "latest_invoice", "test_clock"];
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task Run_NoSubscription_NoWarnings(
|
public async Task Run_NoSubscription_NoWarnings(
|
||||||
@@ -130,7 +130,7 @@ public class GetOrganizationWarningsQueryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethodOptionalTrial(
|
public async Task Run_OrganizationEnabled_NoInactiveSubscriptionWarning(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
SutProvider<GetOrganizationWarningsQuery> sutProvider)
|
SutProvider<GetOrganizationWarningsQuery> sutProvider)
|
||||||
{
|
{
|
||||||
@@ -142,7 +142,7 @@ public class GetOrganizationWarningsQueryTests
|
|||||||
))
|
))
|
||||||
.Returns(new Subscription
|
.Returns(new Subscription
|
||||||
{
|
{
|
||||||
Status = StripeConstants.SubscriptionStatus.Trialing,
|
Status = StripeConstants.SubscriptionStatus.Unpaid,
|
||||||
Customer = new Customer
|
Customer = new Customer
|
||||||
{
|
{
|
||||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||||
@@ -151,14 +151,10 @@ public class GetOrganizationWarningsQueryTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||||
sutProvider.GetDependency<ISetupIntentCache>().Get(organization.Id).Returns((string?)null);
|
|
||||||
|
|
||||||
var response = await sutProvider.Sut.Run(organization);
|
var response = await sutProvider.Sut.Run(organization);
|
||||||
|
|
||||||
Assert.True(response is
|
Assert.Null(response.InactiveSubscription);
|
||||||
{
|
|
||||||
InactiveSubscription.Resolution: "add_payment_method_optional_trial"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
|
|||||||
Reference in New Issue
Block a user