#nullable enable using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data.Subscriptions; using BTCPayServer.Events; using BTCPayServer.Plugins; using BTCPayServer.Tests.PMO; using Microsoft.Playwright; using NBitcoin; using NBXplorer; using Newtonsoft.Json.Linq; using Xunit; using Xunit.Abstractions; namespace BTCPayServer.Tests; [Collection(nameof(NonParallelizableCollectionDefinition))] public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testOutputHelper) { [Fact] [Trait("Playwright", "Playwright")] public async Task CanChangeOfferingEmailsSettings() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(); await s.CreateNewStore(); var offering = await CreateNewSubscription(s); await offering.GoToMails(); var settings = new OfferingPMO.EmailSettingsForm() { PaymentRemindersDays = 7 }; await offering.SetEmailsSettings(settings); var actual = await offering.ReadEmailsSettings(); offering.AssertEqual(settings, actual); } [Fact] [Trait("Playwright", "Playwright")] public async Task CanEditOfferingAndPlans() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(); await s.CreateNewStore(); await CreateNewSubscription(s); var offeringPMO = new OfferingPMO(s); var editPlan = new AddEditPlanPMO(s) { PlanName = "Test plan", Price = "10.00", TrialPeriod = "7", GracePeriod = "7", EnableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"], PlanChanges = [AddEditPlanPMO.PlanChangeType.Upgrade, AddEditPlanPMO.PlanChangeType.Downgrade] }; await offeringPMO.AddPlan(); await editPlan.Save(); // Remove the other plans for (int i = 0; i < 3; i++) { await s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" }).Nth(1).ClickAsync(); await s.ConfirmDeleteModal(); await s.FindAlertMessage(); } await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync(); editPlan = new AddEditPlanPMO(s); editPlan.PlanName = "Test plan new name"; editPlan.Price = "11.00"; editPlan.TrialPeriod = "5"; editPlan.GracePeriod = "5"; editPlan.Description = "Super cool plan"; editPlan.OptimisticActivation = true; editPlan.EnableEntitlements = ["transaction-limit-50000", "payment-processing-1", "email-support-1"]; editPlan.DisableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"]; await editPlan.Save(); await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync(); var expected = editPlan; expected.OptimisticActivation = true; editPlan = new AddEditPlanPMO(s); await editPlan.ReadFields(); editPlan.DisableEntitlements = null; expected.AssertEqual(editPlan); await s.Page.GetByTestId("offering-link").ClickAsync(); await offeringPMO.Configure(); var configureOffering = new ConfigureOfferingPMO(s) { Name = "New test offering 2", SuccessRedirectUrl = "https://test.com/test", Entitlements_0__Id = "analytics-dashboard-0-2", Entitlements_0__ShortDescription = "Basic analytics dashboard 2", }; await configureOffering.Fill(); // Remove "analytics-dashboard-1" which is the second item Assert.Equal("analytics-dashboard-1", await s.Page.Locator("#Entitlements_1__Id").InputValueAsync()); await s.Page.Locator("button[name='removeIndex']").Nth(1).ClickAsync(); await s.ClickPagePrimary(); await offeringPMO.Configure(); var expectedConfigure = configureOffering; expectedConfigure.Entitlements_1__Id = "analytics-dashboard-x"; expectedConfigure.Entitlements_1__ShortDescription = "Custom analytics & reporting"; configureOffering = new ConfigureOfferingPMO(s); await configureOffering.ReadFields(); expectedConfigure.AssertEqual(configureOffering); // Can we add "Support" back? await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add item" }).ClickAsync(); await s.Page.Locator("#Entitlements_14__Id").FillAsync("analytics-dashboard-1"); await s.Page.Locator("#Entitlements_14__ShortDescription").FillAsync("Advanced analytics"); await s.ClickPagePrimary(); await offeringPMO.Configure(); expectedConfigure.Entitlements_1__Id = "analytics-dashboard-1"; expectedConfigure.Entitlements_1__ShortDescription = "Advanced analytics"; configureOffering = new ConfigureOfferingPMO(s); await configureOffering.ReadFields(); expectedConfigure.AssertEqual(configureOffering); await s.ClickPagePrimary(); await offeringPMO.Configure(); await s.Page.GetByText("Delete this offering").ClickAsync(); await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Confirm the action by typing" }).FillAsync("DELETE"); await s.Page.ClickAsync("#ConfirmContinue"); await s.FindAlertMessage(partialText: "App deleted"); await CreateNewSubscription(s); // Change the planchanges editPlan = await offeringPMO.Edit("Basic Plan"); await editPlan.ReadFields(); editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.Downgrade, AddEditPlanPMO.PlanChangeType.Upgrade]; await editPlan.Save(); expected = editPlan; editPlan = await offeringPMO.Edit("Basic Plan"); await editPlan.ReadFields(); expected.AssertEqual(editPlan); editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.None, AddEditPlanPMO.PlanChangeType.Upgrade]; await editPlan.Save(); expected = editPlan; editPlan = await offeringPMO.Edit("Basic Plan"); await editPlan.ReadFields(); expected.AssertEqual(editPlan); } private static async Task CreateNewSubscription(PlaywrightTester s) { await s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscriptions" }).ClickAsync(); await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Name *" }).FillAsync("New test offering"); await s.Page.GetByRole(AriaRole.Button, new() { Name = "Create fake offering" }).ClickAsync(); return new(s); } [Fact] [Trait("Playwright", "Playwright")] public async Task CanUpgradeAndDowngrade() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(); (_, string storeId) = await s.CreateNewStore(); await s.AddDerivationScheme(); var invoice = new InvoiceCheckoutPMO(s); var offering = await CreateNewSubscription(s); await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true); await offering.GoToSubscribers(); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.Downgrade("Pro Plan"); await invoice.AssertContent(new() { TotalFiat = "$99.00" }); } await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.ClickCallToAction(); await s.Server.WaitForEvent(async () => { await s.PayInvoice(mine: true); }); await invoice.ClickRedirect(); await s.FindAlertMessage(partialText: "The plan has been started."); // Note that at this point, the customer has a period of 15 days + 1 month. // This is because the trial period is 15 days, so we extend the plan. await portal.GoTo7Days(); await portal.Downgrade("Pro Plan"); decimal totalRefunded = 0m; // The downgrade can be paid by the current, more expensive plan. var unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 299.0m, daysInPeriod: 15 + DaysInThisMonth()); totalRefunded += await portal.AssertRefunded(unused); var expectedBalance = totalRefunded - 99.0m; await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}"); // This time, we should have 1 month in the current period. await portal.GoTo7Days(); var credited = await s.Server.WaitForEvent(async () => { await portal.Downgrade("Basic Plan"); unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 99.0m, daysInPeriod: DaysInThisMonth()); unused = await portal.AssertRefunded(unused); totalRefunded += unused; }); Assert.Equal(unused, credited.Amount); Assert.Equal(unused + expectedBalance, credited.Total); expectedBalance = totalRefunded - 29.0m - 99.0m; await portal.AssertCredit("$29.00", "-$29.00", "$0.00", $"${expectedBalance:F2}"); // The balance should now be around 202.15 USD // Now, let's try upgrade. Since we have enough money, we should be able to upgrade without invoice. await portal.GoTo7Days(); await portal.Upgrade("Pro Plan"); unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 29.0m, daysInPeriod: DaysInThisMonth()); unused = await portal.AssertRefunded(unused); totalRefunded += unused; expectedBalance = totalRefunded - 29.0m - 99.0m - 99.0m; await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}"); // However, for going back to enterprise, we do not have enough. await portal.GoTo7Days(); await portal.GoTo7Days(); await portal.GoTo7Days(); unused = GetUnusedPeriodValue(usedDays: 21, planPrice: 99.0m, daysInPeriod: DaysInThisMonth()); await s.Server.WaitForEvent(async () => { await portal.Upgrade("Enterprise Plan"); await s.PayInvoice(mine: true); }); await invoice.ClickRedirect(); unused = await portal.AssertRefunded(unused); totalRefunded += unused; await s.Page.EvaluateAsync("window.scrollTo(0, document.body.scrollHeight)"); await s.TakeScreenshot("upgrade2.png"); await portal.AssertCreditHistory( [ "Upgrade to new plan 'Enterprise Plan'", "Credit purchase" ], [ "-$" + (299m - unused).ToString("F2", CultureInfo.InvariantCulture), "$" + (299m - expectedBalance - unused).ToString("F2", CultureInfo.InvariantCulture) ]); expectedBalance = 0m; await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}"); } } private static decimal GetUnusedPeriodValue(int usedDays, decimal planPrice, int daysInPeriod) { var unused = (double)(daysInPeriod - usedDays) / (double)daysInPeriod; var expected = (decimal)Math.Round((double)planPrice * unused, 2); return expected; } private static int DaysInThisMonth() { return DateTime.DaysInMonth(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month); } private string USD(decimal val) => $"${val.ToString("F2", CultureInfo.InvariantCulture)}"; [Fact] [Trait("Playwright", "Playwright")] public async Task CanUseNonRenewableFreePlan() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(); await s.CreateNewStore(); await s.AddDerivationScheme(); var offering = await CreateNewSubscription(s); var addPlan = await offering.AddPlan(); addPlan.Price = "0"; addPlan.PlanName = "Free Plan"; addPlan.Renewable = false; addPlan.OptimisticActivation = true; addPlan.PlanChanges = [ AddEditPlanPMO.PlanChangeType.Upgrade, AddEditPlanPMO.PlanChangeType.None, AddEditPlanPMO.PlanChangeType.None, ]; await addPlan.Save(); await offering.NewSubscriber("Free Plan", "free@example.com", false, hasInvoice: false); await offering.GoToSubscribers(); await using (var portal = await offering.GoToPortal("free@example.com")) { await portal.GoToReminder(); await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Upgrade needed in 3 days"); await portal.ClickCallToAction(); await s.PayInvoice(clickRedirect: true); await portal.AssertNoCallToAction(); await portal.AssertPlan("Basic Plan"); await portal.AssertCreditHistory([ "Upgrade to new plan 'Basic Plan'", "Credit purchase", "Starting plan 'Free Plan'" ]); } } [Fact] [Trait("Playwright", "Playwright")] public async Task CanCreateSubscriberAndCircleThroughStates() { await using var s = CreatePlaywrightTester(); await s.StartAsync(); await s.RegisterNewUser(); (_, string storeId) = await s.CreateNewStore(); await s.AddDerivationScheme(); var offering = await CreateNewSubscription(s); // enterprise@example.com is a trial subscriber await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true); // basic@example.com is a basic plan subscriber (without optimistic activation), so he needs to wait confirmation offering.GoToPlans(); var edit = await offering.Edit("Basic Plan"); edit.OptimisticActivation = false; await edit.Save(); await offering.NewSubscriber("Basic Plan", "basic@example.com", false); offering.GoToPlans(); edit = await offering.Edit("Basic Plan"); edit.OptimisticActivation = true; await edit.Save(); // basic2@example.com is a basic plan subscriber (optimistic activation), so he is imediatly activated await s.Server.WaitForEvent(async () => { await offering.NewSubscriber("Basic Plan", "basic2@example.com", false); }); await s.FastReloadAsync(); await offering.AssertHasSubscriber("enterprise@example.com", new() { Phase = SubscriberData.PhaseTypes.Trial, Active = OfferingPMO.ActiveState.Active }); await offering.AssertHasSubscriber("basic2@example.com", new() { Phase = SubscriberData.PhaseTypes.Normal, Active = OfferingPMO.ActiveState.Active }); // Payment isn't yet confirmed, and no optimistic activation await offering.AssertHasNotSubscriber("basic@example.com"); // Mark the invoice of basic2 invalid, so he should go from active to inactive var api = await s.AsTestAccount().CreateClient(); var invoice = (await api.GetInvoices(storeId)).First(); var invoiceId = invoice.Id; Assert.Equal("basic2@example.com", invoice.Metadata["buyerEmail"]?.ToString()); var waiting = offering.WaitEvent(); await api.MarkInvoiceStatus(storeId, invoiceId, new() { Status = InvoiceStatus.Invalid }); var disabled = await waiting; Assert.True(disabled.Subscriber.IsSuspended); Assert.Equal("The plan has been started by an invoice which later became invalid.", disabled.Subscriber.SuspensionReason); await s.FastReloadAsync(); await offering.AssertHasSubscriber("basic2@example.com", new() { Phase = SubscriberData.PhaseTypes.Normal, Active = OfferingPMO.ActiveState.Suspended }); await using (var suspendedPortal = await offering.GoToPortal("basic2@example.com")) { await suspendedPortal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended"); } var activating = offering.WaitEvent(); await s.Server.GetExplorerNode("BTC").EnsureGenerateAsync(1); var activated = await activating; Assert.Equal("basic@example.com", activated.Subscriber.Customer.GetPrimaryIdentity()); await s.FastReloadAsync(); // Payment confirmed, this one should be active now await offering.AssertHasSubscriber("basic@example.com", new() { Phase = SubscriberData.PhaseTypes.Normal, Active = OfferingPMO.ActiveState.Active }); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.AssertCallToAction(PortalPMO.CallToAction.Info); await portal.ClickCallToAction(); var changingPhase = offering.WaitEvent(); await s.PayInvoice(mine: true, clickRedirect: true); var changeEvent = await changingPhase; Assert.Equal( (SubscriberData.PhaseTypes.Normal, SubscriberData.PhaseTypes.Trial), (changeEvent.Subscriber.Phase, changeEvent.PreviousPhase)); await s.Page.ReloadAsync(); await portal.AssertNoCallToAction(); var sendingPaymentReminder = offering.WaitEvent(); await portal.GoToReminder(); var paymentReminder = await sendingPaymentReminder; await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days"); await portal.GoToNextPhase(); await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Payment due"); var disabling = offering.WaitEvent(); await portal.GoToNextPhase(); await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access expired"); await disabling; await portal.AddCredit("19.00001"); var addingCredit = offering.WaitEvent(); await s.PayInvoice(mine: true, clickRedirect: true); var addedCredit = await addingCredit; Assert.Equal((19.0m, 19.0m), (addedCredit.Amount, addedCredit.Total)); await s.Page.ReloadAsync(); await portal.AssertCredit("$299.00", "-$19.00", "$280.00"); addingCredit = offering.WaitEvent(); await portal.ClickCallToAction(); await s.PayInvoice(mine: true, clickRedirect: true); addedCredit = await addingCredit; Assert.Equal((280.0m, 299.0m), (addedCredit.Amount, addedCredit.Total)); await s.Page.ReloadAsync(); await portal.AssertNoCallToAction(); } await s.Page.ReloadAsync(); await offering.Suspend("enterprise@example.com", "some reason"); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended", noticeSubtitles: ["Your access to this subscription has been suspended.", "Reason: some reason"]); } await offering.Unsuspend("enterprise@example.com"); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.AssertNoCallToAction(); } await offering.Charge("enterprise@example.com", 10.00001m, "-$10.00 (USD)"); await offering.Credit("enterprise@example.com", 15m, "$5.00 (USD)"); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.AssertNoCallToAction(); } await offering.Charge("enterprise@example.com", 5m, "$0.00 (USD)"); await using (var portal = await offering.GoToPortal("enterprise@example.com")) { await portal.GoToReminder(); await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days"); await portal.ClickCallToAction(); await s.PayInvoice(mine: true, clickRedirect: true); await portal.AssertNoCallToAction(); } } class OfferingPMO(PlaywrightTester s) { public Task Configure() => s.Page.GetByRole(AriaRole.Link, new() { Name = "Configure" }).ClickAsync(); public async Task AddPlan() { await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add Plan" }).ClickAsync(); return new AddEditPlanPMO(s); } public async Task NewSubscriber(string planName, string email, bool hasTrial, bool mine = false, bool? hasInvoice = null) { var allowTrial = await s.Page.Locator($"tr[data-plan-name='{planName}']").GetAttributeAsync("data-allow-trial") == "True"; await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .dropdown-toggle"); await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .plan-name-col a"); Assert.Equal(hasTrial, allowTrial); if (allowTrial) await s.Page.CheckAsync("input[name='isTrial']"); else Assert.False(await s.Page.Locator("input[name='isTrial']").IsVisibleAsync()); await s.Page.ClickAsync("#newSubscriberModal button[name='command']"); await s.Page.FillAsync("#emailInput", email); await s.Page.ClickAsync("button[name='command']"); if (!allowTrial && hasInvoice is not false) { await s.PayInvoice(mine, clickRedirect: true); } } public async Task Edit(string planName) { await s.Page.Locator($"tr[data-plan-name='{planName}'] .edit-plan").ClickAsync(); return new(s); } public Task GoToSubscribers() => s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscribers" }).ClickAsync(); public void GoToPlans() => s.Page.GetByRole(AriaRole.Link, new() { Name = "Plans" }).ClickAsync(); public Task GoToMails() => s.Page.GetByRole(AriaRole.Link, new() { Name = "Mails" }).ClickAsync(); public enum ActiveState { Inactive, Active, Suspended } public class ExpectedSubscriber { public SubscriberData.PhaseTypes? Phase { get; set; } public ActiveState? Active { get; set; } } public async Task AssertHasSubscriber(string subscriberEmail, ExpectedSubscriber? expected = null) { await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).WaitForAsync(); if (expected is not null) { if (expected.Phase is not null) { var phase = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-phase").InnerTextAsync(); Assert.Equal(expected.Phase.ToString(), phase.NormalizeWhitespaces()); } if (expected.Active is not null) { var active = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .status-active").InnerTextAsync(); Assert.Equal(expected.Active.ToString(), active.NormalizeWhitespaces()); } } } private static string SubscriberRowSelector(string subscriberEmail) { return $"tr[data-subscriber-email='{subscriberEmail}']"; } public async Task AssertHasNotSubscriber(string subscriberEmail) { Assert.Equal(0, await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).CountAsync()); } public async Task WaitEvent() { using var cts = new CancellationTokenSource(5000); var eventAggregator = s.Server.PayTester.GetService(); return await eventAggregator.WaitNext(cts.Token); } public async Task GoToPortal(string subscriberEmail) { var o = s.Page.Context.WaitForPageAsync(); await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .portal-link").ClickAsync(); var switching = await s.SwitchPage(o); return new(s, switching); } public async Task Suspend(string subscriberEmail, string? reason = null) { await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync(); await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status a").ClickAsync(); if (reason is not null) { await s.Page.FillAsync("#suspensionReason", reason); } await s.Page.ClickAsync("#suspendSubscriberModal button[name='command']"); } public async Task Unsuspend(string subscriberEmail) { await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync(); await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status button").ClickAsync(); } public Task Charge(string subscriberEmail, decimal value, string? expectedNewTotal = null) => UpdateCredit(subscriberEmail, value, expectedNewTotal, "charge"); public async Task Credit(string subscriberEmail, decimal value, string? expectedNewTotal = null) => await UpdateCredit(subscriberEmail, value, expectedNewTotal, "credit"); private async Task UpdateCredit(string subscriberEmail, decimal value, string? expectedNewTotal, string action) { await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col .dropdown-toggle").ClickAsync(); await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col a[data-action='{action}']").ClickAsync(); await s.Page.FillAsync("#updateCreditModal input[name='amount']", value.ToString(CultureInfo.InvariantCulture)); if (expectedNewTotal is not null) await s.Page.WaitForSelectorAsync($"#updateCreditModal .after-change:has-text('{expectedNewTotal}')"); // Sometimes we submit by hitting Enter. Sometimes we submit by clicking the button. var button = s.Page.Locator($"#updateCreditModal") .GetByRole(AriaRole.Button, new() { Name = action == "credit" ? "Credit" : "Charge" }); if (RandomUtils.GetInt32() % 2 == 0) { await button.ClickAsync(); } else { await button.WaitForAsync(); await s.Page.FocusAsync("#updateCreditModal input[name='amount']"); await s.Page.Keyboard.PressAsync("Enter"); } await s.FindAlertMessage(partialText: action == "charge" ? "has been charged" : "has been credited"); var newCredit = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col").InnerTextAsync(); if (expectedNewTotal is not null) Assert.Contains(expectedNewTotal.NormalizeWhitespaces(), newCredit.NormalizeWhitespaces()); } public class EmailSettingsForm { public int? PaymentRemindersDays { get; set; } } public async Task SetEmailsSettings(EmailSettingsForm settings) { if (settings.PaymentRemindersDays is not null) { await s.Page.FillAsync("input[name='PaymentRemindersDays']", settings.PaymentRemindersDays.Value.ToString()); } await s.ClickPagePrimary(); await s.FindAlertMessage(); } public async Task ReadEmailsSettings() { var settings = new EmailSettingsForm(); settings.PaymentRemindersDays = int.Parse(await s.Page.InputValueAsync("input[name='PaymentRemindersDays']")); return settings; } public void AssertEqual(EmailSettingsForm expected, EmailSettingsForm actual) { Assert.Equal(expected.PaymentRemindersDays, actual.PaymentRemindersDays); } } class PortalPMO(PlaywrightTester s, IAsyncDisposable disposable) : IAsyncDisposable { public async Task ClickCallToAction() => await s.Page.ClickAsync("div.alert-translucent button"); public enum CallToAction { Danger, Warning, Info } public async Task AssertCallToAction(CallToAction callToAction, string? noticeTitle = null, string? noticeSubtitle = null, string[]? noticeSubtitles = null) { await s.Page.Locator(GetAlertSelector(callToAction)).WaitForAsync(); if (noticeTitle is not null) Assert.Equal(noticeTitle.NormalizeWhitespaces(), (await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-title").TextContentAsync()).NormalizeWhitespaces()); if (noticeSubtitle is not null) Assert.Equal(noticeSubtitle.NormalizeWhitespaces(), (await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").TextContentAsync()).NormalizeWhitespaces()); if (noticeSubtitles is not null) { var i = 0; foreach (var text in await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").AllInnerTextsAsync()) { Assert.Equal(noticeSubtitles[i].NormalizeWhitespaces(), text.NormalizeWhitespaces()); i++; } Assert.Equal(i, noticeSubtitles.Length); } } private static string GetAlertSelector(CallToAction callToAction) => $"div.alert-translucent.alert-{callToAction.ToString().ToLowerInvariant()}"; public async Task AssertNoCallToAction() => Assert.Equal(0, await s.Page.Locator($"div.alert-translucent").CountAsync()); public ValueTask DisposeAsync() => disposable.DisposeAsync(); public Task GoToNextPhase() => s.Page.ClickAsync("#MovePhase"); public Task GoTo7Days() => s.Page.ClickAsync("#Move7days"); public Task GoToReminder() => s.Page.ClickAsync("#MoveToReminder"); public async Task AddCredit(string credit) { await s.Page.ClickAsync("#add-credit"); await s.Page.FillAsync("#credit-input input", credit); await s.Page.ClickAsync("#credit-input button"); } public async Task AssertCredit(string? planPrice = null, string? creditApplied = null, string? nextCharge = null, string? creditBalance = null) { if (planPrice is not null) Assert.Equal(planPrice.NormalizeWhitespaces(), (await s.Page.Locator(".credit-plan-price div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces()); if (creditApplied is not null) Assert.Equal(creditApplied.NormalizeWhitespaces(), (await s.Page.Locator(".credit-applied div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces()); if (nextCharge is not null) Assert.Equal(nextCharge.NormalizeWhitespaces(), (await s.Page.Locator(".credit-next-charge div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces()); if (creditBalance is not null) Assert.Equal(creditBalance.NormalizeWhitespaces(), (await s.Page.Locator(".credit-balance").TextContentAsync()).NormalizeWhitespaces()); } private async Task ChangePlan(string planName, string buttonText) { await s.Page.ClickAsync($".changeplan-container[data-plan-name='{planName}'] a:has-text('{buttonText}')"); await s.Page.ClickAsync($"#changePlanModal button[value='migrate']:has-text('{buttonText}')"); } public Task Downgrade(string planName) => ChangePlan(planName, "Downgrade"); public Task Upgrade(string planName) => ChangePlan(planName, "Upgrade"); public async Task AssertRefunded(decimal refunded) { var text = await (await s.FindAlertMessage()).TextContentAsync(); var match = Regex.Match(text!, @"\((.*?) USD has been refunded\)"); var v = decimal.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var diff = Math.Abs(refunded - v); if (diff >= 3.0m) { Assert.Fail($"Expected {refunded} USD, but got {v} USD"); } return v; } public async Task AssertPlan(string plan) { var name = await s.Page.GetByTestId("plan-name").InnerTextAsync(); Assert.Equal(plan, name); } public async Task AssertCreditHistory(List creditLines, List? creditAmounts = null) { var descriptions = await s.Page.QuerySelectorAllAsync(".credit-history tr td:nth-child(2)"); var credits = await s.Page.QuerySelectorAllAsync(".credit-history tr td:nth-child(3)"); for (int i = 0; i < creditLines.Count; i++) { var txt = await descriptions[i].InnerTextAsync(); Assert.StartsWith(creditLines[i], txt); if (creditAmounts is not null) Assert.Equal(creditAmounts[i], await credits[i].InnerTextAsync()); } } } class ConfigureOfferingPMO(PlaywrightTester tester) { public string? Name { get; set; } public string? SuccessRedirectUrl { get; set; } public string? Entitlements_0__Id { get; set; } public string? Entitlements_0__ShortDescription { get; set; } public string? Entitlements_1__Id { get; set; } public string? Entitlements_1__ShortDescription { get; set; } public async Task Fill() { var s = tester; if (Name is not null) await s.Page.Locator("#Name").FillAsync(Name); if (SuccessRedirectUrl is not null) await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).FillAsync(SuccessRedirectUrl); if (Entitlements_0__Id is not null) await s.Page.Locator("#Entitlements_0__Id").FillAsync(Entitlements_0__Id); if (Entitlements_0__ShortDescription is not null) await s.Page.Locator("#Entitlements_0__ShortDescription").FillAsync(Entitlements_0__ShortDescription); if (Entitlements_1__Id is not null) await s.Page.Locator("#Entitlements_1__Id").FillAsync(Entitlements_1__Id); if (Entitlements_1__ShortDescription is not null) await s.Page.Locator("#Entitlements_1__ShortDescription").FillAsync(Entitlements_1__ShortDescription); } public async Task ReadFields() { var s = tester; Name = await s.Page.Locator("#Name").InputValueAsync(); SuccessRedirectUrl = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).InputValueAsync(); Entitlements_0__Id = await s.Page.Locator("#Entitlements_0__Id").InputValueAsync(); Entitlements_0__ShortDescription = await s.Page.Locator("#Entitlements_0__ShortDescription").InputValueAsync(); Entitlements_1__Id = await s.Page.Locator("#Entitlements_1__Id").InputValueAsync(); Entitlements_1__ShortDescription = await s.Page.Locator("#Entitlements_1__ShortDescription").InputValueAsync(); } public void AssertEqual(ConfigureOfferingPMO b) { Assert.Equal(Name ?? "", b.Name ?? ""); Assert.Equal(SuccessRedirectUrl ?? "", b.SuccessRedirectUrl ?? ""); Assert.Equal(Entitlements_0__Id ?? "", b.Entitlements_0__Id ?? ""); Assert.Equal(Entitlements_0__ShortDescription ?? "", b.Entitlements_0__ShortDescription ?? ""); Assert.Equal(Entitlements_1__Id ?? "", b.Entitlements_1__Id ?? ""); Assert.Equal(Entitlements_1__ShortDescription ?? "", b.Entitlements_1__ShortDescription ?? ""); } } class AddEditPlanPMO(PlaywrightTester tester) { public string? PlanName { get; set; } public string? Price { get; set; } public string? TrialPeriod { get; set; } public string? GracePeriod { get; set; } public string? Description { get; set; } public bool? OptimisticActivation { get; set; } public List? EnableEntitlements { get; set; } public List? DisableEntitlements { get; set; } public PlanChangeType[]? PlanChanges { get; set; } public bool? Renewable { get; set; } public enum PlanChangeType { Downgrade, Upgrade, None } public async Task Save() { var s = tester; if (PlanName is not null) await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).FillAsync(PlanName); if (Description is not null) await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).FillAsync(Description); if (Price is not null) await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).FillAsync(Price); if (TrialPeriod is not null) await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).FillAsync(TrialPeriod); if (GracePeriod is not null) await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).FillAsync(GracePeriod); if (PlanChanges is not null) { for (var i = 0; i < PlanChanges.Length; i++) { await s.Page.Locator($"#PlanChanges_{i}__SelectedType").SelectOptionAsync(new[] { PlanChanges[i].ToString() }); } } foreach (var entitlement in EnableEntitlements ?? []) { await s.Page.GetByTestId($"check_{entitlement}").CheckAsync(); } foreach (var entitlement in DisableEntitlements ?? []) { await s.Page.GetByTestId($"check_{entitlement}").UncheckAsync(); } if (OptimisticActivation is not null) await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).SetCheckedAsync(OptimisticActivation.Value); if (Renewable is not null) await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).SetCheckedAsync(Renewable.Value); await s.ClickPagePrimary(); await s.FindAlertMessage(); } public async Task ReadFields() { var s = tester; PlanName = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).InputValueAsync(); Description = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).InputValueAsync(); Price = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).InputValueAsync(); TrialPeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).InputValueAsync(); GracePeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).InputValueAsync(); foreach (var entitlement in await s.Page.QuerySelectorAllAsync(".entitlement-checkbox")) { var isChecked = await entitlement.IsCheckedAsync(); var id = (await entitlement.GetAttributeAsync("data-testid"))!.Substring(6); if (isChecked) { EnableEntitlements ??= new(); EnableEntitlements.Add(id); } else { DisableEntitlements ??= new(); DisableEntitlements.Add(id); } } OptimisticActivation = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).IsCheckedAsync(); Renewable = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).IsCheckedAsync(); List changes = new(); foreach (var change in await s.Page.Locator(".plan-change-select").AllAsync()) { changes.Add(Enum.Parse(await change.InputValueAsync())); } PlanChanges = changes.ToArray(); } public void AssertEqual(AddEditPlanPMO b) { Assert.Equal(PlanName ?? "", b.PlanName ?? ""); Assert.Equal(Description ?? "", b.Description ?? ""); Assert.Equal(Price ?? "", b.Price ?? ""); Assert.Equal(TrialPeriod ?? "", b.TrialPeriod ?? ""); Assert.Equal(GracePeriod ?? "", b.GracePeriod ?? ""); if (EnableEntitlements is not null && b.EnableEntitlements is not null) { Assert.Equal(EnableEntitlements.Count, b.EnableEntitlements.Count); var (ea, eb) = (EnableEntitlements.OrderBy(e => e).ToArray(), b.EnableEntitlements.OrderBy(e => e).ToArray()); for (int i = 0; i < EnableEntitlements.Count; i++) Assert.Equal(ea[i], eb[i]); } if (DisableEntitlements is not null && b.DisableEntitlements is not null) { Assert.Equal(DisableEntitlements.Count, b.DisableEntitlements.Count); var (ea, eb) = (DisableEntitlements.OrderBy(e => e).ToArray(), b.DisableEntitlements.OrderBy(e => e).ToArray()); for (int i = 0; i < DisableEntitlements.Count; i++) Assert.Equal(ea[i], eb[i]); } Assert.Equal(OptimisticActivation, b.OptimisticActivation); if (PlanChanges is not null && b.PlanChanges is not null) { Assert.Equal(PlanChanges.Length, b.PlanChanges.Length); for (var i = 0; i < PlanChanges.Length; i++) { Assert.Equal(PlanChanges[i], b.PlanChanges[i]); } } } } }