1
0
mirror of https://github.com/bitwarden/server synced 2025-12-27 05:33:17 +00:00

[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>
This commit is contained in:
Alex Morask
2025-10-21 14:07:55 -05:00
committed by GitHub
parent 6324f692b8
commit 9c51c9971b
81 changed files with 1273 additions and 3959 deletions

View File

@@ -1,83 +0,0 @@
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Migration.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("migrate-providers")]
[SelfHosted(NotSelfHostedOnly = true)]
public class MigrateProvidersController(
IProviderMigrator providerMigrator) : Controller
{
[HttpGet]
[RequirePermission(Permission.Tools_MigrateProviders)]
public IActionResult Index()
{
return View(new MigrateProvidersRequestModel());
}
[HttpPost]
[RequirePermission(Permission.Tools_MigrateProviders)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return RedirectToAction("Index");
}
foreach (var providerId in providerIds)
{
await providerMigrator.Migrate(providerId);
}
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
}
[HttpGet("results")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return View(Array.Empty<ProviderMigrationResult>());
}
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
return View(results);
}
[HttpGet("results/{providerId:guid}")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
{
var result = await providerMigrator.GetResult(providerId);
if (result == null)
{
return RedirectToAction("Index");
}
return View(result);
}
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
? text.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.TrimEntries
)
.Select(id => new Guid(id))
.ToList()
: [];
}

View File

@@ -1,13 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models;
public class MigrateProvidersRequestModel
{
[Required]
[Display(Name = "Provider IDs")]
public string ProviderIds { get; set; }
}

View File

@@ -1,39 +0,0 @@
@using System.Text.Json
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Migration Details: @Model.ProviderName</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
<dt class="col-sm-4 col-lg-3">Result</dt>
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
</dl>
<h3>Client Organizations</h3>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
<th>Previous State</th>
</tr>
</thead>
<tbody>
@foreach (var clientResult in Model.Clients)
{
<tr>
<td>@clientResult.OrganizationId</td>
<td>@clientResult.OrganizationName</td>
<td>@clientResult.Result</td>
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -1,46 +0,0 @@
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
@{
ViewData["Title"] = "Migrate Providers";
}
<h1>Migrate Providers</h1>
<h2>Bulk Consolidated Billing Migration Tool</h2>
<section>
<p>
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
</p>
<p class="alert alert-warning">
Updates made through this tool are irreversible without manual intervention.
</p>
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
<div class="card">
<div class="card-body">
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
174e82fc-70c3-448d-9fe7-00bad2a3ab00
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
</div>
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="Run" class="btn btn-primary"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
</div>
</form>
</section>

View File

@@ -1,28 +0,0 @@
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Results</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
</tr>
</thead>
<tbody>
@foreach (var result in Model)
{
<tr>
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
<td>@result.ProviderName</td>
<td>@result.Result</td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text;
using System.Text.Json;
using Bit.Admin.Enums;
using Bit.Admin.Models;
@@ -10,7 +9,6 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Entities;
using Bit.Core.Models.BitStripe;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -33,7 +31,6 @@ public class ToolsController : Controller
private readonly IInstallationRepository _installationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IPaymentService _paymentService;
private readonly IStripeAdapter _stripeAdapter;
private readonly IWebHostEnvironment _environment;
@@ -46,7 +43,6 @@ public class ToolsController : Controller
IInstallationRepository installationRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IPaymentService paymentService,
IStripeAdapter stripeAdapter,
IWebHostEnvironment environment)
{
@@ -58,7 +54,6 @@ public class ToolsController : Controller
_installationRepository = installationRepository;
_organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository;
_paymentService = paymentService;
_stripeAdapter = stripeAdapter;
_environment = environment;
}
@@ -341,138 +336,4 @@ public class ToolsController : Controller
throw new Exception("No license to generate.");
}
}
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
{
options = options ?? new StripeSubscriptionListOptions();
options.Limit = 10;
options.Expand = new List<string>() { "data.customer", "data.latest_invoice" };
options.SelectAll = false;
var subscriptions = await _stripeAdapter.SubscriptionListAsync(options);
options.StartingAfter = subscriptions.LastOrDefault()?.Id;
options.EndingBefore = await StripeSubscriptionsGetHasPreviousPage(subscriptions, options) ?
subscriptions.FirstOrDefault()?.Id :
null;
var isProduction = _environment.IsProduction();
var model = new StripeSubscriptionsModel()
{
Items = subscriptions.Select(s => new StripeSubscriptionRowModel(s)).ToList(),
Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data,
TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync(),
Filter = options
};
return View(model);
}
[HttpPost]
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions([FromForm] StripeSubscriptionsModel model)
{
if (!ModelState.IsValid)
{
var isProduction = _environment.IsProduction();
model.Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data;
model.TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync();
return View(model);
}
if (model.Action == StripeSubscriptionsAction.Export || model.Action == StripeSubscriptionsAction.BulkCancel)
{
var subscriptions = model.Filter.SelectAll ?
await _stripeAdapter.SubscriptionListAsync(model.Filter) :
model.Items.Where(x => x.Selected).Select(x => x.Subscription);
if (model.Action == StripeSubscriptionsAction.Export)
{
return StripeSubscriptionsExport(subscriptions);
}
if (model.Action == StripeSubscriptionsAction.BulkCancel)
{
await StripeSubscriptionsCancel(subscriptions);
}
}
else
{
if (model.Action == StripeSubscriptionsAction.PreviousPage || model.Action == StripeSubscriptionsAction.Search)
{
model.Filter.StartingAfter = null;
}
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
{
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
if (subscription.Status == "canceled")
{
model.Filter.StartingAfter = null;
}
}
model.Filter.EndingBefore = null;
}
}
return RedirectToAction("StripeSubscriptions", model.Filter);
}
// This requires a redundant API call to Stripe because of the way they handle pagination.
// The StartingBefore value has to be inferred from the list we get, and isn't supplied by Stripe.
private async Task<bool> StripeSubscriptionsGetHasPreviousPage(List<Stripe.Subscription> subscriptions, StripeSubscriptionListOptions options)
{
var hasPreviousPage = false;
if (subscriptions.FirstOrDefault()?.Id != null)
{
var previousPageSearchOptions = new StripeSubscriptionListOptions()
{
EndingBefore = subscriptions.FirstOrDefault().Id,
Limit = 1,
Status = options.Status,
CurrentPeriodEndDate = options.CurrentPeriodEndDate,
CurrentPeriodEndRange = options.CurrentPeriodEndRange,
Price = options.Price
};
hasPreviousPage = (await _stripeAdapter.SubscriptionListAsync(previousPageSearchOptions)).Count > 0;
}
return hasPreviousPage;
}
private async Task StripeSubscriptionsCancel(IEnumerable<Stripe.Subscription> subscriptions)
{
foreach (var s in subscriptions)
{
await _stripeAdapter.SubscriptionCancelAsync(s.Id);
if (s.LatestInvoice?.Status == "open")
{
await _stripeAdapter.InvoiceVoidInvoiceAsync(s.LatestInvoiceId);
}
}
}
private FileResult StripeSubscriptionsExport(IEnumerable<Stripe.Subscription> subscriptions)
{
var fieldsToExport = subscriptions.Select(s => new
{
StripeId = s.Id,
CustomerEmail = s.Customer?.Email,
SubscriptionStatus = s.Status,
InvoiceDueDate = s.CurrentPeriodEnd,
SubscriptionProducts = s.Items?.Data.Select(p => p.Plan.Id)
});
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var result = System.Text.Json.JsonSerializer.Serialize(fieldsToExport, options);
var bytes = Encoding.UTF8.GetBytes(result);
return File(bytes, "application/json", "StripeSubscriptionsSearch.json");
}
}

View File

@@ -52,8 +52,6 @@ public enum Permission
Tools_PromoteProviderServiceUser,
Tools_GenerateLicenseFile,
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction,
Tools_ProcessStripeEvents,
Tools_MigrateProviders
Tools_ProcessStripeEvents
}

View File

@@ -1,45 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Models.BitStripe;
namespace Bit.Admin.Models;
public class StripeSubscriptionRowModel
{
public Stripe.Subscription Subscription { get; set; }
public bool Selected { get; set; }
public StripeSubscriptionRowModel() { }
public StripeSubscriptionRowModel(Stripe.Subscription subscription)
{
Subscription = subscription;
}
}
public enum StripeSubscriptionsAction
{
Search,
PreviousPage,
NextPage,
Export,
BulkCancel
}
public class StripeSubscriptionsModel : IValidatableObject
{
public List<StripeSubscriptionRowModel> Items { get; set; }
public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search;
public string Message { get; set; }
public List<Stripe.Price> Prices { get; set; }
public List<Stripe.TestHelpers.TestClock> TestClocks { get; set; }
public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions();
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid")
{
yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions");
}
}
}

View File

@@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Providers.Migration;
#if !OSS
using Bit.Commercial.Core.Utilities;
@@ -92,7 +91,6 @@ public class Startup
services.AddDistributedCache(globalSettings);
services.AddBillingOperations();
services.AddHttpClient();
services.AddProviderMigration();
#if OSS
services.AddOosServices();

View File

