1
0
mirror of https://github.com/bitwarden/server synced 2025-12-20 10:13:39 +00:00
Files
server/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs
Alex Morask 9c51c9971b [PM-21638] Stripe .NET v48 (#6202)
* Upgrade Stripe.net to v48.4.0

* Update PreviewTaxAmountCommand

* Remove unused UpcomingInvoiceOptionExtensions

* Added SubscriptionExtensions with GetCurrentPeriodEnd

* Update PremiumUserBillingService

* Update OrganizationBillingService

* Update GetOrganizationWarningsQuery

* Update BillingHistoryInfo

* Update SubscriptionInfo

* Remove unused Sql Billing folder

* Update StripeAdapter

* Update StripePaymentService

* Update InvoiceCreatedHandler

* Update PaymentFailedHandler

* Update PaymentSucceededHandler

* Update ProviderEventService

* Update StripeEventUtilityService

* Update SubscriptionDeletedHandler

* Update SubscriptionUpdatedHandler

* Update UpcomingInvoiceHandler

* Update ProviderSubscriptionResponse

* Remove unused Stripe Subscriptions Admin Tool

* Update RemoveOrganizationFromProviderCommand

* Update ProviderBillingService

* Update RemoveOrganizatinoFromProviderCommandTests

* Update PreviewTaxAmountCommandTests

* Update GetCloudOrganizationLicenseQueryTests

* Update GetOrganizationWarningsQueryTests

* Update StripePaymentServiceTests

* Update ProviderBillingControllerTests

* Update ProviderEventServiceTests

* Update SubscriptionDeletedHandlerTests

* Update SubscriptionUpdatedHandlerTests

* Resolve Billing test failures

I completely removed tests for the StripeEventService as they were using a system I setup a while back that read JSON files of the Stripe event structure. I did not anticipate how frequently these structures would change with each API version and the cost of trying to update these specific JSON files to test a very static data retrieval service far outweigh the benefit.

* Resolve Core test failures

* Run dotnet format

* Remove unused provider migration

* Fixed failing tests

* Run dotnet format

* Replace the old webhook secret key with new one (#6223)

* Fix compilation failures in additions

* Run dotnet format

* Bump Stripe API version

* Fix recent addition: CreatePremiumCloudHostedSubscriptionCommand

* Fix new code in main according to Stripe update

* Fix InvoiceExtensions

* Bump SDK version to match API Version

* Fix provider invoice generation validation

* More QA fixes

* Fix tests

* QA defect resolutions

* QA defect resolutions

* Run dotnet format

* Fix tests

---------

Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
2025-10-21 14:07:55 -05:00

289 lines
9.8 KiB
C#

using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using Stripe.Tax;
namespace Bit.Core.Billing.Organizations.Queries;
using static Core.Constants;
using static StripeConstants;
using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning;
using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning;
using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning;
using TaxIdWarning = OrganizationWarnings.TaxIdWarning;
public interface IGetOrganizationWarningsQuery
{
Task<OrganizationWarnings> Run(
Organization organization);
}
public class GetOrganizationWarningsQuery(
ICurrentContext currentContext,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetOrganizationWarningsQuery
{
public async Task<OrganizationWarnings> Run(
Organization organization)
{
var warnings = new OrganizationWarnings();
var subscription =
await subscriberService.GetSubscription(organization,
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] });
if (subscription == null)
{
return warnings;
}
warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription);
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription);
warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription);
warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider);
return warnings;
}
private async Task<FreeTrialWarning?> GetFreeTrialWarningAsync(
Organization organization,
Subscription subscription)
{
if (!await currentContext.EditSubscription(organization.Id))
{
return null;
}
if (subscription is not
{
Status: SubscriptionStatus.Trialing,
TrialEnd: not null,
Customer: not null
})
{
return null;
}
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
if (hasPaymentMethod)
{
return null;
}
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays);
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
}
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarningAsync(
Organization organization,
Provider? provider,
Subscription subscription)
{
// If the organization is enabled or the subscription is active, don't return a warning.
if (organization.Enabled || subscription is not
{
Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled
})
{
return null;
}
// If the organization is managed by a provider, return a warning asking them to contact the provider.
if (provider != null)
{
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
}
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. */
if (isOrganizationOwner)
{
return subscription.Status switch
{
SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
{
Resolution = "add_payment_method"
},
SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
{
Resolution = "resubscribe"
},
_ => null
};
}
// Otherwise, return a warning asking them to contact the owner.
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
}
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarningAsync(
Provider? provider,
Subscription subscription)
{
if (provider is not
{
Type: ProviderType.Reseller
})
{
return null;
}
if (subscription.CollectionMethod != CollectionMethod.SendInvoice)
{
return null;
}
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
// ReSharper disable once ConvertIfStatementToSwitchStatement
if (subscription is
{
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
LatestInvoice: null or { Status: InvoiceStatus.Paid },
Items.Data.Count: > 0
})
{
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
if (currentPeriodEnd != null && (currentPeriodEnd.Value - now).TotalDays <= 14)
{
return new ResellerRenewalWarning
{
Type = "upcoming",
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
{
RenewalDate = currentPeriodEnd.Value
}
};
}
}
if (subscription is
{
Status: SubscriptionStatus.Active,
LatestInvoice: { Status: InvoiceStatus.Open, DueDate: not null }
} && subscription.LatestInvoice.DueDate > now)
{
return new ResellerRenewalWarning
{
Type = "issued",
Issued = new ResellerRenewalWarning.IssuedRenewal
{
IssuedDate = subscription.LatestInvoice.Created,
DueDate = subscription.LatestInvoice.DueDate.Value
}
};
}
// ReSharper disable once InvertIf
if (subscription.Status == SubscriptionStatus.PastDue)
{
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
{
Query = $"subscription:'{subscription.Id}' status:'open'"
});
var earliestOverdueInvoice = openInvoices
.Where(invoice => invoice.DueDate != null && invoice.DueDate < now)
.MinBy(invoice => invoice.Created);
if (earliestOverdueInvoice != null)
{
return new ResellerRenewalWarning
{
Type = "past_due",
PastDue = new ResellerRenewalWarning.PastDueRenewal
{
SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30)
}
};
}
}
return null;
}
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
Organization organization,
Customer customer,
Provider? provider)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
{
return null;
}
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
};
}
}