Add ability to require refund email from app level (#3013)

* Add ability to require refund email from app level

* Add ability request refund email when creating invoice manually

* Adjust labels

* Add UI tests

* Add Greenfield API support

* Rename RequiresRefundEmailType to RequiresRefundEmail

* Fix build

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Umar Bolatov 2021-10-27 07:32:56 -07:00 committed by GitHub
parent 0b5d0349d4
commit 8f117b5079
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 137 additions and 16 deletions

View file

@ -1,9 +1,5 @@
using System;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{

View file

@ -40,6 +40,7 @@ namespace BTCPayServer.Client.Models
public string RedirectURL { get; set; }
public bool? RedirectAutomatically { get; set; }
public bool? RequiresRefundEmail { get; set; } = null;
public string DefaultLanguage { get; set; }
}
}

View file

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Payments;
using BTCPayServer.Tests.Logging;
@ -73,6 +71,68 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = TestTimeout)]
public async Task CanHandleRefundEmailForm2()
{
using (var s = SeleniumTester.Create())
{
// Prepare user account and store
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser();
var store = s.CreateNewStore();
s.AddDerivationScheme("BTC");
// Now create an invoice that requires a refund email
var invoice = s.CreateInvoice(store.storeName, 100, "USD", "", null, true);
s.GoToInvoiceCheckout(invoice);
var emailInput = s.Driver.FindElement(By.Id("emailAddressFormInput"));
Assert.True(emailInput.Displayed);
emailInput.SendKeys("a@g.com");
var actionButton = s.Driver.FindElement(By.Id("emailAddressForm")).FindElement(By.CssSelector("button.action-button"));
actionButton.Click();
try // Sometimes the click only take the focus, without actually really clicking on it...
{
actionButton.Click();
}
catch { }
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.GoToHome();
// Now create an invoice that doesn't require a refund email
s.CreateInvoice(store.storeName, 100, "USD", "", null, false);
s.Driver.FindElement(By.ClassName("invoice-details-link")).Click();
s.Driver.AssertNoError();
s.Driver.Navigate().Back();
s.Driver.FindElement(By.ClassName("invoice-checkout-link")).Click();
Assert.NotEmpty(s.Driver.FindElements(By.Id("checkoutCtrl")));
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.GoToHome();
// Now create an invoice that requires refund email but already has one set, email input shouldn't show up
s.CreateInvoice(store.storeName, 100, "USD", "a@g.com", null, true);
s.Driver.FindElement(By.ClassName("invoice-details-link")).Click();
s.Driver.AssertNoError();
s.Driver.Navigate().Back();
s.Driver.FindElement(By.ClassName("invoice-checkout-link")).Click();
Assert.NotEmpty(s.Driver.FindElements(By.Id("checkoutCtrl")));
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
}
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseLanguageDropdown()
{

View file

@ -1094,11 +1094,13 @@ namespace BTCPayServer.Tests
Metadata = JObject.Parse("{\"itemCode\": \"testitem\", \"orderId\": \"testOrder\"}"),
Checkout = new CreateInvoiceRequest.CheckoutOptions()
{
RedirectAutomatically = true
RedirectAutomatically = true,
RequiresRefundEmail = true
},
AdditionalSearchTerms = new string[] { "Banana" }
});
Assert.True(newInvoice.Checkout.RedirectAutomatically);
Assert.True(newInvoice.Checkout.RequiresRefundEmail);
Assert.Equal(user.StoreId, newInvoice.StoreId);
//list
var invoices = await viewOnly.GetInvoices(user.StoreId);

View file

@ -364,6 +364,7 @@ namespace BTCPayServer.Tests
string currency = "USD",
string refundEmail = "",
string defaultPaymentMethod = null,
bool? requiresRefundEmail = null,
StatusMessageModel.StatusSeverity expectedSeverity = StatusMessageModel.StatusSeverity.Success
)
{
@ -378,6 +379,8 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.Name("StoreId")).SendKeys(storeName);
if (defaultPaymentMethod is string)
new SelectElement(Driver.FindElement(By.Name("DefaultPaymentMethod"))).SelectByValue(defaultPaymentMethod);
if (requiresRefundEmail is bool)
new SelectElement(Driver.FindElement(By.Name("RequiresRefundEmail"))).SelectByValue(requiresRefundEmail == true ? "1" : "2");
Driver.FindElement(By.Id("Create")).Click();
var statusElement = FindAlertMessage(expectedSeverity);