@@ -52,8 +52,7 @@ public static class RolePermissionMapping
Permission.Tools_PromoteAdmin,
Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions
Permission.Tools_ManageTaxRates
}
},
{ "admin", new List<Permission>
@@ -105,7 +104,6 @@ public static class RolePermissionMapping
Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction
}
},
@@ -180,10 +178,8 @@ public static class RolePermissionMapping
Permission.Tools_ChargeBrainTreeCustomer,
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents,
Permission.Tools_MigrateProviders
Permission.Tools_ProcessStripeEvents
}
},
{ "sales", new List<Permission>

View File

@@ -13,12 +13,10 @@
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser ||
canGenerateLicense || canManageStripeSubscriptions;
canGenerateLicense;
}
<!DOCTYPE html>
@@ -102,12 +100,6 @@
<a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense">
Generate License
</a>
}
@if (canManageStripeSubscriptions)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">
Manage Stripe Subscriptions
</a>
}
@if (canProcessStripeEvents)
{
@@ -115,12 +107,6 @@
Process Stripe Events
</a>
}
@if (canMigrateProviders)
{
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
Migrate Providers
</a>
}
</ul>
</li>
}

View File

@@ -1,277 +0,0 @@
@model StripeSubscriptionsModel
@{
ViewData["Title"] = "Stripe Subscriptions";
}
@section Scripts {
<script>
function onRowSelect(selectingPage = false) {
let checkboxes = document.getElementsByClassName('row-check');
let checkedCheckboxCount = 0;
let bulkActions = document.getElementById('bulkActions');
let selectPage = document.getElementById('selectPage');
for(let i = 0; i < checkboxes.length; i++){
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
checkboxes[i].checked = true;
checkedCheckboxCount += 1;
} else {
checkboxes[i].checked = false;
}
}
if(checkedCheckboxCount > 0) {
bulkActions.classList.remove("d-none");
} else {
bulkActions.classList.add("d-none");
}
let selectAll = document.getElementById('selectAll');
if (checkedCheckboxCount === checkboxes.length) {
selectPage.checked = true;
selectAll.classList.remove("d-none");
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.remove('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.add('d-none');
} else {
selectPage.checked = false;
selectAll.classList.add("d-none");
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = false;
}
}
function onSelectAll() {
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = true;
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.add('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.remove('d-none');
}
function exportSelectedSubscriptions() {
let selectAll = document.getElementById('selectAll');
let httpRequest = new XMLHttpRequest();
httpRequest.open("POST");
httpRequest.send();
}
function cancelSelectedSubscriptions() {
}
</script>
}
<h2>Manage Stripe Subscriptions</h2>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert alert-success"></div>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group">
<div class="input-group-text">
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
<label class="form-check-label me-2" for="beforeRadio">Before</label>
</div>
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
<label class="form-check-label" for="afterRadio">After</label>
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
}
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
</div>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</div>
<hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
<i class="fa fa-check"></i> All subscriptions for this search are selected.
</span>
<div class="alert alert-warning mt-2" role="alert">
Please be aware that bulk operations may take several minutes to complete.
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>
<div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td>
@{
var i0 = i;
}
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
{
var i1 = i;
var j1 = j;
<input
type="hidden"
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
}
<div class="form-check">
@{
var i2 = i;
}
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex align-items-center">
<ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit"
class="page-link"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.PreviousPage">
Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage">
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex gap-2">
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
Export
</button>
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</span>
</span>
</nav>
</form>

View File

@@ -132,7 +132,7 @@ public class ProviderBillingController(
}
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] });
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] });
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Tax.Models;
@@ -10,7 +11,7 @@ namespace Bit.Api.Billing.Models.Responses;
public record ProviderSubscriptionResponse(
string Status,
DateTime CurrentPeriodEndDate,
DateTime? CurrentPeriodEndDate,
decimal? DiscountPercentage,
string CollectionMethod,
IEnumerable<ProviderPlanResponse> Plans,
@@ -51,10 +52,12 @@ public record ProviderSubscriptionResponse(
var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100;
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
return new ProviderSubscriptionResponse(
subscription.Status,
subscription.CurrentPeriodEnd,
subscription.Customer?.Discount?.Coupon?.PercentOff,
subscription.GetCurrentPeriodEnd(),
discount?.Coupon?.PercentOff,
subscription.CollectionMethod,
providerPlanResponses,
accountCredit,

View File

@@ -7,9 +7,7 @@ public class BillingSettings
{
public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret { get; set; }
public virtual string StripeWebhookSecret20231016 { get; set; }
public virtual string StripeWebhookSecret20240620 { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();

View File

@@ -120,9 +120,7 @@ public class StripeController : Controller
return deliveryContainer.ApiVersion switch
{
"2024-06-20" => HandleVersionWith(_billingSettings.StripeWebhookSecret20240620),
"2023-10-16" => HandleVersionWith(_billingSettings.StripeWebhookSecret20231016),
"2022-08-01" => HandleVersionWith(_billingSettings.StripeWebhookSecret),
"2025-08-27.basil" => HandleVersionWith(_billingSettings.StripeWebhookSecret20250827Basil),
_ => HandleDefault(deliveryContainer.ApiVersion)
};

View File

@@ -1,4 +1,5 @@
using Event = Stripe.Event;
using Bit.Core.Billing.Constants;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -35,13 +36,13 @@ public class InvoiceCreatedHandler(
if (usingPayPal && invoice is
{
AmountDue: > 0,
Paid: false,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
SubscriptionId: not null and not ""
Parent.SubscriptionDetails: not null
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);

View File

@@ -1,4 +1,5 @@
using Stripe;
using Bit.Core.Billing.Constants;
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -26,17 +27,20 @@ public class PaymentFailedHandler : IPaymentFailedHandler
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
if (invoice.Status == StripeConstants.InvoiceStatus.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
{
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
// attempt count 4 = 11 days after initial failure
if (invoice.AttemptCount <= 3 ||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
if (invoice.Parent?.SubscriptionDetails != null)
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
// attempt count 4 = 11 days after initial failure
if (invoice.AttemptCount <= 3 ||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
}
}
}
@@ -44,9 +48,9 @@ public class PaymentFailedHandler : IPaymentFailedHandler
invoice is
{
AmountDue: > 0,
Paid: false,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null
Parent.SubscriptionDetails: not null
};
}

View File

@@ -1,7 +1,9 @@
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -29,12 +31,17 @@ public class PaymentSucceededHandler(
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await stripeEventService.GetInvoice(parsedEvent, true);
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
if (invoice.Status != StripeConstants.InvoiceStatus.Paid || invoice.BillingReason != "subscription_create")
{
return;
}
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId);
if (invoice.Parent?.SubscriptionDetails == null)
{
return;
}
var subscription = await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active)
{
return;
@@ -96,7 +103,7 @@ public class PaymentSucceededHandler(
return;
}
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
organization = await organizationRepository.GetByIdAsync(organization.Id);
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
}
@@ -107,7 +114,7 @@ public class PaymentSucceededHandler(
return;
}
await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await userService.EnablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
}
}
}

View File

@@ -28,9 +28,14 @@ public class ProviderEventService(
return;
}
var invoice = await stripeEventService.GetInvoice(parsedEvent);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["discounts"]);
var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
if (invoice.Parent is not { Type: "subscription_details" })
{
return;
}
var metadata = (await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
var hasProviderId = metadata.TryGetValue("providerId", out var providerId);
@@ -68,7 +73,9 @@ public class ProviderEventService(
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
var discountedPercentage = (100 - totalPercentOff) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
@@ -96,7 +103,9 @@ public class ProviderEventService(
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
var discountedPercentage = (100 - totalPercentOff) / 100;
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;

View File

@@ -2,6 +2,7 @@
#nullable disable
using Bit.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -87,25 +88,6 @@ public class StripeEventUtilityService : IStripeEventUtilityService
/// <returns></returns>
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
{
Guid? organizationId = null;
Guid? userId = null;
Guid? providerId = null;
if (charge.InvoiceId != null)
{
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
if (invoice?.SubscriptionId != null)
{
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
}
}
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
return (organizationId, userId, providerId);
}
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
{
Customer = charge.CustomerId
@@ -118,7 +100,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
continue;
}
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
@@ -256,10 +238,10 @@ public class StripeEventUtilityService : IStripeEventUtilityService
invoice is
{
AmountDue: > 0,
Paid: false,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null
Parent.SubscriptionDetails: not null
};
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
@@ -272,7 +254,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService
return false;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
if (invoice.Parent?.SubscriptionDetails == null)
{
_logger.LogWarning("Invoice parent was not a subscription.");
return false;
}
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
{

View File

@@ -1,5 +1,6 @@
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -50,11 +51,11 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
return;
}
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
}
else if (userId.HasValue)
{
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
}
}
}

View File

@@ -5,6 +5,8 @@ using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@@ -82,12 +84,14 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
switch (subscription.Status)
{
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
when organizationId.HasValue:
{
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
{
@@ -114,7 +118,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
await VoidOpenInvoices(subscription.Id);
}
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
break;
}
@@ -154,7 +158,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{
if (userId.HasValue)
{
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd);
}
break;
}
@@ -162,17 +166,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
if (organizationId.HasValue)
{
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription))
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
{
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
}
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
}
else if (userId.HasValue)
{
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd);
}
}
@@ -280,9 +284,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
?.Coupon
?.Id == "sm-standalone";
var subscriptionHasSecretsManagerTrial = subscription.Discount
?.Coupon
?.Id == "sm-standalone";
var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id)
.Contains(StripeConstants.CouponIDs.SecretsManagerStandalone);
if (customerHasSecretsManagerTrial)
{

View File

@@ -36,17 +36,16 @@ public class UpcomingInvoiceHandler(
{
var invoice = await stripeEventService.GetInvoice(parsedEvent);
if (string.IsNullOrEmpty(invoice.SubscriptionId))
var customer =
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
var subscription = customer.Subscriptions.FirstOrDefault();
if (subscription == null)
{
logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
return;
}
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer.tax", "customer.tax_ids"]
});
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue)
@@ -58,7 +57,7 @@ public class UpcomingInvoiceHandler(
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id);
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -137,7 +136,7 @@ public class UpcomingInvoiceHandler(
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id);
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
}
@@ -199,13 +198,14 @@ public class UpcomingInvoiceHandler(
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
Customer customer,
string eventId)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
try
{
@@ -246,10 +246,11 @@ public class UpcomingInvoiceHandler(
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
Customer customer,
string eventId)
{
if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{
try
{

View File

@@ -57,9 +57,7 @@
"billingSettings": {
"jobsKey": "SECRET",
"stripeWebhookKey": "SECRET",
"stripeWebhookSecret": "SECRET",
"stripeWebhookSecret20231016": "SECRET",
"stripeWebhookSecret20240620": "SECRET",
"stripeWebhookSecret20250827Basil": "SECRET",
"bitPayWebhookKey": "SECRET",
"appleWebhookKey": "SECRET",
"payPal": {
@@ -87,6 +85,6 @@
"runSearch": "always",
"realTime": true
}
}
}
}
}

View File

@@ -64,10 +64,12 @@ public static class InvoiceExtensions
}
}
var tax = invoice.TotalTaxes?.Sum(invoiceTotalTax => invoiceTotalTax.Amount) ?? 0;
// Add fallback tax from invoice-level tax if present and not already included
if (invoice.Tax.HasValue && invoice.Tax.Value > 0)
if (tax > 0)
{
var taxAmount = invoice.Tax.Value / 100m;
var taxAmount = tax / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
}

