mirror of
https://github.com/bitwarden/server.git
synced 2025-12-10 00:42:07 -06: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:
parent
6324f692b8
commit
9c51c9971b
@ -148,22 +148,30 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
}
|
||||
else if (organization.IsStripeEnabled())
|
||||
{
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
if (subscription.Customer.Discount?.Coupon != null)
|
||||
{
|
||||
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
|
||||
}
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30
|
||||
DaysUntilDue = 30,
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentSource(organization);
|
||||
|
||||
@ -481,7 +481,6 @@ public class ProviderBillingService(
|
||||
City = billingAddress.City,
|
||||
State = billingAddress.State
|
||||
},
|
||||
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
@ -663,6 +662,7 @@ public class ProviderBillingService(
|
||||
: CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
|
||||
OffSession = true,
|
||||
@ -671,7 +671,6 @@ public class ProviderBillingService(
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
};
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
@ -156,16 +156,18 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Contains("customer")))
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
|
||||
|
||||
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
@ -368,10 +370,21 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
|
||||
private static Subscription GetSubscription(string subscriptionId) =>
|
||||
private static Subscription GetSubscription(string subscriptionId, string customerId) =>
|
||||
new()
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "coupon-id"
|
||||
}
|
||||
}
|
||||
},
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
|
||||
@ -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()
|
||||
: [];
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,8 +52,6 @@ public enum Permission
|
||||
Tools_PromoteProviderServiceUser,
|
||||
Tools_GenerateLicenseFile,
|
||||
Tools_ManageTaxRates,
|
||||
Tools_ManageStripeSubscriptions,
|
||||
Tools_CreateEditTransaction,
|
||||
Tools_ProcessStripeEvents,
|
||||
Tools_MigrateProviders
|
||||
Tools_ProcessStripeEvents
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
|
||||
|
||||
25
src/Core/Billing/Extensions/SubscriptionExtensions.cs
Normal file
25
src/Core/Billing/Extensions/SubscriptionExtensions.cs
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Organizations.Models;
|
||||
|
||||
public class OrganizationSale
|
||||
{
|
||||
private OrganizationSale() { }
|
||||
internal OrganizationSale() { }
|
||||
|
||||
public void Deconstruct(
|
||||
out Organization organization,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -17,7 +17,4 @@
|
||||
<SuppressTSqlWarnings>71502</SuppressTSqlWarnings>
|
||||
</Build>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\dbo\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
@ -270,7 +271,6 @@ public class ProviderBillingControllerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
@ -291,20 +291,23 @@ public class ProviderBillingControllerTests
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
|
||||
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
|
||||
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams }
|
||||
}
|
||||
]
|
||||
},
|
||||
Status = "unpaid",
|
||||
Status = "unpaid"
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
|
||||
options =>
|
||||
options.Expand.Contains("customer.tax_ids") &&
|
||||
options.Expand.Contains("discounts") &&
|
||||
options.Expand.Contains("test_clock"))).Returns(subscription);
|
||||
|
||||
var daysInLastMonth = DateTime.DaysInMonth(oneMonthAgo.Year, oneMonthAgo.Month);
|
||||
@ -365,7 +368,7 @@ public class ProviderBillingControllerTests
|
||||
var response = ((Ok<ProviderSubscriptionResponse>)result).Value;
|
||||
|
||||
Assert.Equal(subscription.Status, response.Status);
|
||||
Assert.Equal(subscription.CurrentPeriodEnd, response.CurrentPeriodEndDate);
|
||||
Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate);
|
||||
Assert.Equal(subscription.Customer!.Discount!.Coupon!.PercentOff, response.DiscountPercentage);
|
||||
Assert.Equal(subscription.CollectionMethod, response.CollectionMethod);
|
||||
|
||||
@ -405,6 +408,118 @@ public class ProviderBillingControllerTests
|
||||
Assert.Equal(14, response.Suspension.GracePeriod);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetSubscriptionAsync_SubscriptionLevelDiscount_Ok(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingController> sutProvider)
|
||||
{
|
||||
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var oneMonthAgo = now.AddMonths(-1);
|
||||
|
||||
var daysInThisMonth = DateTime.DaysInMonth(now.Year, now.Month);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
Customer = new Customer
|
||||
{
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345",
|
||||
Line1 = "123 Example St.",
|
||||
Line2 = "Unit 1",
|
||||
City = "Example Town",
|
||||
State = "NY"
|
||||
},
|
||||
Balance = -100000,
|
||||
Discount = null, // No customer-level discount
|
||||
TaxIds = new StripeList<TaxId> { Data = [new TaxId { Value = "123456789" }] }
|
||||
},
|
||||
Discounts =
|
||||
[
|
||||
new Discount { Coupon = new Coupon { PercentOff = 15 } } // Subscription-level discount
|
||||
],
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
|
||||
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
|
||||
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams }
|
||||
}
|
||||
]
|
||||
},
|
||||
Status = "active"
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
|
||||
options =>
|
||||
options.Expand.Contains("customer.tax_ids") &&
|
||||
options.Expand.Contains("discounts") &&
|
||||
options.Expand.Contains("test_clock"))).Returns(subscription);
|
||||
|
||||
stripeAdapter.InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(
|
||||
options => options.Query == $"subscription:'{subscription.Id}' status:'open'"))
|
||||
.Returns([]);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 50,
|
||||
PurchasedSeats = 10,
|
||||
AllocatedSeats = 60
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 90
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(providerPlan.PlanType).Returns(plan);
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
sutProvider.GetDependency<IStripeAdapter>().PriceGetAsync(priceId)
|
||||
.Returns(new Price
|
||||
{
|
||||
UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100
|
||||
});
|
||||
}
|
||||
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id);
|
||||
|
||||
Assert.IsType<Ok<ProviderSubscriptionResponse>>(result);
|
||||
|
||||
var response = ((Ok<ProviderSubscriptionResponse>)result).Value;
|
||||
|
||||
Assert.Equal(subscription.Status, response.Status);
|
||||
Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate);
|
||||
Assert.Equal(15, response.DiscountPercentage); // Verify subscription-level discount is used
|
||||
Assert.Equal(subscription.CollectionMethod, response.CollectionMethod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateTaxInformationAsync
|
||||
|
||||
@ -27,24 +27,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\Events\charge.succeeded.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\Events\customer.subscription.updated.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\Events\customer.updated.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\Events\invoice.created.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\Events\invoice.upcoming.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\Events\payment_method.attached.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Resources\IPN\echeck-payment.txt">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
@ -73,9 +55,6 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
<None Remove="Resources\Events\invoice.finalized.json" />
|
||||
<EmbeddedResource Include="Resources\Events\invoice.finalized.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -1,130 +0,0 @@
|
||||
{
|
||||
"id": "evt_3NvKgBIGBnsLynRr0pJJqudS",
|
||||
"object": "event",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695909300,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "ch_3NvKgBIGBnsLynRr0ZyvP9AN",
|
||||
"object": "charge",
|
||||
"amount": 7200,
|
||||
"amount_captured": 7200,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_3NvKgBIGBnsLynRr0KbYEz76",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "BITWARDEN",
|
||||
"captured": true,
|
||||
"created": 1695909299,
|
||||
"currency": "usd",
|
||||
"customer": "cus_OimAwOzQmThNXx",
|
||||
"description": "Subscription update",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_balance_transaction": null,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {
|
||||
},
|
||||
"invoice": "in_1NvKgBIGBnsLynRrmRFHAcoV",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 37,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_3NvKgBIGBnsLynRr09Ny3Heu",
|
||||
"payment_method": "pm_1NvKbpIGBnsLynRrcOwez4A1",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"amount_authorized": 7200,
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 6,
|
||||
"exp_year": 2033,
|
||||
"extended_authorization": {
|
||||
"status": "disabled"
|
||||
},
|
||||
"fingerprint": "0VgUBpvqcUUnuSmK",
|
||||
"funding": "credit",
|
||||
"incremental_authorization": {
|
||||
"status": "unavailable"
|
||||
},
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"mandate": null,
|
||||
"multicapture": {
|
||||
"status": "unavailable"
|
||||
},
|
||||
"network": "visa",
|
||||
"network_token": {
|
||||
"used": false
|
||||
},
|
||||
"overcapture": {
|
||||
"maximum_amount_capturable": 7200,
|
||||
"status": "unavailable"
|
||||
},
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "cturnbull@bitwarden.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xOXNtSVhJR0Juc0x5blJyKLSL1qgGMgYTnk_JOUA6LBY_SDEZNtuae1guQ6Dlcuev1TUHwn712t-UNnZdIc383zS15bXv_1dby8e4?s=ap",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_3NvKgBIGBnsLynRr0ZyvP9AN/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 9,
|
||||
"request": {
|
||||
"id": "req_rig8N5Ca8EXYRy",
|
||||
"idempotency_key": "db75068d-5d90-4c65-a410-4e2ed8347509"
|
||||
},
|
||||
"type": "charge.succeeded"
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
{
|
||||
"id": "evt_1NvLMDIGBnsLynRr6oBxebrE",
|
||||
"object": "event",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695911902,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "sub_1NvKoKIGBnsLynRrcLIAUWGf",
|
||||
"object": "subscription",
|
||||
"application": null,
|
||||
"application_fee_percent": null,
|
||||
"automatic_tax": {
|
||||
"enabled": false
|
||||
},
|
||||
"billing_cycle_anchor": 1695911900,
|
||||
"billing_thresholds": null,
|
||||
"cancel_at": null,
|
||||
"cancel_at_period_end": false,
|
||||
"canceled_at": null,
|
||||
"cancellation_details": {
|
||||
"comment": null,
|
||||
"feedback": null,
|
||||
"reason": null
|
||||
},
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1695909804,
|
||||
"currency": "usd",
|
||||
"current_period_end": 1727534300,
|
||||
"current_period_start": 1695911900,
|
||||
"customer": "cus_OimNNCC3RiI2HQ",
|
||||
"days_until_due": null,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"ended_at": null,
|
||||
"items": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "si_OimNgVtrESpqus",
|
||||
"object": "subscription_item",
|
||||
"billing_thresholds": null,
|
||||
"created": 1695909805,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "enterprise-org-seat-annually",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 3600,
|
||||
"amount_decimal": "3600",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1494268677,
|
||||
"currency": "usd",
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "2019 Enterprise Seat (Annually)",
|
||||
"product": "prod_BUtogGemxnTi9z",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"price": {
|
||||
"id": "enterprise-org-seat-annually",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1494268677,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "2019 Enterprise Seat (Annually)",
|
||||
"product": "prod_BUtogGemxnTi9z",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 3600,
|
||||
"unit_amount_decimal": "3600"
|
||||
},
|
||||
"quantity": 1,
|
||||
"subscription": "sub_1NvKoKIGBnsLynRrcLIAUWGf",
|
||||
"tax_rates": [
|
||||
]
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/subscription_items?subscription=sub_1NvKoKIGBnsLynRrcLIAUWGf"
|
||||
},
|
||||
"latest_invoice": "in_1NvLM9IGBnsLynRrOysII07d",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"organizationId": "84a569ea-4643-474a-83a9-b08b00e7a20d"
|
||||
},
|
||||
"next_pending_invoice_item_invoice": null,
|
||||
"on_behalf_of": null,
|
||||
"pause_collection": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null,
|
||||
"save_default_payment_method": "off"
|
||||
},
|
||||
"pending_invoice_item_interval": null,
|
||||
"pending_setup_intent": null,
|
||||
"pending_update": null,
|
||||
"plan": {
|
||||
"id": "enterprise-org-seat-annually",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 3600,
|
||||
"amount_decimal": "3600",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1494268677,
|
||||
"currency": "usd",
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "2019 Enterprise Seat (Annually)",
|
||||
"product": "prod_BUtogGemxnTi9z",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"schedule": null,
|
||||
"start_date": 1695909804,
|
||||
"status": "active",
|
||||
"test_clock": null,
|
||||
"transfer_data": null,
|
||||
"trial_end": 1695911899,
|
||||
"trial_settings": {
|
||||
"end_behavior": {
|
||||
"missing_payment_method": "create_invoice"
|
||||
}
|
||||
},
|
||||
"trial_start": 1695909804
|
||||
},
|
||||
"previous_attributes": {
|
||||
"billing_cycle_anchor": 1696514604,
|
||||
"current_period_end": 1696514604,
|
||||
"current_period_start": 1695909804,
|
||||
"latest_invoice": "in_1NvKoKIGBnsLynRrSNRC6oYI",
|
||||
"status": "trialing",
|
||||
"trial_end": 1696514604
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 8,
|
||||
"request": {
|
||||
"id": "req_DMZPUU3BI66zAx",
|
||||
"idempotency_key": "3fd8b4a5-6a20-46ab-9f45-b37b02a8017f"
|
||||
},
|
||||
"type": "customer.subscription.updated"
|
||||
}
|
||||
@ -1,311 +0,0 @@
|
||||
{
|
||||
"id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ",
|
||||
"object": "event",
|
||||
"account": "acct_19smIXIGBnsLynRr",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695909502,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "cus_Of54kUr3gV88lM",
|
||||
"object": "customer",
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": "US",
|
||||
"line1": "",
|
||||
"line2": null,
|
||||
"postal_code": "33701",
|
||||
"state": null
|
||||
},
|
||||
"balance": 0,
|
||||
"created": 1695056798,
|
||||
"currency": "usd",
|
||||
"default_source": "src_1NtAfeIGBnsLynRrYDrceax7",
|
||||
"delinquent": false,
|
||||
"description": "Premium User",
|
||||
"discount": null,
|
||||
"email": "premium@bitwarden.com",
|
||||
"invoice_prefix": "C506E8CE",
|
||||
"invoice_settings": {
|
||||
"custom_fields": [
|
||||
{
|
||||
"name": "Subscriber",
|
||||
"value": "Premium User"
|
||||
}
|
||||
],
|
||||
"default_payment_method": "pm_1Nrku9IGBnsLynRrcsQ3hy6C",
|
||||
"footer": null,
|
||||
"rendering_options": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"region": "US"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 2,
|
||||
"phone": null,
|
||||
"preferred_locales": [
|
||||
],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none",
|
||||
"test_clock": null,
|
||||
"account_balance": 0,
|
||||
"cards": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/cards"
|
||||
},
|
||||
"default_card": null,
|
||||
"default_currency": "usd",
|
||||
"sources": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "src_1NtAfeIGBnsLynRrYDrceax7",
|
||||
"object": "source",
|
||||
"ach_credit_transfer": {
|
||||
"account_number": "test_b2d1c6415f6f",
|
||||
"routing_number": "110000000",
|
||||
"fingerprint": "ePO4hBQanSft3gvU",
|
||||
"swift_code": "TSTEZ122",
|
||||
"bank_name": "TEST BANK",
|
||||
"refund_routing_number": null,
|
||||
"refund_account_holder_type": null,
|
||||
"refund_account_holder_name": null
|
||||
},
|
||||
"amount": null,
|
||||
"client_secret": "src_client_secret_bUAP2uDRw6Pwj0xYk32LmJ3K",
|
||||
"created": 1695394170,
|
||||
"currency": "usd",
|
||||
"customer": "cus_Of54kUr3gV88lM",
|
||||
"flow": "receiver",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"owner": {
|
||||
"address": null,
|
||||
"email": "amount_0@stripe.com",
|
||||
"name": null,
|
||||
"phone": null,
|
||||
"verified_address": null,
|
||||
"verified_email": null,
|
||||
"verified_name": null,
|
||||
"verified_phone": null
|
||||
},
|
||||
"receiver": {
|
||||
"address": "110000000-test_b2d1c6415f6f",
|
||||
"amount_charged": 0,
|
||||
"amount_received": 0,
|
||||
"amount_returned": 0,
|
||||
"refund_attributes_method": "email",
|
||||
"refund_attributes_status": "missing"
|
||||
},
|
||||
"statement_descriptor": null,
|
||||
"status": "pending",
|
||||
"type": "ach_credit_transfer",
|
||||
"usage": "reusable"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/sources"
|
||||
},
|
||||
"subscriptions": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "sub_1NrkuBIGBnsLynRrzjFGIjEw",
|
||||
"object": "subscription",
|
||||
"application": null,
|
||||
"application_fee_percent": null,
|
||||
"automatic_tax": {
|
||||
"enabled": false
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_cycle_anchor": 1695056799,
|
||||
"billing_thresholds": null,
|
||||
"cancel_at": null,
|
||||
"cancel_at_period_end": false,
|
||||
"canceled_at": null,
|
||||
"cancellation_details": {
|
||||
"comment": null,
|
||||
"feedback": null,
|
||||
"reason": null
|
||||
},
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1695056799,
|
||||
"currency": "usd",
|
||||
"current_period_end": 1726679199,
|
||||
"current_period_start": 1695056799,
|
||||
"customer": "cus_Of54kUr3gV88lM",
|
||||
"days_until_due": null,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"ended_at": null,
|
||||
"invoice_customer_balance_settings": {
|
||||
"consume_applied_balance_on_void": true
|
||||
},
|
||||
"items": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "si_Of54i3aK9I5Wro",
|
||||
"object": "subscription_item",
|
||||
"billing_thresholds": null,
|
||||
"created": 1695056800,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "premium-annually",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"amount_decimal": "1000",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1499289328,
|
||||
"currency": "usd",
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"name": "Premium (Annually)",
|
||||
"nickname": "Premium (Annually)",
|
||||
"product": "prod_BUqgYr48VzDuCg",
|
||||
"statement_description": null,
|
||||
"statement_descriptor": null,
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"price": {
|
||||
"id": "premium-annually",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1499289328,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "Premium (Annually)",
|
||||
"product": "prod_BUqgYr48VzDuCg",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 1000,
|
||||
"unit_amount_decimal": "1000"
|
||||
},
|
||||
"quantity": 1,
|
||||
"subscription": "sub_1NrkuBIGBnsLynRrzjFGIjEw",
|
||||
"tax_rates": [
|
||||
]
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/subscription_items?subscription=sub_1NrkuBIGBnsLynRrzjFGIjEw"
|
||||
},
|
||||
"latest_invoice": "in_1NrkuBIGBnsLynRr40gyJTVU",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"userId": "91f40b6d-ac3b-4348-804b-b0810119ac6a"
|
||||
},
|
||||
"next_pending_invoice_item_invoice": null,
|
||||
"on_behalf_of": null,
|
||||
"pause_collection": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null,
|
||||
"save_default_payment_method": "off"
|
||||
},
|
||||
"pending_invoice_item_interval": null,
|
||||
"pending_setup_intent": null,
|
||||
"pending_update": null,
|
||||
"plan": {
|
||||
"id": "premium-annually",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"amount_decimal": "1000",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1499289328,
|
||||
"currency": "usd",
|
||||
"interval": "year",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"name": "Premium (Annually)",
|
||||
"nickname": "Premium (Annually)",
|
||||
"product": "prod_BUqgYr48VzDuCg",
|
||||
"statement_description": null,
|
||||
"statement_descriptor": null,
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"schedule": null,
|
||||
"start": 1695056799,
|
||||
"start_date": 1695056799,
|
||||
"status": "active",
|
||||
"tax_percent": null,
|
||||
"test_clock": null,
|
||||
"transfer_data": null,
|
||||
"trial_end": null,
|
||||
"trial_settings": {
|
||||
"end_behavior": {
|
||||
"missing_payment_method": "create_invoice"
|
||||
}
|
||||
},
|
||||
"trial_start": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/subscriptions"
|
||||
},
|
||||
"tax_ids": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/tax_ids"
|
||||
},
|
||||
"tax_info": null,
|
||||
"tax_info_verification": null
|
||||
},
|
||||
"previous_attributes": {
|
||||
"email": "premium-new@bitwarden.com"
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 5,
|
||||
"request": "req_2RtGdXCfiicFLx",
|
||||
"type": "customer.updated",
|
||||
"user_id": "acct_19smIXIGBnsLynRr"
|
||||
}
|
||||
@ -1,222 +0,0 @@
|
||||
{
|
||||
"id": "evt_1NvKzfIGBnsLynRr0SkwrlkE",
|
||||
"object": "event",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695910506,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "in_1NvKzdIGBnsLynRr8fE8cpbg",
|
||||
"object": "invoice",
|
||||
"account_country": "US",
|
||||
"account_name": "Bitwarden Inc.",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"amount_shipping": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "subscription_create",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1695910505,
|
||||
"currency": "usd",
|
||||
"custom_fields": [
|
||||
{
|
||||
"name": "Organization",
|
||||
"value": "teams 2023 monthly - 2"
|
||||
}
|
||||
],
|
||||
"customer": "cus_OimYrxnMTMMK1E",
|
||||
"customer_address": {
|
||||
"city": null,
|
||||
"country": "US",
|
||||
"line1": "",
|
||||
"line2": null,
|
||||
"postal_code": "12345",
|
||||
"state": null
|
||||
},
|
||||
"customer_email": "cturnbull@bitwarden.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [
|
||||
],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [
|
||||
],
|
||||
"due_date": null,
|
||||
"effective_at": 1695910505,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"from_invoice": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2?s=ap",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2/pdf?s=ap",
|
||||
"last_finalization_error": null,
|
||||
"latest_revision": null,
|
||||
"lines": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "il_1NvKzdIGBnsLynRr2pS4ZA8e",
|
||||
"object": "line_item",
|
||||
"amount": 0,
|
||||
"amount_excluding_tax": 0,
|
||||
"currency": "usd",
|
||||
"description": "Trial period for Teams Organization Seat",
|
||||
"discount_amounts": [
|
||||
],
|
||||
"discountable": true,
|
||||
"discounts": [
|
||||
],
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a"
|
||||
},
|
||||
"period": {
|
||||
"end": 1696515305,
|
||||
"start": 1695910505
|
||||
},
|
||||
"plan": {
|
||||
"id": "2020-teams-org-seat-monthly",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 400,
|
||||
"amount_decimal": "400",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1595263113,
|
||||
"currency": "usd",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "Teams Organization Seat (Monthly) 2023",
|
||||
"product": "prod_HgOooYXDr2DDAA",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"price": {
|
||||
"id": "2020-teams-org-seat-monthly",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1595263113,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "Teams Organization Seat (Monthly) 2023",
|
||||
"product": "prod_HgOooYXDr2DDAA",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 400,
|
||||
"unit_amount_decimal": "400"
|
||||
},
|
||||
"proration": false,
|
||||
"proration_details": {
|
||||
"credited_items": null
|
||||
},
|
||||
"quantity": 1,
|
||||
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc",
|
||||
"subscription_item": "si_OimYNSbvuqdtTr",
|
||||
"tax_amounts": [
|
||||
],
|
||||
"tax_rates": [
|
||||
],
|
||||
"type": "subscription",
|
||||
"unit_amount_excluding_tax": "0"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/in_1NvKzdIGBnsLynRr8fE8cpbg/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"next_payment_attempt": null,
|
||||
"number": "3E96D078-0001",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"paid_out_of_band": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"default_mandate": null,
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1695910505,
|
||||
"period_start": 1695910505,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"rendering": null,
|
||||
"rendering_options": null,
|
||||
"shipping_cost": null,
|
||||
"shipping_details": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": null,
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1695910505,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1695910505,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc",
|
||||
"subscription_details": {
|
||||
"metadata": {
|
||||
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a"
|
||||
}
|
||||
},
|
||||
"subtotal": 0,
|
||||
"subtotal_excluding_tax": 0,
|
||||
"tax": null,
|
||||
"test_clock": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [
|
||||
],
|
||||
"total_excluding_tax": 0,
|
||||
"total_tax_amounts": [
|
||||
],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 8,
|
||||
"request": {
|
||||
"id": "req_roIwONfgyfZdr4",
|
||||
"idempotency_key": "dd2a171b-b9c7-4d2d-89d5-1ceae3c0595d"
|
||||
},
|
||||
"type": "invoice.created"
|
||||
}
|
||||
@ -1,400 +0,0 @@
|
||||
{
|
||||
"id": "evt_1PQaABIGBnsLynRrhoJjGnyz",
|
||||
"object": "event",
|
||||
"account": "acct_19smIXIGBnsLynRr",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1718133319,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "in_1PQa9fIGBnsLynRraYIqTdBs",
|
||||
"object": "invoice",
|
||||
"account_country": "US",
|
||||
"account_name": "Bitwarden Inc.",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 84240,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 84240,
|
||||
"amount_shipping": 0,
|
||||
"application": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": true,
|
||||
"liability": {
|
||||
"type": "self"
|
||||
},
|
||||
"status": "complete"
|
||||
},
|
||||
"billing_reason": "subscription_update",
|
||||
"charge": null,
|
||||
"collection_method": "send_invoice",
|
||||
"created": 1718133291,
|
||||
"currency": "usd",
|
||||
"custom_fields": [
|
||||
{
|
||||
"name": "Provider",
|
||||
"value": "MSP"
|
||||
}
|
||||
],
|
||||
"customer": "cus_QH8QVKyTh2lfcG",
|
||||
"customer_address": {
|
||||
"city": null,
|
||||
"country": "US",
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": "12345",
|
||||
"state": null
|
||||
},
|
||||
"customer_email": "billing@msp.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [
|
||||
],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"description": null,
|
||||
"discount": {
|
||||
"id": "di_1PQa9eIGBnsLynRrwwYr2bGD",
|
||||
"object": "discount",
|
||||
"checkout_session": null,
|
||||
"coupon": {
|
||||
"id": "msp-discount-35",
|
||||
"object": "coupon",
|
||||
"amount_off": null,
|
||||
"created": 1678805729,
|
||||
"currency": null,
|
||||
"duration": "forever",
|
||||
"duration_in_months": null,
|
||||
"livemode": false,
|
||||
"max_redemptions": null,
|
||||
"metadata": {
|
||||
},
|
||||
"name": "MSP Discount - 35%",
|
||||
"percent_off": 35,
|
||||
"redeem_by": null,
|
||||
"times_redeemed": 515,
|
||||
"valid": true,
|
||||
"percent_off_precise": 35
|
||||
},
|
||||
"customer": "cus_QH8QVKyTh2lfcG",
|
||||
"end": null,
|
||||
"invoice": null,
|
||||
"invoice_item": null,
|
||||
"promotion_code": null,
|
||||
"start": 1718133290,
|
||||
"subscription": null,
|
||||
"subscription_item": null
|
||||
},
|
||||
"discounts": [
|
||||
"di_1PQa9eIGBnsLynRrwwYr2bGD"
|
||||
],
|
||||
"due_date": 1720725291,
|
||||
"effective_at": 1718136893,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"from_invoice": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw?s=ap",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw/pdf?s=ap",
|
||||
"issuer": {
|
||||
"type": "self"
|
||||
},
|
||||
"last_finalization_error": null,
|
||||
"latest_revision": null,
|
||||
"lines": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
|
||||
"object": "line_item",
|
||||
"amount": 50000,
|
||||
"amount_excluding_tax": 50000,
|
||||
"currency": "usd",
|
||||
"description": null,
|
||||
"discount_amounts": [
|
||||
{
|
||||
"amount": 17500,
|
||||
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
|
||||
}
|
||||
],
|
||||
"discountable": true,
|
||||
"discounts": [
|
||||
],
|
||||
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"period": {
|
||||
"end": 1720725291,
|
||||
"start": 1718133291
|
||||
},
|
||||
"plan": {
|
||||
"id": "2023-teams-org-seat-monthly",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 500,
|
||||
"amount_decimal": "500",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1695839010,
|
||||
"currency": "usd",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"meter": null,
|
||||
"nickname": "Teams Organization Seat (Monthly)",
|
||||
"product": "prod_HgOooYXDr2DDAA",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed",
|
||||
"name": "Password Manager - Teams Plan",
|
||||
"statement_description": null,
|
||||
"statement_descriptor": null,
|
||||
"tiers": null
|
||||
},
|
||||
"price": {
|
||||
"id": "2023-teams-org-seat-monthly",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1695839010,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "Teams Organization Seat (Monthly)",
|
||||
"product": "prod_HgOooYXDr2DDAA",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"meter": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "exclusive",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 500,
|
||||
"unit_amount_decimal": "500"
|
||||
},
|
||||
"proration": false,
|
||||
"proration_details": {
|
||||
"credited_items": null
|
||||
},
|
||||
"quantity": 100,
|
||||
"subscription": null,
|
||||
"subscription_item": "si_QH8Qo4WEJxOVwx",
|
||||
"tax_amounts": [
|
||||
{
|
||||
"amount": 2600,
|
||||
"inclusive": false,
|
||||
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
|
||||
"taxability_reason": "standard_rated",
|
||||
"taxable_amount": 32500
|
||||
}
|
||||
],
|
||||
"tax_rates": [
|
||||
],
|
||||
"type": "subscription",
|
||||
"unit_amount_excluding_tax": "500",
|
||||
"unique_id": "il_1PQa9fIGBnsLynRrSJ3cxrdU",
|
||||
"unique_line_item_id": "sli_1acb3eIGBnsLynRr4b9c2f48"
|
||||
},
|
||||
{
|
||||
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
|
||||
"object": "line_item",
|
||||
"amount": 70000,
|
||||
"amount_excluding_tax": 70000,
|
||||
"currency": "usd",
|
||||
"description": null,
|
||||
"discount_amounts": [
|
||||
{
|
||||
"amount": 24500,
|
||||
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
|
||||
}
|
||||
],
|
||||
"discountable": true,
|
||||
"discounts": [
|
||||
],
|
||||
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"period": {
|
||||
"end": 1720725291,
|
||||
"start": 1718133291
|
||||
},
|
||||
"plan": {
|
||||
"id": "2023-enterprise-seat-monthly",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 700,
|
||||
"amount_decimal": "700",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1695152194,
|
||||
"currency": "usd",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"meter": null,
|
||||
"nickname": "Enterprise Organization (Monthly)",
|
||||
"product": "prod_HgSOgzUlYDFOzf",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed",
|
||||
"name": "Password Manager - Enterprise Plan",
|
||||
"statement_description": null,
|
||||
"statement_descriptor": null,
|
||||
"tiers": null
|
||||
},
|
||||
"price": {
|
||||
"id": "2023-enterprise-seat-monthly",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1695152194,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "Enterprise Organization (Monthly)",
|
||||
"product": "prod_HgSOgzUlYDFOzf",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"meter": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "exclusive",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 700,
|
||||
"unit_amount_decimal": "700"
|
||||
},
|
||||
"proration": false,
|
||||
"proration_details": {
|
||||
"credited_items": null
|
||||
},
|
||||
"quantity": 100,
|
||||
"subscription": null,
|
||||
"subscription_item": "si_QH8QUjtceXvcis",
|
||||
"tax_amounts": [
|
||||
{
|
||||
"amount": 3640,
|
||||
"inclusive": false,
|
||||
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
|
||||
"taxability_reason": "standard_rated",
|
||||
"taxable_amount": 45500
|
||||
}
|
||||
],
|
||||
"tax_rates": [
|
||||
],
|
||||
"type": "subscription",
|
||||
"unit_amount_excluding_tax": "700",
|
||||
"unique_id": "il_1PQa9fIGBnsLynRrVviet37m",
|
||||
"unique_line_item_id": "sli_11b229IGBnsLynRr837b79d0"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_1PQa9fIGBnsLynRraYIqTdBs/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"next_payment_attempt": null,
|
||||
"number": "525EB050-0001",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"paid_out_of_band": false,
|
||||
"payment_intent": "pi_3PQaA7IGBnsLynRr1swr9XJE",
|
||||
"payment_settings": {
|
||||
"default_mandate": null,
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1718133291,
|
||||
"period_start": 1718133291,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"rendering": null,
|
||||
"rendering_options": null,
|
||||
"shipping_cost": null,
|
||||
"shipping_details": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": null,
|
||||
"status": "open",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1718136893,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": "sub_1PQa9fIGBnsLynRr83lNrFHa",
|
||||
"subscription_details": {
|
||||
"metadata": {
|
||||
"providerId": "655bc5a3-2332-4201-a9a6-b18c013d0572"
|
||||
}
|
||||
},
|
||||
"subtotal": 120000,
|
||||
"subtotal_excluding_tax": 120000,
|
||||
"tax": 6240,
|
||||
"test_clock": "clock_1PQaA4IGBnsLynRrptkZjgxc",
|
||||
"total": 84240,
|
||||
"total_discount_amounts": [
|
||||
{
|
||||
"amount": 42000,
|
||||
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
|
||||
}
|
||||
],
|
||||
"total_excluding_tax": 78000,
|
||||
"total_tax_amounts": [
|
||||
{
|
||||
"amount": 6240,
|
||||
"inclusive": false,
|
||||
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
|
||||
"taxability_reason": "standard_rated",
|
||||
"taxable_amount": 78000
|
||||
}
|
||||
],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1718133293,
|
||||
"application_fee": null,
|
||||
"billing": "send_invoice",
|
||||
"closed": false,
|
||||
"date": 1718133291,
|
||||
"finalized_at": 1718136893,
|
||||
"forgiven": false,
|
||||
"payment": null,
|
||||
"statement_description": null,
|
||||
"tax_percent": 8
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 5,
|
||||
"request": null,
|
||||
"type": "invoice.finalized",
|
||||
"user_id": "acct_19smIXIGBnsLynRr"
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
{
|
||||
"id": "evt_1Nv0w8IGBnsLynRrZoDVI44u",
|
||||
"object": "event",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695833408,
|
||||
"data": {
|
||||
"object": {
|
||||
"object": "invoice",
|
||||
"account_country": "US",
|
||||
"account_name": "Bitwarden Inc.",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"amount_shipping": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"automatic_tax": {
|
||||
"enabled": true,
|
||||
"status": "complete"
|
||||
},
|
||||
"billing_reason": "upcoming",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1697128681,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_M8DV9wiyNa2JxQ",
|
||||
"customer_address": {
|
||||
"city": null,
|
||||
"country": "US",
|
||||
"line1": "",
|
||||
"line2": null,
|
||||
"postal_code": "90019",
|
||||
"state": null
|
||||
},
|
||||
"customer_email": "vphan@bitwarden.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [
|
||||
],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [
|
||||
],
|
||||
"due_date": null,
|
||||
"effective_at": null,
|
||||
"ending_balance": -6779,
|
||||
"footer": null,
|
||||
"from_invoice": null,
|
||||
"last_finalization_error": null,
|
||||
"latest_revision": null,
|
||||
"lines": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "il_tmp_12b5e8IGBnsLynRr1996ac3a",
|
||||
"object": "line_item",
|
||||
"amount": 2000,
|
||||
"amount_excluding_tax": 2000,
|
||||
"currency": "usd",
|
||||
"description": "5 × 2019 Enterprise Seat (Monthly) (at $4.00 / month)",
|
||||
"discount_amounts": [
|
||||
],
|
||||
"discountable": true,
|
||||
"discounts": [
|
||||
],
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"period": {
|
||||
"end": 1699807081,
|
||||
"start": 1697128681
|
||||
},
|
||||
"plan": {
|
||||
"id": "enterprise-org-seat-monthly",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 400,
|
||||
"amount_decimal": "400",
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1494268635,
|
||||
"currency": "usd",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "2019 Enterprise Seat (Monthly)",
|
||||
"product": "prod_BVButYytPSlgs6",
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"price": {
|
||||
"id": "enterprise-org-seat-monthly",
|
||||
"object": "price",
|
||||
"active": true,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1494268635,
|
||||
"currency": "usd",
|
||||
"custom_unit_amount": null,
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "2019 Enterprise Seat (Monthly)",
|
||||
"product": "prod_BVButYytPSlgs6",
|
||||
"recurring": {
|
||||
"aggregate_usage": null,
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "recurring",
|
||||
"unit_amount": 400,
|
||||
"unit_amount_decimal": "400"
|
||||
},
|
||||
"proration": false,
|
||||
"proration_details": {
|
||||
"credited_items": null
|
||||
},
|
||||
"quantity": 5,
|
||||
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v",
|
||||
"subscription_item": "si_ODOmLnPDHBuMxX",
|
||||
"tax_amounts": [
|
||||
{
|
||||
"amount": 0,
|
||||
"inclusive": false,
|
||||
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD",
|
||||
"taxability_reason": "product_exempt",
|
||||
"taxable_amount": 0
|
||||
}
|
||||
],
|
||||
"tax_rates": [
|
||||
],
|
||||
"type": "subscription",
|
||||
"unit_amount_excluding_tax": "400"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/upcoming/lines?customer=cus_M8DV9wiyNa2JxQ&subscription=sub_1NQxz4IGBnsLynRr1KbitG7v"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"next_payment_attempt": 1697132281,
|
||||
"number": null,
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"paid_out_of_band": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"default_mandate": null,
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1697128681,
|
||||
"period_start": 1694536681,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"rendering": null,
|
||||
"rendering_options": null,
|
||||
"shipping_cost": null,
|
||||
"shipping_details": null,
|
||||
"starting_balance": -8779,
|
||||
"statement_descriptor": null,
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v",
|
||||
"subscription_details": {
|
||||
"metadata": {
|
||||
}
|
||||
},
|
||||
"subtotal": 2000,
|
||||
"subtotal_excluding_tax": 2000,
|
||||
"tax": 0,
|
||||
"test_clock": null,
|
||||
"total": 2000,
|
||||
"total_discount_amounts": [
|
||||
],
|
||||
"total_excluding_tax": 2000,
|
||||
"total_tax_amounts": [
|
||||
{
|
||||
"amount": 0,
|
||||
"inclusive": false,
|
||||
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD",
|
||||
"taxability_reason": "product_exempt",
|
||||
"taxable_amount": 0
|
||||
}
|
||||
],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 5,
|
||||
"request": {
|
||||
"id": null,
|
||||
"idempotency_key": null
|
||||
},
|
||||
"type": "invoice.upcoming"
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
{
|
||||
"id": "evt_1NvKzcIGBnsLynRrPJ3hybkd",
|
||||
"object": "event",
|
||||
"api_version": "2024-06-20",
|
||||
"created": 1695910504,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "pm_1NvKzbIGBnsLynRry6x7Buvc",
|
||||
"object": "payment_method",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 6,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "0VgUBpvqcUUnuSmK",
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1695910503,
|
||||
"customer": "cus_OimYrxnMTMMK1E",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"type": "card"
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 7,
|
||||
"request": {
|
||||
"id": "req_2WslNSBD9wAV5v",
|
||||
"idempotency_key": "db1a648a-3445-47b3-a403-9f3d1303a880"
|
||||
},
|
||||
"type": "payment_method.attached"
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Billing.Test.Utilities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -59,29 +58,69 @@ public class ProviderEventServiceTests
|
||||
public async Task TryRecordInvoiceLineItems_EventTypeNotInvoiceCreatedOrInvoiceFinalized_NoOp()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
|
||||
var stripeEvent = new Event { Type = "payment_method.attached" };
|
||||
|
||||
// Act
|
||||
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
|
||||
|
||||
// Assert
|
||||
await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any<Event>());
|
||||
await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecordInvoiceLineItems_InvoiceParentTypeNotSubscriptionDetails_NoOp()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = new Event
|
||||
{
|
||||
Type = "invoice.created"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Parent = new InvoiceParent
|
||||
{
|
||||
Type = "credit_note",
|
||||
SubscriptionDetails = new InvoiceParentSubscriptionDetails
|
||||
{
|
||||
SubscriptionId = "sub_1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
|
||||
|
||||
// Act
|
||||
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecordInvoiceLineItems_EventNotProviderRelated_NoOp()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
|
||||
var stripeEvent = new Event
|
||||
{
|
||||
Type = "invoice.created"
|
||||
};
|
||||
|
||||
const string subscriptionId = "sub_1";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
SubscriptionId = subscriptionId
|
||||
Parent = new InvoiceParent
|
||||
{
|
||||
Type = "subscription_details",
|
||||
SubscriptionDetails = new InvoiceParentSubscriptionDetails
|
||||
{
|
||||
SubscriptionId = subscriptionId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
|
||||
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
@ -101,7 +140,10 @@ public class ProviderEventServiceTests
|
||||
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
|
||||
var stripeEvent = new Event
|
||||
{
|
||||
Type = "invoice.created"
|
||||
};
|
||||
|
||||
const string subscriptionId = "sub_1";
|
||||
var providerId = Guid.NewGuid();
|
||||
@ -110,17 +152,26 @@ public class ProviderEventServiceTests
|
||||
{
|
||||
Id = "invoice_1",
|
||||
Number = "A",
|
||||
SubscriptionId = subscriptionId,
|
||||
Discount = new Discount
|
||||
Parent = new InvoiceParent
|
||||
{
|
||||
Coupon = new Coupon
|
||||
Type = "subscription_details",
|
||||
SubscriptionDetails = new InvoiceParentSubscriptionDetails
|
||||
{
|
||||
PercentOff = 35
|
||||
SubscriptionId = subscriptionId
|
||||
}
|
||||
}
|
||||
},
|
||||
Discounts = [
|
||||
new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
PercentOff = 35
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
|
||||
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
@ -249,7 +300,10 @@ public class ProviderEventServiceTests
|
||||
public async Task TryRecordInvoiceLineItems_InvoiceFinalized_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceFinalized);
|
||||
var stripeEvent = new Event
|
||||
{
|
||||
Type = "invoice.finalized"
|
||||
};
|
||||
|
||||
const string subscriptionId = "sub_1";
|
||||
var providerId = Guid.NewGuid();
|
||||
@ -258,10 +312,17 @@ public class ProviderEventServiceTests
|
||||
{
|
||||
Id = "invoice_1",
|
||||
Number = "A",
|
||||
SubscriptionId = subscriptionId
|
||||
Parent = new InvoiceParent
|
||||
{
|
||||
Type = "subscription_details",
|
||||
SubscriptionDetails = new InvoiceParentSubscriptionDetails
|
||||
{
|
||||
SubscriptionId = subscriptionId
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
|
||||
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
@ -38,7 +39,13 @@ public class SubscriptionDeletedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = "active",
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
@ -63,11 +70,14 @@ public class SubscriptionDeletedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Metadata = new Dictionary<string, string>
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
{ "organizationId", organizationId.ToString() }
|
||||
}
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
|
||||
@ -79,7 +89,7 @@ public class SubscriptionDeletedHandlerTests
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.Received(1)
|
||||
.DisableAsync(organizationId, subscription.CurrentPeriodEnd);
|
||||
.DisableAsync(organizationId, subscription.GetCurrentPeriodEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -91,11 +101,14 @@ public class SubscriptionDeletedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Metadata = new Dictionary<string, string>
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
{ "userId", userId.ToString() }
|
||||
}
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
|
||||
@ -107,7 +120,7 @@ public class SubscriptionDeletedHandlerTests
|
||||
|
||||
// Assert
|
||||
await _userService.Received(1)
|
||||
.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd);
|
||||
.DisablePremiumAsync(userId, subscription.GetCurrentPeriodEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -119,11 +132,14 @@ public class SubscriptionDeletedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Metadata = new Dictionary<string, string>
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
{ "organizationId", organizationId.ToString() }
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
|
||||
CancellationDetails = new SubscriptionCancellationDetails
|
||||
{
|
||||
Comment = "Cancelled as part of provider migration to Consolidated Billing"
|
||||
@ -151,11 +167,14 @@ public class SubscriptionDeletedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Metadata = new Dictionary<string, string>
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
{ "organizationId", organizationId.ToString() }
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
|
||||
CancellationDetails = new SubscriptionCancellationDetails
|
||||
{
|
||||
Comment = "Organization was added to Provider"
|
||||
|
||||
@ -96,7 +96,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
};
|
||||
@ -142,7 +148,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["providerId"] = providerId.ToString(),
|
||||
@ -206,7 +218,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["providerId"] = providerId.ToString() },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" },
|
||||
TestClock = null
|
||||
@ -257,6 +275,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
@ -306,6 +331,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
@ -348,7 +380,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.IncompleteExpired,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "renewal" }
|
||||
};
|
||||
@ -390,7 +428,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
};
|
||||
@ -426,7 +470,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
};
|
||||
@ -464,13 +514,16 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } }
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
@ -508,7 +561,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
|
||||
};
|
||||
|
||||
@ -552,7 +611,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
|
||||
};
|
||||
|
||||
@ -583,7 +648,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = currentPeriodEnd,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
|
||||
};
|
||||
|
||||
@ -616,18 +687,24 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
Id = "sub_123",
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
|
||||
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
|
||||
}
|
||||
]
|
||||
},
|
||||
Customer = new Customer
|
||||
{
|
||||
Balance = 0,
|
||||
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
|
||||
},
|
||||
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } },
|
||||
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
|
||||
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
|
||||
};
|
||||
|
||||
@ -728,7 +805,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_EnableProvider()
|
||||
@ -998,6 +1074,13 @@ public class SubscriptionUpdatedHandlerTests
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = previousSubscription?.Id ?? "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
|
||||
};
|
||||
@ -1021,7 +1104,10 @@ public class SubscriptionUpdatedHandlerTests
|
||||
{
|
||||
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid } },
|
||||
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete } },
|
||||
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } },
|
||||
new object[]
|
||||
{
|
||||
new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired }
|
||||
},
|
||||
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused } }
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Test.Utilities;
|
||||
|
||||
public enum StripeEventType
|
||||
{
|
||||
ChargeSucceeded,
|
||||
CustomerSubscriptionUpdated,
|
||||
CustomerUpdated,
|
||||
InvoiceCreated,
|
||||
InvoiceFinalized,
|
||||
InvoiceUpcoming,
|
||||
PaymentMethodAttached
|
||||
}
|
||||
|
||||
public static class StripeTestEvents
|
||||
{
|
||||
public static async Task<Event> GetAsync(StripeEventType eventType)
|
||||
{
|
||||
var fileName = eventType switch
|
||||
{
|
||||
StripeEventType.ChargeSucceeded => "charge.succeeded.json",
|
||||
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json",
|
||||
StripeEventType.CustomerUpdated => "customer.updated.json",
|
||||
StripeEventType.InvoiceCreated => "invoice.created.json",
|
||||
StripeEventType.InvoiceFinalized => "invoice.finalized.json",
|
||||
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json",
|
||||
StripeEventType.PaymentMethodAttached => "payment_method.attached.json"
|
||||
};
|
||||
|
||||
var resource = await EmbeddedResourceReader.ReadAsync("Events", fileName);
|
||||
|
||||
return EventUtility.ParseEvent(resource);
|
||||
}
|
||||
}
|
||||
@ -294,7 +294,8 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 120; // $1.20 in cents
|
||||
|
||||
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 120 }]; // $1.20 in cents
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@ -318,7 +319,7 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = null;
|
||||
invoice.TotalTaxes = [];
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@ -341,7 +342,7 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 0;
|
||||
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 0 }];
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@ -374,7 +375,7 @@ public class InvoiceExtensionsTests
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Lines = lineItems,
|
||||
Tax = 200 // Additional $2.00 tax
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
|
||||
@ -227,8 +227,16 @@ If you believe you need to change the version for a valid reason, please discuss
|
||||
Status = "active",
|
||||
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return new SubscriptionInfo
|
||||
|
||||
@ -141,8 +141,16 @@ If you believe you need to change the version for a valid reason, please discuss
|
||||
Status = "active",
|
||||
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return new SubscriptionInfo
|
||||
|
||||
@ -54,7 +54,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
@ -77,7 +77,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -112,7 +112,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 750,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 750 }],
|
||||
Total = 8250
|
||||
};
|
||||
|
||||
@ -137,7 +137,9 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) &&
|
||||
options.Coupon == CouponIDs.SecretsManagerStandalone));
|
||||
options.Discounts != null &&
|
||||
options.Discounts.Count == 1 &&
|
||||
options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -173,7 +175,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1200,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }],
|
||||
Total = 12200
|
||||
};
|
||||
|
||||
@ -205,7 +207,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -234,7 +236,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 300,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
@ -257,7 +259,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 6 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -286,7 +288,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 0,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
|
||||
Total = 2700
|
||||
};
|
||||
|
||||
@ -309,7 +311,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 3 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -339,7 +341,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 2100,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 2100 }],
|
||||
Total = 12100
|
||||
};
|
||||
|
||||
@ -365,7 +367,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 15 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -399,7 +401,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 120,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 120 }],
|
||||
Total = 1320
|
||||
};
|
||||
|
||||
@ -422,7 +424,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 2 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -452,7 +454,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 400,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 400 }],
|
||||
Total = 4400
|
||||
};
|
||||
|
||||
@ -475,7 +477,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -524,7 +526,11 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 900,
|
||||
TotalTaxes = [new InvoiceTotalTax
|
||||
{
|
||||
Amount = 900
|
||||
}
|
||||
],
|
||||
Total = 9900
|
||||
};
|
||||
|
||||
@ -546,7 +552,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 6 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -595,7 +601,11 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1200,
|
||||
TotalTaxes = [new InvoiceTotalTax
|
||||
{
|
||||
Amount = 1200
|
||||
}
|
||||
],
|
||||
Total = 13200
|
||||
};
|
||||
|
||||
@ -617,7 +627,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 6 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -647,7 +657,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@ -672,7 +682,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -724,7 +734,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
|
||||
Total = 16500
|
||||
};
|
||||
|
||||
@ -753,7 +763,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -808,7 +818,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@ -831,7 +841,9 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 5 &&
|
||||
options.Coupon == "EXISTING_DISCOUNT_50"));
|
||||
options.Discounts != null &&
|
||||
options.Discounts.Count == 1 &&
|
||||
options.Discounts[0].Coupon == "EXISTING_DISCOUNT_50"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -911,7 +923,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@ -934,7 +946,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 10 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -976,7 +988,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1200,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }],
|
||||
Total = 13200
|
||||
};
|
||||
|
||||
@ -1001,7 +1013,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "storage-gb-annually" && item.Quantity == 5) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1043,7 +1055,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@ -1066,7 +1078,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 8 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1111,7 +1123,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
|
||||
Total = 16500
|
||||
};
|
||||
|
||||
@ -1139,7 +1151,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1192,7 +1204,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 2500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 2500 }],
|
||||
Total = 27500
|
||||
};
|
||||
|
||||
@ -1224,7 +1236,9 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) &&
|
||||
options.Coupon == "ENTERPRISE_DISCOUNT_20"));
|
||||
options.Discounts != null &&
|
||||
options.Discounts.Count == 1 &&
|
||||
options.Discounts[0].Coupon == "ENTERPRISE_DISCOUNT_20"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1266,7 +1280,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
@ -1291,7 +1305,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
item.Price == "2020-families-org-annually" && item.Quantity == 6) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == "personal-storage-gb-annually" && item.Quantity == 2) &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -1368,7 +1382,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 300,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
@ -1391,7 +1405,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 5 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@ -27,25 +27,27 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, int version)
|
||||
{
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).ReturnsNull();
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation)
|
||||
{
|
||||
installation.Enabled = false;
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).Returns(installation);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
@ -71,7 +73,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, string token)
|
||||
{
|
||||
@ -90,7 +93,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, Provider provider)
|
||||
{
|
||||
@ -99,8 +103,17 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
subInfo.Subscription = new SubscriptionInfo.BillingSubscription(new Subscription
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
installation.Enabled = true;
|
||||
|
||||
@ -272,7 +272,16 @@ public class GetOrganizationWarningsQueryTests
|
||||
CollectionMethod = CollectionMethod.SendInvoice,
|
||||
Customer = new Customer(),
|
||||
Status = SubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = now.AddDays(10),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = now.AddDays(10)
|
||||
}
|
||||
]
|
||||
},
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Services;
|
||||
@ -105,6 +106,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -152,6 +163,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -241,7 +262,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -286,6 +316,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -326,7 +366,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -342,7 +391,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -368,7 +417,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -384,7 +442,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -411,7 +469,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@ -453,7 +520,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 300,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
@ -65,7 +65,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
@ -101,7 +101,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 250,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 250 }],
|
||||
Total = 2750
|
||||
};
|
||||
|
||||
@ -135,7 +135,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@ -171,7 +171,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 450,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 450 }],
|
||||
Total = 4950
|
||||
};
|
||||
|
||||
@ -207,7 +207,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 0,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
|
||||
Total = 3000
|
||||
};
|
||||
|
||||
@ -241,7 +241,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@ -276,7 +276,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
// Stripe amounts are in cents
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 123, // $1.23
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 123 }], // $1.23
|
||||
Total = 3123 // $31.23
|
||||
};
|
||||
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Services;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@ -118,4 +123,232 @@ public class OrganizationBillingServiceTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Finalize - Trial Settings
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = false
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(false);
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Trialing
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(7, capturedOptions.TrialPeriodDays);
|
||||
Assert.NotNull(capturedOptions.TrialSettings);
|
||||
Assert.NotNull(capturedOptions.TrialSettings.EndBehavior);
|
||||
Assert.Equal("cancel", capturedOptions.TrialSettings.EndBehavior.MissingPaymentMethod);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = true // This will result in TrialPeriodDays = 0
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(false);
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(0, capturedOptions.TrialPeriodDays);
|
||||
Assert.Null(capturedOptions.TrialSettings);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = false
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(true); // Has payment method
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Trialing
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(7, capturedOptions.TrialPeriodDays);
|
||||
Assert.Null(capturedOptions.TrialSettings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -88,7 +88,13 @@ public class RestartSubscriptionCommandTests
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
@ -138,7 +144,13 @@ public class RestartSubscriptionCommandTests
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
|
||||
@ -177,7 +189,13 @@ public class RestartSubscriptionCommandTests
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(user).Returns(existingSubscription);
|
||||
|
||||
@ -18,8 +18,9 @@ public class StripePaymentServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@ -28,16 +29,13 @@ public class StripePaymentServiceTests
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@ -52,7 +50,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 4000,
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 4800
|
||||
});
|
||||
|
||||
@ -75,16 +73,13 @@ public class StripePaymentServiceTests
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@ -96,12 +91,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 4000,
|
||||
Tax = 800,
|
||||
Total = 4800
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 4000, TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@ -112,8 +102,9 @@ public class StripePaymentServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@ -128,11 +119,7 @@ public class StripePaymentServiceTests
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@ -144,12 +131,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 0)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 0,
|
||||
Tax = 0,
|
||||
Total = 0
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 0, TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 0 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@ -160,8 +142,9 @@ public class StripePaymentServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@ -176,11 +159,7 @@ public class StripePaymentServiceTests
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@ -192,12 +171,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 400, TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@ -235,7 +209,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -277,7 +251,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -319,7 +293,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -361,7 +335,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -403,7 +377,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -445,7 +419,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -487,7 +461,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@ -529,7 +503,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user