View file

@ -1344,7 +1344,7 @@ namespace BTCPayServer.Tests
s.Driver.SetCheckbox(By.Id("LNURLBech32Mode"), false);
s.Driver.SetCheckbox(By.Id("DisableBolt11PaymentMethod"), true);
}, false);
s.CreateInvoice(store.storeName, 0.0000001m, "BTC","",null, StatusMessageModel.StatusSeverity.Error);
s.CreateInvoice(store.storeName, 0.0000001m, "BTC","",null, expectedSeverity: StatusMessageModel.StatusSeverity.Error);
i = s.CreateInvoice(store.storeName, null, "BTC");

View file

@ -56,6 +56,7 @@ namespace BTCPayServer.Controllers
ShowCustomAmount = true;
ShowDiscount = true;
EnableTips = true;
RequiresRefundEmail = RequiresRefundEmail.InheritFromStore;
}
public string Title { get; set; }
public string Currency { get; set; }
@ -65,6 +66,7 @@ namespace BTCPayServer.Controllers
public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; }
public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; }
public const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF;
@ -118,7 +120,8 @@ namespace BTCPayServer.Controllers
NotificationUrl = settings.NotificationUrl,
RedirectUrl = settings.RedirectUrl,
SearchTerm = $"storeid:{app.StoreDataId}",
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : ""
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
RequiresRefundEmail = settings.RequiresRefundEmail
};
if (HttpContext?.Request != null)
{
@ -202,7 +205,8 @@ namespace BTCPayServer.Controllers
RedirectUrl = vm.RedirectUrl,
Description = vm.Description,
EmbeddedCSS = vm.EmbeddedCSS,
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically)
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically),
RequiresRefundEmail = vm.RequiresRefundEmail,
});
await _AppService.UpdateOrCreateApp(app);
TempData[WellKnownTempData.SuccessMessage] = "App updated";

View file

@ -101,7 +101,8 @@ namespace BTCPayServer.Controllers
CustomLogoLink = storeBlob.CustomLogo,
AppId = appId,
Description = settings.Description,
EmbeddedCSS = settings.EmbeddedCSS
EmbeddedCSS = settings.EmbeddedCSS,
RequiresRefundEmail = settings.RequiresRefundEmail
});
}
@ -120,7 +121,9 @@ namespace BTCPayServer.Controllers
string notificationUrl,
string redirectUrl,
string choiceKey,
string posData = null, CancellationToken cancellationToken = default)
string posData = null,
RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore,
CancellationToken cancellationToken = default)
{
var app = await _AppService.GetApp(appId, AppType.PointOfSale);
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
@ -226,6 +229,9 @@ namespace BTCPayServer.Controllers
PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail
: requiresRefundEmail == RequiresRefundEmail.On,
}, store, HttpContext.Request.GetAbsoluteRoot(),
new List<string>() { AppService.GetAppInternalTag(appId) },
cancellationToken);

View file

@ -420,6 +420,7 @@ namespace BTCPayServer.Controllers.GreenField
SpeedPolicy = entity.SpeedPolicy,
DefaultLanguage = entity.DefaultLanguage,
RedirectAutomatically = entity.RedirectAutomatically,
RequiresRefundEmail = entity.RequiresRefundEmail,
RedirectURL = entity.RedirectURLTemplate
}
};

View file

@ -18,8 +18,8 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Invoices.Export;
using BTCPayServer.Services.Rates;
@ -30,7 +30,6 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.RPC;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json.Linq;
@ -564,7 +563,7 @@ namespace BTCPayServer.Controllers
IsUnsetTopUp = invoice.IsUnsetTopUp(),
OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice),
CustomerEmail = invoice.RefundMail,
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
RequiresRefundEmail = invoice.RequiresRefundEmail ?? storeBlob.RequiresRefundEmail,
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds,
MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes,
@ -873,7 +872,10 @@ namespace BTCPayServer.Controllers
}),
DefaultPaymentMethod = model.DefaultPaymentMethod,
NotificationEmail = model.NotificationEmail,
ExtendedNotifications = model.NotificationEmail != null
ExtendedNotifications = model.NotificationEmail != null,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";