View File

@@ -0,0 +1,25 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class SubscriptionExtensions
{
/*
* For the time being, this is the simplest migration approach from v45 to v48 as
* we do not support multi-cadence subscriptions. Each subscription item should be on the
* same billing cycle. If this changes, we'll need a significantly more robust approach.
*
* Because we can't guarantee a subscription will have items, this has to be nullable.
*/
public static (DateTime? Start, DateTime? End)? GetCurrentPeriod(this Subscription subscription)
{
var item = subscription.Items?.FirstOrDefault();
return item is null ? null : (item.CurrentPeriodStart, item.CurrentPeriodEnd);
}
public static DateTime? GetCurrentPeriodStart(this Subscription subscription) =>
subscription.Items?.FirstOrDefault()?.CurrentPeriodStart;
public static DateTime? GetCurrentPeriodEnd(this Subscription subscription) =>
subscription.Items?.FirstOrDefault()?.CurrentPeriodEnd;
}

View File

@@ -1,35 +0,0 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class UpcomingInvoiceOptionsExtensions
{
/// <summary>
/// Attempts to enable automatic tax for given upcoming invoice options.
/// </summary>
/// <param name="options"></param>
/// <param name="customer">The existing customer to which the upcoming invoice belongs.</param>
/// <param name="subscription">The existing subscription to which the upcoming invoice belongs.</param>
/// <returns>Returns true when successful, false when conditions are not met.</returns>
public static bool EnableAutomaticTax(
this UpcomingInvoiceOptions options,
Customer customer,
Subscription subscription)
{
if (subscription != null && subscription.AutomaticTax.Enabled)
{
return false;
}
// We might only need to check the automatic tax status.
if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country))
{
return false;
}
options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true };
options.SubscriptionDefaultTaxRates = [];
return true;
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;
@@ -46,7 +47,7 @@ public class BillingHistoryInfo
Url = inv.HostedInvoiceUrl;
PdfUrl = inv.InvoicePdf;
Number = inv.Number;
Paid = inv.Paid;
Paid = inv.Status == StripeConstants.InvoiceStatus.Paid;
Amount = inv.Total / 100M;
}

View File

