mirror of
https://github.com/bitwarden/server
synced 2025-12-20 10:13:39 +00:00
* 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>
289 lines
9.8 KiB
C#
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
|
|
};
|
|
}
|
|
}
|