View file

@ -127,6 +127,7 @@ namespace BTCPayServer.Controllers
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter? excludeFilter = null;
@ -151,6 +152,7 @@ namespace BTCPayServer.Controllers
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken);
}
@ -178,6 +180,7 @@ namespace BTCPayServer.Controllers
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod;
entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically;
entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail;
IPaymentFilter? excludeFilter = null;
if (invoice.Checkout.PaymentMethods != null)
{
@ -188,6 +191,7 @@ namespace BTCPayServer.Controllers
}
entity.PaymentTolerance = invoice.Checkout.PaymentTolerance ?? storeBlob.PaymentTolerance;
entity.RedirectURLTemplate = invoice.Checkout.RedirectURL?.Trim();
entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, invoice.AdditionalSearchTerms, cancellationToken);

View file

@ -89,5 +89,7 @@ namespace BTCPayServer.Models.AppViewModels
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
public string Description { get; set; }
[Display(Name = "Require refund email on checkout")]
public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore;
}
}

View file

@ -64,5 +64,6 @@ namespace BTCPayServer.Models.AppViewModels
public string Description { get; set; }
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore;
}
}

View file

@ -79,6 +79,8 @@ namespace BTCPayServer.Models
[JsonProperty(PropertyName = "redirectAutomatically", DefaultValueHandling = DefaultValueHandling.Ignore)]
public bool? RedirectAutomatically { get; set; }
[JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
public bool? RequiresRefundEmail { get; set; }
//Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies
[JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]

View file

@ -4,6 +4,7 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Models.InvoicingModels
{
@ -87,5 +88,11 @@ namespace BTCPayServer.Models.InvoicingModels
{
get; set;
}
[Display(Name = "Require Refund Email")]
public RequiresRefundEmail RequiresRefundEmail
{
get; set;
}
}
}

View file

@ -18,4 +18,14 @@ namespace BTCPayServer.Services.Apps
[Display(Name = "Keypad only")]
Light
}
public enum RequiresRefundEmail
{
[Display(Name = "Inherit from store settings")]
InheritFromStore,
[Display(Name = "On")]
On,
[Display(Name = "Off")]
Off
}
}

View file

@ -378,6 +378,7 @@ namespace BTCPayServer.Services.Invoices
}
#pragma warning restore CS0618
public bool Refundable { get; set; }
public bool? RequiresRefundEmail { get; set; } = null;
public string RefundMail { get; set; }
[JsonProperty("redirectURL")]
public string RedirectURLTemplate { get; set; }

View file

@ -57,6 +57,11 @@
<input asp-for="ButtonText" class="form-control" required />
<span asp-validation-for="ButtonText" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-check-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select" required></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
</div>
<section id="discounts" class="p-0">
<h4 class="mt-5 mb-4">Discounts</h4>
<div class="form-check mb-4">

View file

@ -37,12 +37,14 @@
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id"/>
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.Price.Value, item.Price.Value);}
</form>
}
else
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@Safe.Raw(buttonText)
</button>
@ -94,6 +96,7 @@
{
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue">
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>

View file

@ -1,4 +1,5 @@
@model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
@using BTCPayServer.Services.Apps
@{
ViewData.SetActivePageAndTitle(InvoiceNavPages.Create, "Create an invoice");
}
@ -64,6 +65,11 @@
<input asp-for="BuyerEmail" class="form-control" />
<span asp-validation-for="BuyerEmail" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-check-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="NotificationUrl" class="form-label"></label>
<input asp-for="NotificationUrl" class="form-control" />

View file

@ -1094,6 +1094,11 @@
"nullable": true,
"description": "When the customer has paid the invoice, and a `redirectURL` is set, the checkout is redirected to `redirectURL` automatically if `redirectAutomatically` is true. Defaults to the store's settings. (The default store settings is false)"
},
"requiresRefundEmail": {
"type": "boolean",
"nullable": true,
"description": "Invoice will require user to provide a refund email if this option is set to `true`. Has no effect if `buyerEmail` metadata is set as there is no email to collect in this case."
},
"defaultLanguage": {
"type": "string",
"nullable": true,