@@ -75,7 +75,13 @@ public class PreviewOrganizationTaxCommand(
Quantity = purchase.SecretsManager.Seats
}
]);
options.Coupon = CouponIDs.SecretsManagerStandalone;
options.Discounts =
[
new InvoiceDiscountOptions
{
Coupon = CouponIDs.SecretsManagerStandalone
}
];
break;
default:
@@ -180,7 +186,10 @@ public class PreviewOrganizationTaxCommand(
if (subscription.Customer.Discount != null)
{
options.Coupon = subscription.Customer.Discount.Coupon.Id;
options.Discounts =
[
new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id }
];
}
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -277,7 +286,10 @@ public class PreviewOrganizationTaxCommand(
if (subscription.Customer.Discount != null)
{
options.Coupon = subscription.Customer.Discount.Coupon.Id;
options.Discounts =
[
new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id }
];
}
var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@@ -329,7 +341,7 @@ public class PreviewOrganizationTaxCommand(
});
private static (decimal, decimal) GetAmounts(Invoice invoice) => (
Convert.ToDecimal(invoice.Tax) / 100,
Convert.ToDecimal(invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
Convert.ToDecimal(invoice.Total) / 100);
private static InvoiceCreatePreviewOptions GetBaseOptions(

View File

@@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Organizations.Models;
public class OrganizationSale
{
private OrganizationSale() { }
internal OrganizationSale() { }
public void Deconstruct(
out Organization organization,

View File

@@ -162,17 +162,23 @@ public class GetOrganizationWarningsQuery(
if (subscription is
{
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
LatestInvoice: null or { Status: InvoiceStatus.Paid }
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14)
LatestInvoice: null or { Status: InvoiceStatus.Paid },
Items.Data.Count: > 0
})
{
return new ResellerRenewalWarning
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
if (currentPeriodEnd != null && (currentPeriodEnd.Value - now).TotalDays <= 14)
{
Type = "upcoming",
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
return new ResellerRenewalWarning
{
RenewalDate = subscription.CurrentPeriodEnd
}
};
Type = "upcoming",
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
{
RenewalDate = currentPeriodEnd.Value
}
};
}
}
if (subscription is

View File

@@ -45,12 +45,12 @@ public class OrganizationBillingService(
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
: await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, customerSetup?.Coupon);
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
{
organization.Enabled = true;
organization.ExpirationDate = subscription.CurrentPeriodEnd;
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
await organizationRepository.ReplaceAsync(organization);
}
}
@@ -187,7 +187,6 @@ public class OrganizationBillingService(
var customerCreateOptions = new CustomerCreateOptions
{
Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail,
Expand = ["tax", "tax_ids"],
@@ -273,7 +272,7 @@ public class OrganizationBillingService(
customerCreateOptions.TaxIdData =
[
new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId }
new CustomerTaxIdDataOptions { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId }
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
@@ -381,7 +380,8 @@ public class OrganizationBillingService(
private async Task<Subscription> CreateSubscriptionAsync(
Organization organization,
Customer customer,
SubscriptionSetup subscriptionSetup)
SubscriptionSetup subscriptionSetup,
string? coupon)
{
var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType);
@@ -444,6 +444,7 @@ public class OrganizationBillingService(
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Discounts = !string.IsNullOrEmpty(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon }] : null,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
@@ -459,8 +460,9 @@ public class OrganizationBillingService(
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
if (!hasPaymentMethod)
// Only set trial_settings.end_behavior.missing_payment_method to "cancel"
// if there is no payment method AND there's an actual trial period
if (!hasPaymentMethod && subscriptionCreateOptions.TrialPeriodDays > 0)
{
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
{

View File

@@ -1,6 +1,7 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@@ -87,7 +88,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}

View File

@@ -60,6 +60,6 @@ public class PreviewPremiumTaxCommand(
});
private static (decimal, decimal) GetAmounts(Invoice invoice) => (
Convert.ToDecimal(invoice.Tax) / 100,
Convert.ToDecimal(invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
Convert.ToDecimal(invoice.Total) / 100);
}

View File

@@ -1,26 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Billing.Providers.Migration.Models;
public enum ClientMigrationProgress
{
Started = 1,
MigrationRecordCreated = 2,
SubscriptionEnded = 3,
Completed = 4,
Reversing = 5,
ResetOrganization = 6,
RecreatedSubscription = 7,
RemovedMigrationRecord = 8,
Reversed = 9
}
public class ClientMigrationTracker
{
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public ClientMigrationProgress Progress { get; set; } = ClientMigrationProgress.Started;
}

View File

@@ -1,48 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Providers.Entities;
namespace Bit.Core.Billing.Providers.Migration.Models;
public class ProviderMigrationResult
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public string Result { get; set; }
public List<ClientMigrationResult> Clients { get; set; }
}
public class ClientMigrationResult
{
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public string Result { get; set; }
public ClientPreviousState PreviousState { get; set; }
}
public class ClientPreviousState
{
public ClientPreviousState() { }
public ClientPreviousState(ClientOrganizationMigrationRecord migrationRecord)
{
PlanType = migrationRecord.PlanType.ToString();
Seats = migrationRecord.Seats;
MaxStorageGb = migrationRecord.MaxStorageGb;
GatewayCustomerId = migrationRecord.GatewayCustomerId;
GatewaySubscriptionId = migrationRecord.GatewaySubscriptionId;
ExpirationDate = migrationRecord.ExpirationDate;
MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
Status = migrationRecord.Status.ToString();
}
public string PlanType { get; set; }
public int Seats { get; set; }
public short? MaxStorageGb { get; set; }
public string GatewayCustomerId { get; set; } = null!;
public string GatewaySubscriptionId { get; set; } = null!;
public DateTime? ExpirationDate { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public string Status { get; set; }
}

View File

@@ -1,25 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Billing.Providers.Migration.Models;
public enum ProviderMigrationProgress
{
Started = 1,
NoClients = 2,
ClientsMigrated = 3,
TeamsPlanConfigured = 4,
EnterprisePlanConfigured = 5,
CustomerSetup = 6,
SubscriptionSetup = 7,
CreditApplied = 8,
Completed = 9,
}
public class ProviderMigrationTracker
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public List<Guid> OrganizationIds { get; set; }
public ProviderMigrationProgress Progress { get; set; } = ProviderMigrationProgress.Started;
}

View File

@@ -1,15 +0,0 @@
using Bit.Core.Billing.Providers.Migration.Services;
using Bit.Core.Billing.Providers.Migration.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Providers.Migration;
public static class ServiceCollectionExtensions
{
public static void AddProviderMigration(this IServiceCollection services)
{
services.AddTransient<IMigrationTrackerCache, MigrationTrackerDistributedCache>();
services.AddTransient<IOrganizationMigrator, OrganizationMigrator>();
services.AddTransient<IProviderMigrator, ProviderMigrator>();
}
}

View File

@@ -1,17 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Providers.Migration.Models;
namespace Bit.Core.Billing.Providers.Migration.Services;
public interface IMigrationTrackerCache
{
Task StartTracker(Provider provider);
Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds);
Task<ProviderMigrationTracker> GetTracker(Guid providerId);
Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status);
Task StartTracker(Guid providerId, Organization organization);
Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId);
Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status);
}

View File

@@ -1,8 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Billing.Providers.Migration.Services;
public interface IOrganizationMigrator
{
Task Migrate(Guid providerId, Organization organization);
}

View File

@@ -1,10 +0,0 @@
using Bit.Core.Billing.Providers.Migration.Models;
namespace Bit.Core.Billing.Providers.Migration.Services;
public interface IProviderMigrator
{
Task Migrate(Guid providerId);
Task<ProviderMigrationResult> GetResult(Guid providerId);
}

View File

@@ -1,110 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Providers.Migration.Models;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Providers.Migration.Services.Implementations;
public class MigrationTrackerDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : IMigrationTrackerCache
{
public async Task StartTracker(Provider provider) =>
await SetAsync(new ProviderMigrationTracker
{
ProviderId = provider.Id,
ProviderName = provider.Name
});
public async Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds)
{
var tracker = await GetAsync(providerId);
tracker.OrganizationIds = organizationIds.ToList();
await SetAsync(tracker);
}
public Task<ProviderMigrationTracker> GetTracker(Guid providerId) => GetAsync(providerId);
public async Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status)
{
var tracker = await GetAsync(providerId);
tracker.Progress = status;
await SetAsync(tracker);
}
public async Task StartTracker(Guid providerId, Organization organization) =>
await SetAsync(new ClientMigrationTracker
{
ProviderId = providerId,
OrganizationId = organization.Id,
OrganizationName = organization.Name
});
public Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId) =>
GetAsync(providerId, organizationId);
public async Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status)
{
var tracker = await GetAsync(providerId, organizationId);
tracker.Progress = status;
await SetAsync(tracker);
}
private static string GetProviderCacheKey(Guid providerId) => $"provider_{providerId}_migration";
private static string GetClientCacheKey(Guid providerId, Guid clientId) =>
$"provider_{providerId}_client_{clientId}_migration";
private async Task<ProviderMigrationTracker> GetAsync(Guid providerId)
{
var cacheKey = GetProviderCacheKey(providerId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ProviderMigrationTracker>(json);
}
private async Task<ClientMigrationTracker> GetAsync(Guid providerId, Guid organizationId)
{
var cacheKey = GetClientCacheKey(providerId, organizationId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ClientMigrationTracker>(json);
}
private async Task SetAsync(ProviderMigrationTracker tracker)
{
var cacheKey = GetProviderCacheKey(tracker.ProviderId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
private async Task SetAsync(ClientMigrationTracker tracker)
{
var cacheKey = GetClientCacheKey(tracker.ProviderId, tracker.OrganizationId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
}

View File

@@ -1,331 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Core.Billing.Providers.Migration.Services.Implementations;
public class OrganizationMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
ILogger<OrganizationMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : IOrganizationMigrator
{
private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
public async Task Migrate(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Starting migration for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.StartTracker(providerId, organization);
await CreateMigrationRecordAsync(providerId, organization);
await CancelSubscriptionAsync(providerId, organization);
await UpdateOrganizationAsync(providerId, organization);
}
#region Steps
private async Task CreateMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Creating ClientOrganizationMigrationRecord for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
logger.LogInformation(
"CB: ClientOrganizationMigrationRecord already exists for organization ({OrganizationID}), deleting record",
organization.Id);
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
}
await clientOrganizationMigrationRecordRepository.CreateAsync(new ClientOrganizationMigrationRecord
{
OrganizationId = organization.Id,
ProviderId = providerId,
PlanType = organization.PlanType,
Seats = organization.Seats ?? 0,
MaxStorageGb = organization.MaxStorageGb,
GatewayCustomerId = organization.GatewayCustomerId!,
GatewaySubscriptionId = organization.GatewaySubscriptionId!,
ExpirationDate = organization.ExpirationDate,
MaxAutoscaleSeats = organization.MaxAutoscaleSeats,
Status = organization.Status
});
logger.LogInformation("CB: Created migration record for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.MigrationRecordCreated);
}
private async Task CancelSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Cancelling subscription for organization ({OrganizationID})", organization.Id);
var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.PastDue or
StripeConstants.SubscriptionStatus.Trialing
})
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = _cancellationComment
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});
logger.LogInformation("CB: Cancelled subscription for organization ({OrganizationID})", organization.Id);
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var trialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!trialing && subscription is { Status: StripeConstants.SubscriptionStatus.Canceled, CancellationDetails.Comment: _cancellationComment })
{
var latestInvoice = subscription.LatestInvoice;
if (latestInvoice.Status == "draft")
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(latestInvoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = true });
logger.LogInformation("CB: Finalized prorated invoice for organization ({OrganizationID})", organization.Id);
}
}
}
else
{
logger.LogInformation(
"CB: Did not need to cancel subscription for organization ({OrganizationID}) as it was inactive",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.SubscriptionEnded);
}
private async Task UpdateOrganizationAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management",
organization.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Brought organization ({OrganizationID}) under provider management",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.Completed);
}
#endregion
#region Reverse
private async Task RemoveMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Removing migration record for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
logger.LogInformation(
"CB: Removed migration record for organization ({OrganizationID})",
organization.Id);
}
else
{
logger.LogInformation("CB: Did not remove migration record for organization ({OrganizationID}) as it does not exist", organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, ClientMigrationProgress.Reversed);
}
private async Task RecreateSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Recreating subscription for organization ({OrganizationID})", organization.Id);
if (!string.IsNullOrEmpty(organization.GatewaySubscriptionId))
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogError(
"CB: Cannot recreate subscription for organization ({OrganizationID}) as it does not have a Stripe customer",
organization.Id);
throw new Exception();
}
var customer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId,
new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] });
var collectionMethod =
customer.DefaultSource != null ||
customer.InvoiceSettings?.DefaultPaymentMethod != null ||
customer.Metadata.ContainsKey(Utilities.BraintreeCustomerIdKey)
? StripeConstants.CollectionMethod.ChargeAutomatically
: StripeConstants.CollectionMethod.SendInvoice;
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var items = new List<SubscriptionItemOptions>
{
new ()
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = organization.Seats
}
};
if (organization.MaxStorageGb.HasValue && plan.PasswordManager.BaseStorageGb.HasValue && organization.MaxStorageGb.Value > plan.PasswordManager.BaseStorageGb.Value)
{
var additionalStorage = organization.MaxStorageGb.Value - plan.PasswordManager.BaseStorageGb.Value;
items.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = additionalStorage
});
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
Customer = customer.Id,
CollectionMethod = collectionMethod,
DaysUntilDue = collectionMethod == StripeConstants.CollectionMethod.SendInvoice ? 30 : null,
Items = items,
Metadata = new Dictionary<string, string>
{
[organization.GatewayIdField()] = organization.Id.ToString()
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = plan.TrialPeriodDays
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Recreated subscription for organization ({OrganizationID})", organization.Id);
}
else
{
logger.LogInformation(
"CB: Did not recreate subscription for organization ({OrganizationID}) as it already exists",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.RecreatedSubscription);
}
private async Task ReverseOrganizationUpdateAsync(Guid providerId, Organization organization)
{
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord == null)
{
logger.LogError(
"CB: Cannot reverse migration for organization ({OrganizationID}) as it does not have a migration record",
organization.Id);
throw new Exception();
}
var plan = await pricingClient.GetPlanOrThrow(migrationRecord.PlanType);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = migrationRecord.MaxStorageGb;
organization.ExpirationDate = migrationRecord.ExpirationDate;
organization.MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
organization.Status = migrationRecord.Status;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Reversed organization ({OrganizationID}) updates",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.ResetOrganization);
}
#endregion
#region Shared
private static void ResetOrganizationPlan(Organization organization, Plan plan)
{
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseOrganizationDomains = plan.HasOrganizationDomains;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
}
#endregion
}

View File

@@ -1,436 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
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.Models;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Providers.Migration.Services.Implementations;
public class ProviderMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
IOrganizationMigrator organizationMigrator,
ILogger<ProviderMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderPlanRepository providerPlanRepository,
IStripeAdapter stripeAdapter) : IProviderMigrator
{
public async Task Migrate(Guid providerId)
{
var provider = await GetProviderAsync(providerId);
if (provider == null)
{
return;
}
logger.LogInformation("CB: Starting migration for provider ({ProviderID})", providerId);
await migrationTrackerCache.StartTracker(provider);
var organizations = await GetClientsAsync(provider.Id);
if (organizations.Count == 0)
{
logger.LogInformation("CB: Skipping migration for provider ({ProviderID}) with no clients", providerId);
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.NoClients);
return;
}
await MigrateClientsAsync(providerId, organizations);
await ConfigureTeamsPlanAsync(providerId);
await ConfigureEnterprisePlanAsync(providerId);
await SetupCustomerAsync(provider);
await SetupSubscriptionAsync(provider);
await ApplyCreditAsync(provider);
await UpdateProviderAsync(provider);
}
public async Task<ProviderMigrationResult> GetResult(Guid providerId)
{
var providerTracker = await migrationTrackerCache.GetTracker(providerId);
if (providerTracker == null)
{
return null;
}
if (providerTracker.Progress == ProviderMigrationProgress.NoClients)
{
return new ProviderMigrationResult
{
ProviderId = providerTracker.ProviderId,
ProviderName = providerTracker.ProviderName,
Result = providerTracker.Progress.ToString()
};
}
var clientTrackers = await Task.WhenAll(providerTracker.OrganizationIds.Select(organizationId =>
migrationTrackerCache.GetTracker(providerId, organizationId)));
var migrationRecordLookup = new Dictionary<Guid, ClientOrganizationMigrationRecord>();
foreach (var clientTracker in clientTrackers)
{
var migrationRecord =
await clientOrganizationMigrationRecordRepository.GetByOrganizationId(clientTracker.OrganizationId);
migrationRecordLookup.Add(clientTracker.OrganizationId, migrationRecord);
}
return new ProviderMigrationResult
{
ProviderId = providerTracker.ProviderId,
ProviderName = providerTracker.ProviderName,
Result = providerTracker.Progress.ToString(),
Clients = clientTrackers.Select(tracker =>
{
var foundMigrationRecord = migrationRecordLookup.TryGetValue(tracker.OrganizationId, out var migrationRecord);
return new ClientMigrationResult
{
OrganizationId = tracker.OrganizationId,
OrganizationName = tracker.OrganizationName,
Result = tracker.Progress.ToString(),
PreviousState = foundMigrationRecord ? new ClientPreviousState(migrationRecord) : null
};
}).ToList(),
};
}
#region Steps
private async Task MigrateClientsAsync(Guid providerId, List<Organization> organizations)
{
logger.LogInformation("CB: Migrating clients for provider ({ProviderID})", providerId);
var organizationIds = organizations.Select(organization => organization.Id);
await migrationTrackerCache.SetOrganizationIds(providerId, organizationIds);
foreach (var organization in organizations)
{
var tracker = await migrationTrackerCache.GetTracker(providerId, organization.Id);
if (tracker is not { Progress: ClientMigrationProgress.Completed })
{
await organizationMigrator.Migrate(providerId, organization);
}
}
logger.LogInformation("CB: Migrated clients for provider ({ProviderID})", providerId);
await migrationTrackerCache.UpdateTrackingStatus(providerId,
ProviderMigrationProgress.ClientsMigrated);
}
private async Task ConfigureTeamsPlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Teams plan for provider ({ProviderID})", providerId);
var organizations = await GetClientsAsync(providerId);
var teamsSeats = organizations
.Where(IsTeams)
.Sum(client => client.Seats) ?? 0;
var teamsProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = teamsSeats,
PurchasedSeats = 0,
AllocatedSeats = teamsSeats
});
logger.LogInformation("CB: Created Teams plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, teamsSeats);
}
else
{
logger.LogInformation("CB: Teams plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
teamsProviderPlan.SeatMinimum = teamsSeats;
teamsProviderPlan.AllocatedSeats = teamsSeats;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
logger.LogInformation("CB: Updated Teams plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, teamsProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.TeamsPlanConfigured);
}
private async Task ConfigureEnterprisePlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Enterprise plan for provider ({ProviderID})", providerId);
var organizations = await GetClientsAsync(providerId);
var enterpriseSeats = organizations
.Where(IsEnterprise)
.Sum(client => client.Seats) ?? 0;
var enterpriseProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = enterpriseSeats,
PurchasedSeats = 0,
AllocatedSeats = enterpriseSeats
});
logger.LogInformation("CB: Created Enterprise plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, enterpriseSeats);
}
else
{
logger.LogInformation("CB: Enterprise plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
enterpriseProviderPlan.SeatMinimum = enterpriseSeats;
enterpriseProviderPlan.AllocatedSeats = enterpriseSeats;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
logger.LogInformation("CB: Updated Enterprise plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, enterpriseProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.EnterprisePlanConfigured);
}
private async Task SetupCustomerAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var organizations = await GetClientsAsync(provider.Id);
var sampleOrganization = organizations.FirstOrDefault(organization => !string.IsNullOrEmpty(organization.GatewayCustomerId));
if (sampleOrganization == null)
{
logger.LogInformation(
"CB: Could not find sample organization for provider ({ProviderID}) that has a Stripe customer",
provider.Id);
return;
}
var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization);
// Create dummy payment source for legacy migration - this migrator is deprecated and will be removed
var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token");
var customer = await providerBillingService.SetupCustomer(provider, null, null);
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Coupon = StripeConstants.CouponIDs.LegacyMSPDiscount
});
provider.GatewayCustomerId = customer.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe customer for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation("CB: Stripe customer already exists for provider ({ProviderID})", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CustomerSetup);
}
private async Task SetupSubscriptionAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewaySubscriptionId))
{
if (!string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var subscription = await providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe subscription for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation(
"CB: Could not set up Stripe subscription for provider ({ProviderID}) with no Stripe customer",
provider.Id);
return;
}
}
else
{
logger.LogInformation("CB: Stripe subscription already exists for provider ({ProviderID})", provider.Id);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var enterpriseSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly)?
.SeatMinimum ?? 0;
var teamsSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.SeatMinimum ?? 0;
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider,
[
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
]);
await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand);
logger.LogInformation(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.SubscriptionSetup);
}
private async Task ApplyCreditAsync(Provider provider)
{
var organizations = await GetClientsAsync(provider.Id);
var organizationCustomers =
await Task.WhenAll(organizations.Select(organization => stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId)));
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
if (organizationCancellationCredit != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = organizationCancellationCredit,
Currency = "USD",
Description = "Unused, prorated time for client organization subscriptions."
});
}
var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));
var legacyOrganizationMigrationRecords = migrationRecords.Where(migrationRecord =>
migrationRecord.PlanType is
PlanType.EnterpriseAnnually2020 or
PlanType.TeamsAnnually2020);
var legacyOrganizationCredit = legacyOrganizationMigrationRecords.Sum(migrationRecord => migrationRecord.Seats) * 12 * -100;
if (legacyOrganizationCredit < 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = legacyOrganizationCredit,
Currency = "USD",
Description = "1 year rebate for legacy client organizations."
});
}
logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit + legacyOrganizationCredit, provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CreditApplied);
}
private async Task UpdateProviderAsync(Provider provider)
{
provider.Status = ProviderStatusType.Billable;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Completed migration for provider ({ProviderID})", provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.Completed);
}
#endregion
#region Utilities
private async Task<List<Organization>> GetClientsAsync(Guid providerId)
{
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
return (await Task.WhenAll(providerOrganizations.Select(providerOrganization =>
organizationRepository.GetByIdAsync(providerOrganization.OrganizationId))))
.ToList();
}
private async Task<Provider> GetProviderAsync(Guid providerId)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it does not exist", providerId);
return null;
}
if (provider.Type != ProviderType.Msp)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not an MSP", providerId);
return null;
}
if (provider.Status == ProviderStatusType.Created)
{
return provider;
}
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not in the 'Created' state", providerId);
return null;
}
private static bool IsEnterprise(Organization organization) => organization.Plan.Contains("Enterprise");
private static bool IsTeams(Organization organization) => organization.Plan.Contains("Teams");
#endregion
}

View File

@@ -3,6 +3,7 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
@@ -108,7 +109,7 @@ public class PremiumUserBillingService(
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Repositories;
@@ -65,7 +66,7 @@ public class RestartSubscriptionCommand(
{
organization.GatewaySubscriptionId = subscription.Id;
organization.Enabled = true;
organization.ExpirationDate = subscription.CurrentPeriodEnd;
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
organization.RevisionDate = DateTime.UtcNow;
await organizationRepository.ReplaceAsync(organization);
break;
@@ -82,7 +83,7 @@ public class RestartSubscriptionCommand(
{
user.GatewaySubscriptionId = subscription.Id;
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
user.RevisionDate = DateTime.UtcNow;
await userRepository.ReplaceAsync(user);
break;

View File

@@ -57,7 +57,7 @@
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Braintree" Version="5.28.0" />
<PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Stripe.net" Version="48.5.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Extensions;
using Stripe;
namespace Bit.Core.Models.Business;
@@ -36,8 +37,13 @@ public class SubscriptionInfo
Status = sub.Status;
TrialStartDate = sub.TrialStart;
TrialEndDate = sub.TrialEnd;
PeriodStartDate = sub.CurrentPeriodStart;
PeriodEndDate = sub.CurrentPeriodEnd;
var currentPeriod = sub.GetCurrentPeriod();
if (currentPeriod != null)
{
var (start, end) = currentPeriod.Value;
PeriodStartDate = start;
PeriodEndDate = end;
}
CancelledDate = sub.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd;
Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired";

View File

@@ -1,51 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Core.Models.BitStripe;
// Stripe's SubscriptionListOptions model has a complex input for date filters.
// It expects a dictionary, and has lots of validation rules around what can have a value and what can't.
// To simplify this a bit we are extending Stripe's model and using our own date inputs, and building the dictionary they expect JiT.
// ___
// Our model also facilitates selecting all elements in a list, which is unsupported by Stripe's model.
public class StripeSubscriptionListOptions : Stripe.SubscriptionListOptions
{
public DateTime? CurrentPeriodEndDate { get; set; }
public string CurrentPeriodEndRange { get; set; } = "lt";
public bool SelectAll { get; set; }
public new Stripe.DateRangeOptions CurrentPeriodEnd
{
get
{
return CurrentPeriodEndDate.HasValue ?
new Stripe.DateRangeOptions()
{
LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null,
GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null
} :
null;
}
}
public Stripe.SubscriptionListOptions ToStripeApiOptions()
{
var stripeApiOptions = (Stripe.SubscriptionListOptions)this;
if (SelectAll)
{
stripeApiOptions.EndingBefore = null;
stripeApiOptions.StartingAfter = null;
}
if (CurrentPeriodEndDate.HasValue)
{
stripeApiOptions.CurrentPeriodEnd = new Stripe.DateRangeOptions()
{
LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null,
GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null
};
}
return stripeApiOptions;
}
}

View File

@@ -3,58 +3,47 @@
using Bit.Core.Models.BitStripe;
using Stripe;
using Stripe.Tax;
namespace Bit.Core.Services;
public interface IStripeAdapter
{
Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions customerCreateOptions);
Task<Stripe.Customer> CustomerGetAsync(string id, Stripe.CustomerGetOptions options = null);
Task<Stripe.Customer> CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null);
Task<Stripe.Customer> CustomerDeleteAsync(string id);
Task<List<PaymentMethod>> CustomerListPaymentMethods(string id, CustomerListPaymentMethodsOptions options = null);
Task<Customer> CustomerCreateAsync(CustomerCreateOptions customerCreateOptions);
Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null);
Task<Customer> CustomerGetAsync(string id, CustomerGetOptions options = null);
Task<Customer> CustomerUpdateAsync(string id, CustomerUpdateOptions options = null);
Task<Customer> CustomerDeleteAsync(string id);
Task<List<PaymentMethod>> CustomerListPaymentMethods(string id, CustomerPaymentMethodListOptions options = null);
Task<CustomerBalanceTransaction> CustomerBalanceTransactionCreate(string customerId,
CustomerBalanceTransactionCreateOptions options);
Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions);
Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null);
/// <summary>
/// Retrieves a subscription object for a provider.
/// </summary>
/// <param name="id">The subscription ID.</param>
/// <param name="providerId">The provider ID.</param>
/// <param name="options">Additional options.</param>
/// <returns>The subscription object.</returns>
/// <exception cref="InvalidOperationException">Thrown when the subscription doesn't belong to the provider.</exception>
Task<Stripe.Subscription> ProviderSubscriptionGetAsync(string id, Guid providerId, Stripe.SubscriptionGetOptions options = null);
Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions);
Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null);
Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null);
Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options);
Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options);
Task<List<Stripe.Invoice>> InvoiceListAsync(StripeInvoiceListOptions options);
Task<Stripe.Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions 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);
Task<Stripe.Invoice> InvoicePayAsync(string id, Stripe.InvoicePayOptions options = null);
Task<Stripe.Invoice> InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null);
Task<Stripe.Invoice> InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null);
IEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options);
IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options);
Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null);
Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null);
Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options);
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.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options);
Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null);
Task<Stripe.BankAccount> BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null);
Task<Stripe.BankAccount> BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null);
Task<Stripe.StripeList<Stripe.Price>> PriceListAsync(Stripe.PriceListOptions options = null);
Task<Subscription> SubscriptionCreateAsync(SubscriptionCreateOptions subscriptionCreateOptions);
Task<Subscription> SubscriptionGetAsync(string id, SubscriptionGetOptions options = null);
Task<Subscription> SubscriptionUpdateAsync(string id, SubscriptionUpdateOptions options = null);
Task<Subscription> SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null);
Task<Invoice> InvoiceGetAsync(string id, InvoiceGetOptions options);
Task<List<Invoice>> InvoiceListAsync(StripeInvoiceListOptions options);
Task<Invoice> InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options);
Task<List<Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options);
Task<Invoice> InvoiceUpdateAsync(string id, InvoiceUpdateOptions options);
Task<Invoice> InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options);
Task<Invoice> InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options);
Task<Invoice> InvoicePayAsync(string id, InvoicePayOptions options = null);
Task<Invoice> InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null);
Task<Invoice> InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null);
IEnumerable<PaymentMethod> PaymentMethodListAutoPaging(PaymentMethodListOptions options);
IAsyncEnumerable<PaymentMethod> PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options);
Task<PaymentMethod> PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null);
Task<PaymentMethod> PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null);
Task<TaxId> TaxIdCreateAsync(string id, TaxIdCreateOptions options);
Task<TaxId> TaxIdDeleteAsync(string customerId, string taxIdId, TaxIdDeleteOptions options = null);
Task<StripeList<Registration>> TaxRegistrationsListAsync(RegistrationListOptions options = null);
Task<StripeList<Charge>> ChargeListAsync(ChargeListOptions options);
Task<Refund> RefundCreateAsync(RefundCreateOptions options);
Task<Card> CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null);
Task<BankAccount> BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null);
Task<BankAccount> BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null);
Task<StripeList<Price>> PriceListAsync(PriceListOptions options = null);
Task<SetupIntent> SetupIntentCreate(SetupIntentCreateOptions options);
Task<List<SetupIntent>> SetupIntentList(SetupIntentListOptions options);
Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null);

View File

@@ -9,18 +9,18 @@ namespace Bit.Core.Services;
public class StripeAdapter : IStripeAdapter
{
private readonly Stripe.CustomerService _customerService;
private readonly Stripe.SubscriptionService _subscriptionService;
private readonly Stripe.InvoiceService _invoiceService;
private readonly Stripe.PaymentMethodService _paymentMethodService;
private readonly Stripe.TaxIdService _taxIdService;
private readonly Stripe.ChargeService _chargeService;
private readonly Stripe.RefundService _refundService;
private readonly Stripe.CardService _cardService;
private readonly Stripe.BankAccountService _bankAccountService;
private readonly Stripe.PlanService _planService;
private readonly Stripe.PriceService _priceService;
private readonly Stripe.SetupIntentService _setupIntentService;
private readonly CustomerService _customerService;
private readonly SubscriptionService _subscriptionService;
private readonly InvoiceService _invoiceService;
private readonly PaymentMethodService _paymentMethodService;
private readonly TaxIdService _taxIdService;
private readonly ChargeService _chargeService;
private readonly RefundService _refundService;
private readonly CardService _cardService;
private readonly BankAccountService _bankAccountService;
private readonly PlanService _planService;
private readonly PriceService _priceService;
private readonly SetupIntentService _setupIntentService;
private readonly Stripe.TestHelpers.TestClockService _testClockService;
private readonly CustomerBalanceTransactionService _customerBalanceTransactionService;
private readonly Stripe.Tax.RegistrationService _taxRegistrationService;
@@ -28,17 +28,17 @@ public class StripeAdapter : IStripeAdapter
public StripeAdapter()
{
_customerService = new Stripe.CustomerService();
_subscriptionService = new Stripe.SubscriptionService();
_invoiceService = new Stripe.InvoiceService();
_paymentMethodService = new Stripe.PaymentMethodService();
_taxIdService = new Stripe.TaxIdService();
_chargeService = new Stripe.ChargeService();
_refundService = new Stripe.RefundService();
_cardService = new Stripe.CardService();
_bankAccountService = new Stripe.BankAccountService();
_priceService = new Stripe.PriceService();
_planService = new Stripe.PlanService();
_customerService = new CustomerService();
_subscriptionService = new SubscriptionService();
_invoiceService = new InvoiceService();
_paymentMethodService = new PaymentMethodService();
_taxIdService = new TaxIdService();
_chargeService = new ChargeService();
_refundService = new RefundService();
_cardService = new CardService();
_bankAccountService = new BankAccountService();
_priceService = new PriceService();
_planService = new PlanService();
_setupIntentService = new SetupIntentService();
_testClockService = new Stripe.TestHelpers.TestClockService();
_customerBalanceTransactionService = new CustomerBalanceTransactionService();
@@ -46,28 +46,31 @@ public class StripeAdapter : IStripeAdapter
_calculationService = new CalculationService();
}
public Task<Stripe.Customer> CustomerCreateAsync(Stripe.CustomerCreateOptions options)
public Task<Customer> CustomerCreateAsync(CustomerCreateOptions options)
{
return _customerService.CreateAsync(options);
}
public Task<Stripe.Customer> CustomerGetAsync(string id, Stripe.CustomerGetOptions options = null)
public Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null) =>
_customerService.DeleteDiscountAsync(customerId, options);
public Task<Customer> CustomerGetAsync(string id, CustomerGetOptions options = null)
{
return _customerService.GetAsync(id, options);
}
public Task<Stripe.Customer> CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null)
public Task<Customer> CustomerUpdateAsync(string id, CustomerUpdateOptions options = null)
{
return _customerService.UpdateAsync(id, options);
}
public Task<Stripe.Customer> CustomerDeleteAsync(string id)
public Task<Customer> CustomerDeleteAsync(string id)
{
return _customerService.DeleteAsync(id);
}
public async Task<List<PaymentMethod>> CustomerListPaymentMethods(string id,
CustomerListPaymentMethodsOptions options = null)
CustomerPaymentMethodListOptions options = null)
{
var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options);
return paymentMethods.Data;
@@ -77,12 +80,12 @@ public class StripeAdapter : IStripeAdapter
CustomerBalanceTransactionCreateOptions options)
=> await _customerBalanceTransactionService.CreateAsync(customerId, options);
public Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions options)
public Task<Subscription> SubscriptionCreateAsync(SubscriptionCreateOptions options)
{
return _subscriptionService.CreateAsync(options);
}
public Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null)
public Task<Subscription> SubscriptionGetAsync(string id, SubscriptionGetOptions options = null)
{
return _subscriptionService.GetAsync(id, options);
}
@@ -101,28 +104,23 @@ public class StripeAdapter : IStripeAdapter
throw new InvalidOperationException("Subscription does not belong to the provider.");
}
public Task<Stripe.Subscription> SubscriptionUpdateAsync(string id,
Stripe.SubscriptionUpdateOptions options = null)
public Task<Subscription> SubscriptionUpdateAsync(string id,
SubscriptionUpdateOptions options = null)
{
return _subscriptionService.UpdateAsync(id, options);
}
public Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null)
public Task<Subscription> SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null)
{
return _subscriptionService.CancelAsync(Id, options);
}
public Task<Stripe.Invoice> InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options)
{
return _invoiceService.UpcomingAsync(options);
}
public Task<Stripe.Invoice> InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options)
public Task<Invoice> InvoiceGetAsync(string id, InvoiceGetOptions options)
{
return _invoiceService.GetAsync(id, options);
}
public async Task<List<Stripe.Invoice>> InvoiceListAsync(StripeInvoiceListOptions options)
public async Task<List<Invoice>> InvoiceListAsync(StripeInvoiceListOptions options)
{
if (!options.SelectAll)
{
@@ -131,7 +129,7 @@ public class StripeAdapter : IStripeAdapter
options.Limit = 100;
var invoices = new List<Stripe.Invoice>();
var invoices = new List<Invoice>();
await foreach (var invoice in _invoiceService.ListAutoPagingAsync(options.ToInvoiceListOptions()))
{
@@ -146,120 +144,104 @@ public class StripeAdapter : IStripeAdapter
return _invoiceService.CreatePreviewAsync(options);
}
public async Task<List<Stripe.Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options)
public async Task<List<Invoice>> InvoiceSearchAsync(InvoiceSearchOptions options)
=> (await _invoiceService.SearchAsync(options)).Data;
public Task<Stripe.Invoice> InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options)
public Task<Invoice> InvoiceUpdateAsync(string id, InvoiceUpdateOptions options)
{
return _invoiceService.UpdateAsync(id, options);
}
public Task<Stripe.Invoice> InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options)
public Task<Invoice> InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options)
{
return _invoiceService.FinalizeInvoiceAsync(id, options);
}
public Task<Stripe.Invoice> InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options)
public Task<Invoice> InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options)
{
return _invoiceService.SendInvoiceAsync(id, options);
}
public Task<Stripe.Invoice> InvoicePayAsync(string id, Stripe.InvoicePayOptions options = null)
public Task<Invoice> InvoicePayAsync(string id, InvoicePayOptions options = null)
{
return _invoiceService.PayAsync(id, options);
}
public Task<Stripe.Invoice> InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null)
public Task<Invoice> InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null)
{
return _invoiceService.DeleteAsync(id, options);
}
public Task<Stripe.Invoice> InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null)
public Task<Invoice> InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null)
{
return _invoiceService.VoidInvoiceAsync(id, options);
}
public IEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options)
public IEnumerable<PaymentMethod> PaymentMethodListAutoPaging(PaymentMethodListOptions options)
{
return _paymentMethodService.ListAutoPaging(options);
}
public IAsyncEnumerable<Stripe.PaymentMethod> PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options)
public IAsyncEnumerable<PaymentMethod> PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options)
=> _paymentMethodService.ListAutoPagingAsync(options);
public Task<Stripe.PaymentMethod> PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null)
public Task<PaymentMethod> PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null)
{
return _paymentMethodService.AttachAsync(id, options);
}
public Task<Stripe.PaymentMethod> PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null)
public Task<PaymentMethod> PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null)
{
return _paymentMethodService.DetachAsync(id, options);
}
public Task<Stripe.Plan> PlanGetAsync(string id, Stripe.PlanGetOptions options = null)
public Task<Plan> PlanGetAsync(string id, PlanGetOptions options = null)
{
return _planService.GetAsync(id, options);
}
public Task<Stripe.TaxId> TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options)
public Task<TaxId> TaxIdCreateAsync(string id, TaxIdCreateOptions options)
{
return _taxIdService.CreateAsync(id, options);
}
public Task<Stripe.TaxId> TaxIdDeleteAsync(string customerId, string taxIdId,
Stripe.TaxIdDeleteOptions options = null)
public Task<TaxId> TaxIdDeleteAsync(string customerId, string taxIdId,
TaxIdDeleteOptions options = null)
{
return _taxIdService.DeleteAsync(customerId, taxIdId);
}
public Task<Stripe.StripeList<Stripe.Tax.Registration>> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null)
public Task<StripeList<Registration>> TaxRegistrationsListAsync(RegistrationListOptions options = null)
{
return _taxRegistrationService.ListAsync(options);
}
public Task<Stripe.StripeList<Stripe.Charge>> ChargeListAsync(Stripe.ChargeListOptions options)
public Task<StripeList<Charge>> ChargeListAsync(ChargeListOptions options)
{
return _chargeService.ListAsync(options);
}
public Task<Stripe.Refund> RefundCreateAsync(Stripe.RefundCreateOptions options)
public Task<Refund> RefundCreateAsync(RefundCreateOptions options)
{
return _refundService.CreateAsync(options);
}
public Task<Stripe.Card> CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null)
public Task<Card> CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null)
{
return _cardService.DeleteAsync(customerId, cardId, options);
}
public Task<Stripe.BankAccount> BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null)
public Task<BankAccount> BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null)
{
return _bankAccountService.CreateAsync(customerId, options);
}
public Task<Stripe.BankAccount> BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null)
public Task<BankAccount> BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null)
{
return _bankAccountService.DeleteAsync(customerId, bankAccount, options);
}
public async Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions options)
{
if (!options.SelectAll)
{
return (await _subscriptionService.ListAsync(options.ToStripeApiOptions())).Data;
}
options.Limit = 100;
var items = new List<Stripe.Subscription>();
await foreach (var i in _subscriptionService.ListAutoPagingAsync(options.ToStripeApiOptions()))
{
items.Add(i);
}
return items;
}
public async Task<Stripe.StripeList<Stripe.Price>> PriceListAsync(Stripe.PriceListOptions options = null)
public async Task<StripeList<Price>> PriceListAsync(PriceListOptions options = null)
{
return await _priceService.ListAsync(options);
}

View File

@@ -65,19 +65,20 @@ public class StripePaymentService : IPaymentService
bool applySponsorship)
{
var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType);
var sponsoredPlan = sponsorship?.PlanSponsorshipType != null ?
Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) :
null;
var subscriptionUpdate = new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship);
var sponsoredPlan = sponsorship?.PlanSponsorshipType != null
? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)
: null;
var subscriptionUpdate =
new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship);
await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, true);
var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId);
org.ExpirationDate = sub.CurrentPeriodEnd;
org.ExpirationDate = sub.GetCurrentPeriodEnd();
if (sponsorship is not null)
{
sponsorship.ValidUntil = sub.CurrentPeriodEnd;
sponsorship.ValidUntil = sub.GetCurrentPeriodEnd();
}
}
@@ -100,7 +101,8 @@ public class StripePaymentService : IPaymentService
if (sub.Status == SubscriptionStatuses.Canceled)
{
throw new BadRequestException("You do not have an active subscription. Reinstate your subscription to make changes.");
throw new BadRequestException(
"You do not have an active subscription. Reinstate your subscription to make changes.");
}
var existingCoupon = sub.Customer.Discount?.Coupon?.Id;
@@ -191,24 +193,24 @@ public class StripePaymentService : IPaymentService
throw;
}
}
else if (!invoice.Paid)
else if (invoice.Status != StripeConstants.InvoiceStatus.Paid)
{
// Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h
invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId);
paymentIntentClientSecret = null;
}
}
finally
{
// Change back the subscription collection method and/or days until due
if (collectionMethod != "send_invoice" || daysUntilDue == null)
{
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
});
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
new SubscriptionUpdateOptions
{
CollectionMethod = collectionMethod,
DaysUntilDue = daysUntilDue,
});
}
var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId);
@@ -218,9 +220,15 @@ public class StripePaymentService : IPaymentService
if (!string.IsNullOrEmpty(existingCoupon) && string.IsNullOrEmpty(newCoupon))
{
// Re-add the lost coupon due to the update.
await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, new CustomerUpdateOptions
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
Coupon = existingCoupon
Discounts =
[
new SubscriptionDiscountOptions
{
Coupon = existingCoupon
}
]
});
}
}
@@ -352,7 +360,7 @@ public class StripePaymentService : IPaymentService
{
var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card";
var hasDefaultValidSource = customer.DefaultSource != null &&
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount);
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount);
if (!hasDefaultCardPaymentMethod && !hasDefaultValidSource)
{
cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id;
@@ -365,12 +373,11 @@ public class StripePaymentService : IPaymentService
}
catch
{
await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions
{
AutoAdvance = false
});
await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = false });
await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id);
}
throw new BadRequestException("No payment method is available.");
}
}
@@ -381,14 +388,9 @@ public class StripePaymentService : IPaymentService
{
// Finalize the invoice (from Draft) w/o auto-advance so we
// can attempt payment manually.
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions
{
AutoAdvance = false,
});
var invoicePayOptions = new InvoicePayOptions
{
PaymentMethod = cardPaymentMethodId,
};
invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = false, });
var invoicePayOptions = new InvoicePayOptions { PaymentMethod = cardPaymentMethodId, };
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
{
invoicePayOptions.PaidOutOfBand = true;
@@ -403,13 +405,15 @@ public class StripePaymentService : IPaymentService
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}"
CustomField =
$"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion
[subscriber.BraintreeCloudRegionField()] =
_globalSettings.BaseServiceUri.CloudRegion
}
});
@@ -442,9 +446,9 @@ public class StripePaymentService : IPaymentService
{
// SCA required, get intent client secret
var invoiceGetOptions = new InvoiceGetOptions();
invoiceGetOptions.AddExpand("payment_intent");
invoiceGetOptions.AddExpand("confirmation_secret");
invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions);
paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret;
paymentIntentClientSecret = invoice?.ConfirmationSecret?.ClientSecret;
}
else
{
@@ -458,6 +462,7 @@ public class StripePaymentService : IPaymentService
{
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
}
if (invoice != null)
{
if (invoice.Status == "paid")
@@ -479,10 +484,8 @@ public class StripePaymentService : IPaymentService
// Assumption: Customer balance should now be $0, otherwise payment would not have failed.
if (customer.Balance == 0)
{
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = invoice.StartingBalance
});
await _stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { Balance = invoice.StartingBalance });
}
}
}
@@ -496,6 +499,7 @@ public class StripePaymentService : IPaymentService
// Let the caller perform any subscription change cleanup
throw;
}
return paymentIntentClientSecret;
}
@@ -526,10 +530,10 @@ public class StripePaymentService : IPaymentService
try
{
var canceledSub = endOfPeriod ?
await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) :
await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions());
var canceledSub = endOfPeriod
? await _stripeAdapter.SubscriptionUpdateAsync(sub.Id,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true })
: await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions());
if (!canceledSub.CanceledAt.HasValue)
{
throw new GatewayException("Unable to cancel subscription.");
@@ -580,7 +584,7 @@ public class StripePaymentService : IPaymentService
{
Customer customer = null;
var customerExists = subscriber.Gateway == GatewayType.Stripe &&
!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId);
!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId);
if (customerExists)
{
customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId);
@@ -595,10 +599,10 @@ public class StripePaymentService : IPaymentService
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = customer.Id;
}
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance - (long)(creditAmount * 100)
});
await _stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { Balance = customer.Balance - (long)(creditAmount * 100) });
return !customerExists;
}
@@ -630,50 +634,45 @@ public class StripePaymentService : IPaymentService
{
var subscriptionInfo = new SubscriptionInfo();
if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
var customerGetOptions = new CustomerGetOptions();
customerGetOptions.AddExpand("discount.coupon.applies_to");
var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer.Discount != null)
{
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount);
}
}
if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId))
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
return subscriptionInfo;
}
var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer", "discounts", "test_clock"] });
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription);
var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault();
if (discount != null)
{
Expand = ["test_clock"]
});
if (sub != null)
{
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub);
var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(sub);
if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue)
{
subscriptionInfo.Subscription.SuspensionDate = suspensionDate;
subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate;
}
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(discount);
}
if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(subscription);
if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue)
{
subscriptionInfo.Subscription.SuspensionDate = suspensionDate;
subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate;
}
if (subscription is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
{
return subscriptionInfo;
}
try
{
var upcomingInvoiceOptions = new UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId };
var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions);
var invoiceCreatePreviewOptions = new InvoiceCreatePreviewOptions
{
Customer = subscriber.GatewayCustomerId,
Subscription = subscriber.GatewaySubscriptionId
};
var upcomingInvoice = await _stripeAdapter.InvoiceCreatePreviewAsync(invoiceCreatePreviewOptions);
if (upcomingInvoice != null)
{
@@ -682,7 +681,12 @@ public class StripePaymentService : IPaymentService
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Encountered an unexpected Stripe error");
_logger.LogWarning(
ex,
"Failed to retrieve upcoming invoice for customer {CustomerId}, subscription {SubscriptionId}. Error Code: {ErrorCode}",
subscriber.GatewayCustomerId,
subscriber.GatewaySubscriptionId,
ex.StripeError?.Code);
}
return subscriptionInfo;
@@ -788,7 +792,11 @@ public class StripePaymentService : IPaymentService
if (taxInfo.TaxIdType == StripeConstants.TaxIdType.SpanishNIF)
{
await _stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInfo.TaxIdNumber}" });
new TaxIdCreateOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{taxInfo.TaxIdNumber}"
});
}
}
catch (StripeException e)
@@ -829,7 +837,8 @@ public class StripePaymentService : IPaymentService
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
organizationHasSecretsManager: organization.UseSecretsManager);
private async Task<bool> HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager)
private async Task<bool> HasSecretsManagerStandaloneAsync(string gatewayCustomerId,
bool organizationHasSecretsManager)
{
if (string.IsNullOrEmpty(gatewayCustomerId))
{
@@ -894,26 +903,14 @@ public class StripePaymentService : IPaymentService
{
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = true,
},
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, },
Currency = "usd",
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items =
[
new()
{
Quantity = 1,
Plan = StripeConstants.Prices.PremiumAnnually
},
new()
{
Quantity = parameters.PasswordManager.AdditionalStorage,
Plan = "storage-gb-annually"
}
new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = StripeConstants.Prices.PremiumAnnually },
new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = StripeConstants.Prices.StoragePlanPersonal }
]
},
CustomerDetails = new InvoiceCustomerDetailsOptions
@@ -940,12 +937,9 @@ public class StripePaymentService : IPaymentService
throw new BadRequestException("billingPreviewInvalidTaxIdError");
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
{
Type = taxIdType,
Value = parameters.TaxInformation.TaxId
}
options.CustomerDetails.TaxIds =
[
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId }
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
@@ -964,7 +958,7 @@ public class StripePaymentService : IPaymentService
if (gatewayCustomer.Discount != null)
{
options.Coupon = gatewayCustomer.Discount.Coupon.Id;
options.Discounts = [new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }];
}
}
@@ -972,24 +966,31 @@ public class StripePaymentService : IPaymentService
{
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
if (gatewaySubscription?.Discount != null)
if (gatewaySubscription?.Discounts is { Count: > 0 })
{
options.Coupon ??= gatewaySubscription.Discount.Coupon.Id;
options.Discounts = gatewaySubscription.Discounts.Select(x => new InvoiceDiscountOptions { Coupon = x.Coupon.Id }).ToList();
}
}
if (options.Discounts is { Count: > 0 })
{
options.Discounts = options.Discounts.DistinctBy(invoiceDiscountOptions => invoiceDiscountOptions.Coupon).ToList();
}
try
{
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount);
var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M;
var result = new PreviewInvoiceResponseModel(
effectiveTaxRate,
invoice.TotalExcludingTax.ToMajor() ?? 0,
invoice.Tax.ToMajor() ?? 0,
tax.ToMajor(),
invoice.Total.ToMajor());
return result;
}
@@ -1003,7 +1004,8 @@ public class StripePaymentService : IPaymentService
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvalidTaxIdError");
default:
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
_logger.LogError(e,
"Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvoiceError");
@@ -1101,12 +1103,9 @@ public class StripePaymentService : IPaymentService
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.CustomerDetails.TaxIds = [
new InvoiceCustomerDetailsTaxIdOptions
{
Type = taxIdType,
Value = parameters.TaxInformation.TaxId
}
options.CustomerDetails.TaxIds =
[
new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId }
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
@@ -1127,7 +1126,10 @@ public class StripePaymentService : IPaymentService
if (gatewayCustomer.Discount != null)
{
options.Coupon = gatewayCustomer.Discount.Coupon.Id;
options.Discounts =
[
new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }
];
}
}
@@ -1135,9 +1137,10 @@ public class StripePaymentService : IPaymentService
{
var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId);
if (gatewaySubscription?.Discount != null)
if (gatewaySubscription?.Discounts != null)
{
options.Coupon ??= gatewaySubscription.Discount.Coupon.Id;
options.Discounts = gatewaySubscription.Discounts
.Select(discount => new InvoiceDiscountOptions { Coupon = discount.Coupon.Id }).ToList();
}
}
@@ -1152,14 +1155,16 @@ public class StripePaymentService : IPaymentService
{
var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options);
var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount);
var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0
? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor()
: 0M;
var result = new PreviewInvoiceResponseModel(
effectiveTaxRate,
invoice.TotalExcludingTax.ToMajor() ?? 0,
invoice.Tax.ToMajor() ?? 0,
tax.ToMajor(),
invoice.Total.ToMajor());
return result;
}
@@ -1173,7 +1178,8 @@ public class StripePaymentService : IPaymentService
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvalidTaxIdError");
default:
_logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
_logger.LogError(e,
"Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.",
parameters.TaxInformation.TaxId,
parameters.TaxInformation.Country);
throw new BadRequestException("billingPreviewInvoiceError");
@@ -1207,7 +1213,9 @@ public class StripePaymentService : IPaymentService
braintreeCustomer.DefaultPaymentMethod);
}
}
catch (Braintree.Exceptions.NotFoundException) { }
catch (Braintree.Exceptions.NotFoundException)
{
}
}
if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
@@ -1246,12 +1254,15 @@ public class StripePaymentService : IPaymentService
{
customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options);
}
catch (StripeException) { }
catch (StripeException)
{
}
return customer;
}
private async Task<IEnumerable<BillingHistoryInfo.BillingTransaction>> GetBillingTransactionsAsync(ISubscriber subscriber, int? limit = null)
private async Task<IEnumerable<BillingHistoryInfo.BillingTransaction>> GetBillingTransactionsAsync(
ISubscriber subscriber, int? limit = null)
{
var transactions = subscriber switch
{

View File

@@ -17,7 +17,4 @@
<SuppressTSqlWarnings>71502</SuppressTSqlWarnings>
</Build>
</ItemGroup>
<ItemGroup>
<Folder Include="Billing\dbo\" />
</ItemGroup>
</Project>