Form Builder (#4137)

* wip

* Cleanups

* UI updates

* Update UIFormsController.cs

* Make predefined forms usable statically

* Add support for pos app + forms

* pay request form rough support

* invoice form through receipt page

* Display form name in inherit from store setting

* Do not request additional forms on invoice from pay request

* fix up code

* move checkoutform id in checkout appearance outside of checkotu v2 toggle

* general fixes for form system

* fix pav bug

* UI updates

* Fix warnings in Form builder (#4331)

* Fix build warnings about string?

Enable nullable on UIFormsController.cs
Fixes CS8632 The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

* Clean up lack of space in injected services in Submit() of UIFormsController.cs

* Remove unused variables (CS0219) and assignment of nullable value to nullable type (CS8600)

* Cleanup double semicolons while we're at tit

* Fix: If reverse proxy wasn't well configured, and error message should have been displayed (#4322)

* fix monero issue

* Server Settings: Update Policies page (#4326)

Handles the multiple submit buttons on that page and closes #4319.

Contains some UI unifications with other pages and also shows the block explorers without needing to toggle the section via JS.

* Change confirmed to settled. (#4328)

* POS: Fix null pointer

Introduced in #4307, the referenced object needs to be `itemChoice` instead of `choice`.

* Add documentation link to plugins (#4329)

* Add documentation link to plugins

* Minor UI updates

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>

* Fix flaky test (#4330)

* Fix flaky test

* Update BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs

Co-authored-by: d11n <mail@dennisreimann.de>

Co-authored-by: d11n <mail@dennisreimann.de>

* Remove invoice and store level form

* add form test

* fix migration for forms

* fix

* make pay request form submission redirect to invoice

* Refactor FormQuery to only be able to query single store and single form

* Put the Authorize at controller level on UIForms

* Fix warnings

* Fix ef request

* Fix query to forms, ensure no permission bypass

* Fix modify

* Remove storeId from step form

* Remove useless storeId parameter

* Hide custom form feature in UI

* Minor cleanups

* Remove custom form options from select for now

* More minor syntax cleanups

* Update test

* Add index - needs migration

* Refactoring: Use PostRedirect instead of TempData for data transfer

* Remove untested and unfinished code

* formResponse should be a JObject, not a string

* Fix case for Form type

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: JesterHodl <103882255+jesterhodl@users.noreply.github.com>
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
Co-authored-by: Andreas Tasch <andy.tasch@gmail.com>
This commit is contained in:
Andrew Camilleri 2022-11-25 02:42:55 +01:00 committed by GitHub
parent bb60c2ac48
commit 022285806b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 936 additions and 260 deletions

View file

@ -0,0 +1,18 @@
using Newtonsoft.Json;
namespace BTCPayServer.Abstractions
{
public class CamelCaseSerializerSettings
{
static CamelCaseSerializerSettings()
{
Settings = new JsonSerializerSettings()
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
Serializer = JsonSerializer.Create(Settings);
}
public static readonly JsonSerializerSettings Settings;
public static readonly JsonSerializer Serializer;
}
}

View file

@ -1,35 +1,34 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Form;
public abstract class Field
public class Field
{
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type;
// The name of the HTML5 node. Should be used as the key for the posted data.
public string Name;
// The translated label of the field.
public string Label;
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type;
// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
// If this is the first the user sees the form, then value and original value are the same. Value changes as the user starts interacting with the form.
public string Value;
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
public string OriginalValue;
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
public string HelpText;
// The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>();
public bool Required = false;
public bool IsValid()
public virtual bool IsValid()
{
return ValidationErrors.Count == 0;
return ValidationErrors.Count == 0 && Fields.All(field => field.IsValid());
}
[JsonExtensionData] public IDictionary<string, JToken> AdditionalData { get; set; }
public List<Field> Fields { get; set; } = new();
}

View file

@ -1,14 +1,12 @@
using System.Collections.Generic;
namespace BTCPayServer.Abstractions.Form;
public class Fieldset
public class Fieldset : Field
{
public bool Hidden { get; set; }
public string Label { get; set; }
public Fieldset()
{
this.Fields = new List<Field>();
Type = "fieldset";
}
public string Label { get; set; }
public List<Field> Fields { get; set; }
}

View file

@ -1,60 +1,154 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Form;
public class Form
{
#nullable enable
public static Form Parse(string str)
{
ArgumentNullException.ThrowIfNull(str);
return JObject.Parse(str).ToObject<Form>(CamelCaseSerializerSettings.Serializer) ?? throw new InvalidOperationException("Impossible to deserialize Form");
}
public override string ToString()
{
return JObject.FromObject(this, CamelCaseSerializerSettings.Serializer).ToString(Newtonsoft.Json.Formatting.Indented);
}
#nullable restore
// Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc...
public List<AlertMessage> TopMessages { get; set; } = new();
// Groups of fields in the form
public List<Fieldset> Fieldsets { get; set; } = new();
public List<Field> Fields { get; set; } = new();
// Are all the fields valid in the form?
public bool IsValid()
{
foreach (var fieldset in Fieldsets)
{
foreach (var field in fieldset.Fields)
{
if (!field.IsValid())
{
return false;
}
}
}
return true;
return Fields.All(field => field.IsValid());
}
public Field GetFieldByName(string name)
{
foreach (var fieldset in Fieldsets)
return GetFieldByName(name, Fields, null);
}
private static Field GetFieldByName(string name, List<Field> fields, string prefix)
{
prefix ??= string.Empty;
foreach (var field in fields)
{
foreach (var field in fieldset.Fields)
var currentPrefix = prefix;
if (!string.IsNullOrEmpty(field.Name))
{
if (name.Equals(field.Name))
currentPrefix = $"{prefix}{field.Name}";
if (currentPrefix.Equals(name, StringComparison.InvariantCultureIgnoreCase))
{
return field;
}
currentPrefix += "_";
}
var subFieldResult = GetFieldByName(name, field.Fields, currentPrefix);
if (subFieldResult is not null)
{
return subFieldResult;
}
}
return null;
}
public List<string> GetAllNames()
{
return GetAllNames(Fields);
}
private static List<string> GetAllNames(List<Field> fields)
{
var names = new List<string>();
foreach (var fieldset in Fieldsets)
foreach (var field in fields)
{
foreach (var field in fieldset.Fields)
string prefix = string.Empty;
if (!string.IsNullOrEmpty(field.Name))
{
names.Add(field.Name);
prefix = $"{field.Name}_";
}
if (field.Fields.Any())
{
names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}" ));
}
}
return names;
}
public void ApplyValuesFromOtherForm(Form form)
{
foreach (var fieldset in Fields)
{
foreach (var field in fieldset.Fields)
{
field.Value = form
.GetFieldByName(
$"{(string.IsNullOrEmpty(fieldset.Name) ? string.Empty : fieldset.Name + "_")}{field.Name}")
?.Value;
}
}
}
public void ApplyValuesFromForm(IFormCollection form)
{
var names = GetAllNames();
foreach (var name in names)
{
var field = GetFieldByName(name);
if (field is null || !form.TryGetValue(name, out var val))
{
continue;
}
field.Value = val;
}
}
public Dictionary<string, object> GetValues()
{
return GetValues(Fields);
}
private static Dictionary<string, object> GetValues(List<Field> fields)
{
var result = new Dictionary<string, object>();
foreach (Field field in fields)
{
var name = field.Name ?? string.Empty;
if (field.Fields.Any())
{
var values = GetValues(fields);
values.Remove(string.Empty, out var keylessValue);
result.TryAdd(name, values);
if (keylessValue is not Dictionary<string, object> dict) continue;
foreach (KeyValuePair<string,object> keyValuePair in dict)
{
result.TryAdd(keyValuePair.Key, keyValuePair.Value);
}
}
else
{
result.TryAdd(name, field.Value);
}
}
return result;
}
}

View file

@ -0,0 +1,27 @@
namespace BTCPayServer.Abstractions.Form;
public class HtmlInputField : Field
{
// The translated label of the field.
public string Label;
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
public string OriginalValue;
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
public string HelpText;
public bool Required;
public HtmlInputField(string label, string name, string value, bool required, string helpText, string type = "text")
{
Label = label;
Name = name;
Value = value;
OriginalValue = value;
Required = required;
HelpText = helpText;
Type = type;
}
// TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types.
}

View file

@ -1,19 +0,0 @@
namespace BTCPayServer.Abstractions.Form;
public class TextField : Field
{
public TextField(string label, string name, string value, bool required, string helpText)
{
this.Label = label;
this.Name = name;
this.Value = value;
this.OriginalValue = value;
this.Required = required;
this.HelpText = helpText;
this.Type = "text";
}
// TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types.
}

View file

@ -37,7 +37,7 @@ namespace BTCPayServer.Client.Models
public string RedirectUrl { get; set; } = null;
public bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { get; set; } = null;
public string CheckoutFormId { get; set; } = null;
public string FormId { get; set; } = null;
public string EmbeddedCSS { get; set; } = null;
public CheckoutType? CheckoutType { get; set; } = null;
}

View file

@ -85,8 +85,6 @@ namespace BTCPayServer.Client.Models
public bool? RedirectAutomatically { get; set; }
public bool? RequiresRefundEmail { get; set; } = null;
public string DefaultLanguage { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
public CheckoutType? CheckoutType { get; set; }
}
}

View file

@ -24,5 +24,9 @@ namespace BTCPayServer.Client.Models
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
public string FormId { get; set; }
public JObject FormResponse { get; set; }
}
}

View file

@ -12,7 +12,6 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset CreatedTime { get; set; }
public string Id { get; set; }
public bool Archived { get; set; }
public enum PaymentRequestStatus
{
Pending = 0,

View file

@ -31,8 +31,6 @@ namespace BTCPayServer.Client.Models
public bool AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { get; set; }
public bool RequiresRefundEmail { get; set; }
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType CheckoutType { get; set; }
public bool LightningAmountInSatoshi { get; set; }

View file

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Data;
public class FormData
{
public string Id { get; set; }
public string Name { get; set; }
public string Config { get; set; }
}

View file

@ -1599,13 +1599,11 @@ namespace BTCPayServer.Tests
{
RedirectAutomatically = true,
RequiresRefundEmail = true,
CheckoutFormId = GenericFormOption.Email.ToString()
},
AdditionalSearchTerms = new string[] { "Banana" }
});
Assert.True(newInvoice.Checkout.RedirectAutomatically);
Assert.True(newInvoice.Checkout.RequiresRefundEmail);
Assert.Equal(GenericFormOption.Email.ToString(), newInvoice.Checkout.CheckoutFormId);
Assert.Equal(user.StoreId, newInvoice.StoreId);
//list
var invoices = await viewOnly.GetInvoices(user.StoreId);

View file

@ -64,6 +64,61 @@ namespace BTCPayServer.Tests
s.Driver.Quit();
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseForms()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
s.CreateNewStore();
s.GenerateWallet(isHotWallet: true);
// Point Of Sale
s.Driver.FindElement(By.Id("StoreNav-CreateApp")).Click();
new SelectElement(s.Driver.FindElement(By.Id("SelectedAppType"))).SelectByValue("PointOfSale");
s.Driver.FindElement(By.Id("AppName")).SendKeys(Guid.NewGuid().ToString());
s.Driver.FindElement(By.Id("Create")).Click();
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.FindElement(By.CssSelector("button[type='submit']")).Click();
Assert.Contains("Enter your email", s.Driver.PageSource);
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
s.PayInvoice(true);
var invoiceId = s.Driver.Url[(s.Driver.Url.LastIndexOf("/", StringComparison.Ordinal) + 1)..];
s.GoToInvoice(invoiceId);
Assert.Contains("aa@aa.com", s.Driver.PageSource);
// Payment Request
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123");
s.Driver.FindElement(By.Id("Amount")).SendKeys("700");
new SelectElement(s.Driver.FindElement(By.Id("FormId"))).SelectByValue("Email");
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.XPath("//a[starts-with(@id, 'Edit-')]")).Click();
var editUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("ViewPaymentRequest")).Click();
s.Driver.FindElement(By.CssSelector("[data-test='form-button']")).Click();
Assert.Contains("Enter your email", s.Driver.PageSource);
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
s.Driver.Navigate().GoToUrl(editUrl);
Assert.Contains("aa@aa.com", s.Driver.PageSource);
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseCPFP()
{

View file

@ -9,7 +9,7 @@
<RunAnalyzersDuringBuild>False</RunAnalyzersDuringBuild>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Condition="'$(GitCommit)' != ''" Include="BTCPayServer.GitCommitAttribute">
<AssemblyAttribute Condition="'$(GitCommit)' != ''" Include="BTCPayServer.GitCommitAttribute">
<_Parameter1>$(GitCommit)</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

View file

@ -233,7 +233,7 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS,
RedirectAutomatically = request.RedirectAutomatically,
RequiresRefundEmail = BoolToRequiresRefundEmail(request.RequiresRefundEmail) ?? RequiresRefundEmail.InheritFromStore,
CheckoutFormId = request.CheckoutFormId,
FormId = request.FormId,
CheckoutType = request.CheckoutType ?? CheckoutType.V1
};
}

View file

@ -437,7 +437,6 @@ namespace BTCPayServer.Controllers.Greenfield
DefaultLanguage = entity.DefaultLanguage,
RedirectAutomatically = entity.RedirectAutomatically,
RequiresRefundEmail = entity.RequiresRefundEmail,
CheckoutFormId = entity.CheckoutFormId,
CheckoutType = entity.CheckoutType,
RedirectURL = entity.RedirectURLTemplate
},

View file

@ -127,7 +127,6 @@ namespace BTCPayServer.Controllers.Greenfield
//we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
NetworkFeeMode = storeBlob.NetworkFeeMode,
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
CheckoutFormId = storeBlob.CheckoutFormId,
CheckoutType = storeBlob.CheckoutType,
Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null),
LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi,
@ -167,7 +166,6 @@ namespace BTCPayServer.Controllers.Greenfield
blob.NetworkFeeMode = restModel.NetworkFeeMode;
blob.DefaultCurrency = restModel.DefaultCurrency;
blob.RequiresRefundEmail = restModel.RequiresRefundEmail;
blob.CheckoutFormId = restModel.CheckoutFormId;
blob.CheckoutType = restModel.CheckoutType;
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi;

View file

@ -179,7 +179,12 @@ namespace BTCPayServer.Controllers
}
JToken? receiptData = null;
i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData);
string? formResponse = null;
if (i.Metadata?.AdditionalData?.TryGetValue("formResponse", out var formResponseRaw)is true)
{
formResponseRaw.Value<string>();
}
var payments = i.GetPayments(true)
.Select(paymentEntity =>
{
@ -229,7 +234,6 @@ namespace BTCPayServer.Controllers
? new Dictionary<string, object>()
: PosDataParser.ParsePosData(receiptData.ToString())
});
}
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
{
@ -760,7 +764,6 @@ namespace BTCPayServer.Controllers
CustomLogoLink = storeBlob.CustomLogo,
LogoFileId = storeBlob.LogoFileId,
BrandColor = storeBlob.BrandColor,
CheckoutFormId = invoice.CheckoutFormId ?? storeBlob.CheckoutFormId,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
@ -1140,9 +1143,6 @@ namespace BTCPayServer.Controllers
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
CheckoutFormId = model.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: model.CheckoutFormId
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";

View file

@ -142,7 +142,6 @@ namespace BTCPayServer.Controllers
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.CheckoutFormId = invoice.CheckoutFormId;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter? excludeFilter = null;
@ -227,7 +226,6 @@ namespace BTCPayServer.Controllers
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod;
entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically;
entity.CheckoutFormId = invoice.Checkout.CheckoutFormId;
entity.CheckoutType = invoice.Checkout.CheckoutType;
entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail;
IPaymentFilter? excludeFilter = null;

View file

@ -9,6 +9,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Invoices;
@ -16,9 +17,11 @@ using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData;
@ -145,6 +148,7 @@ namespace BTCPayServer.Controllers
blob.EmbeddedCSS = viewModel.EmbeddedCSS;
blob.CustomCSSLink = viewModel.CustomCSSLink;
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
blob.FormId = viewModel.FormId;
data.SetBlob(blob);
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
@ -174,6 +178,51 @@ namespace BTCPayServer.Controllers
return View(result);
}
[HttpGet("{payReqId}/form")]
[HttpPost("{payReqId}/form")]
[AllowAnonymous]
public async Task<IActionResult> ViewPaymentRequestForm(string payReqId, [FromForm] string formId, [FromForm] string formData)
{
var result = await _PaymentRequestRepository.FindPaymentRequest(payReqId, GetUserId());
if (result == null)
{
return NotFound();
}
var prBlob = result.GetBlob();
var prFormId = prBlob.FormId;
switch (prFormId)
{
case null:
case { } when string.IsNullOrEmpty(prFormId):
break;
default:
// POST case: Handle form submit
if (!string.IsNullOrEmpty(formData) && formId == prFormId)
{
prBlob.FormResponse = JObject.Parse(formData);
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });
}
// GET or empty form data case: Redirect to form
return View("PostRedirect", new PostRedirectViewModel
{
AspController = "UIForms",
AspAction = "ViewPublicForm",
FormParameters =
{
{ "formId", prFormId },
{ "redirectUrl", Request.GetCurrentUrl() }
}
});
}
return RedirectToAction("ViewPaymentRequest", new { payReqId });
}
[HttpGet("{payReqId}/pay")]
[AllowAnonymous]
public async Task<IActionResult> PayPaymentRequest(string payReqId, bool redirectToInvoice = true,

View file

@ -1,3 +1,4 @@
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data;
@ -8,7 +9,7 @@ public static class CustodianAccountDataExtensions
{
var result = custodianAccountData.Blob == null
? new JObject()
: JObject.Parse(ZipUtils.Unzip(custodianAccountData.Blob));
: InvoiceRepository.FromBytes<JObject>(custodianAccountData.Blob);
return result;
}
@ -17,7 +18,8 @@ public static class CustodianAccountDataExtensions
var original = custodianAccountData.GetBlob();
if (JToken.DeepEquals(original, blob))
return false;
custodianAccountData.Blob = blob is null ? null : ZipUtils.Zip(blob.ToString(Newtonsoft.Json.Formatting.None));
custodianAccountData.Blob = blob is null ? null : InvoiceRepository.ToBytes(blob);
return true;
}
}

View file

@ -38,7 +38,6 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public CheckoutType CheckoutType { get; set; }
public string CheckoutFormId { get; set; }
public bool RequiresRefundEmail { get; set; }
public bool LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; }

View file

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public class FormComponentProvider : IFormComponentProvider
{
private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
public FormComponentProvider(IEnumerable<IFormComponentProvider> formComponentProviders)
{
_formComponentProviders = formComponentProviders;
}
public string CanHandle(Field field)
{
return _formComponentProviders.Select(formComponentProvider => formComponentProvider.CanHandle(field)).FirstOrDefault(result => !string.IsNullOrEmpty(result));
}
}

View file

@ -0,0 +1,22 @@
using BTCPayServer.Data.Data;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms;
public static class FormDataExtensions
{
public static void AddForms(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<FormDataService>();
serviceCollection.AddSingleton<FormComponentProvider>();
serviceCollection.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
}
public static string Serialize(this JObject form)
{
return JsonConvert.SerializeObject(form);
}
}

View file

@ -0,0 +1,35 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Forms;
public class FormDataService
{
public static readonly Form StaticFormEmail = new()
{
Fields = new List<Field>() {new HtmlInputField("Enter your email", "buyerEmail", null, true, null)}
};
public static readonly Form StaticFormAddress = new()
{
Fields = new List<Field>()
{
new HtmlInputField("Enter your email", "buyerEmail", null, true, null, "email"),
new HtmlInputField("Name", "buyerName", null, true, null),
new HtmlInputField("Address Line 1", "buyerAddress1", null, true, null),
new HtmlInputField("Address Line 2", "buyerAddress2", null, false, null),
new HtmlInputField("City", "buyerCity", null, true, null),
new HtmlInputField("Postcode", "buyerZip", null, false, null),
new HtmlInputField("State", "buyerState", null, false, null),
new HtmlInputField("Country", "buyerCountry", null, true, null)
}
};
}

View file

@ -0,0 +1,12 @@
using System.Linq;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public class HtmlFieldsetFormProvider: IFormComponentProvider
{
public string CanHandle(Field field)
{
return new[] { "fieldset"}.Contains(field.Type) ? "Forms/FieldSetElement" : null;
}
}

View file

@ -0,0 +1,34 @@
using System.Linq;
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public class HtmlInputFormProvider: IFormComponentProvider
{
public string CanHandle(Field field)
{
return new[] {
"text",
"radio",
"checkbox",
"password",
"file",
"hidden",
"button",
"submit",
"color",
"date",
"datetime-local",
"month",
"week",
"time",
"email",
"image",
"number",
"range",
"search",
"url",
"tel",
"reset"}.Contains(field.Type) ? "Forms/InputElement" : null;
}
}

View file

@ -0,0 +1,8 @@
using BTCPayServer.Abstractions.Form;
namespace BTCPayServer.Forms;
public interface IFormComponentProvider
{
public string CanHandle(Field field);
}

View file

@ -0,0 +1,13 @@
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Data.Data;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms.Models;
public class FormViewModel
{
public string RedirectUrl { get; set; }
public FormData FormData { get; set; }
Form _Form;
public Form Form { get => _Form ??= Form.Parse(FormData.Config); }
}

View file

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace BTCPayServer.Forms;
public class ModifyForm
{
public string Name { get; set; }
[DisplayName("Form configuration (JSON)")]
public string FormConfig { get; set; }
}

View file

@ -0,0 +1,99 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms;
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIFormsController : Controller
{
[AllowAnonymous]
[HttpGet("~/forms/{formId}")]
[HttpPost("~/forms")]
public IActionResult ViewPublicForm(string? formId, string? redirectUrl)
{
FormData? formData = string.IsNullOrEmpty(formId) ? null : GetFormData(formId);
if (formData == null)
{
return string.IsNullOrEmpty(redirectUrl)
? NotFound()
: Redirect(redirectUrl);
}
return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl });
}
[AllowAnonymous]
[HttpPost("~/forms/{formId}")]
public IActionResult SubmitForm(
string formId, string? redirectUrl,
[FromServices] StoreRepository storeRepository,
[FromServices] UIInvoiceController invoiceController)
{
var formData = GetFormData(formId);
if (formData?.Config is null)
{
return NotFound();
}
var dbForm = Form.Parse(formData.Config);
dbForm.ApplyValuesFromForm(Request.Form);
Dictionary<string, object> data = dbForm.GetValues();
// With redirect, the form comes from another entity that we need to send the data back to
if (!string.IsNullOrEmpty(redirectUrl))
{
return View("PostRedirect", new PostRedirectViewModel
{
FormUrl = redirectUrl,
FormParameters =
{
{ "formId", formId },
{ "formData", JsonConvert.SerializeObject(data) }
}
});
}
return NotFound();
}
private FormData? GetFormData(string id)
{
FormData? form = id switch
{
{ } formId when formId == GenericFormOption.Address.ToString() => new FormData
{
Config = FormDataService.StaticFormAddress.ToString(),
Id = GenericFormOption.Address.ToString(),
Name = "Provide your address",
},
{ } formId when formId == GenericFormOption.Email.ToString() => new FormData
{
Config = FormDataService.StaticFormEmail.ToString(),
Id = GenericFormOption.Email.ToString(),
Name = "Provide your email address",
},
_ => null
};
return form;
}
}

View file

@ -15,6 +15,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
@ -437,6 +438,7 @@ namespace BTCPayServer.Hosting
//also provide a factory that can impersonate user/store id
services.AddSingleton<IBTCPayServerClientFactory, BTCPayServerClientFactory>();
services.AddPayoutProcesors();
services.AddForms();
services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes();

View file

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

View file

@ -268,9 +268,6 @@ namespace BTCPayServer.Models
public Dictionary<string, InvoiceCryptoInfo.InvoicePaymentUrls> PaymentCodes { get; set; }
[JsonProperty("buyer")]
public JObject Buyer { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; }
}

View file

@ -88,9 +88,6 @@ namespace BTCPayServer.Models.InvoicingModels
{
get; set;
}
[Display(Name = "Request customer data on checkout")]
public string CheckoutFormId { get; set; }
public bool UseNewCheckout { get; set; }
}

View file

@ -72,7 +72,6 @@ namespace BTCPayServer.Models.InvoicingModels
public bool Activated { get; set; }
public string InvoiceCurrency { get; set; }
public string ReceiptLink { get; set; }
public string CheckoutFormId { get; set; }
public bool AltcoinsBuild { get; set; }
public CheckoutType CheckoutType { get; set; }
}

View file

@ -8,6 +8,7 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json.Linq;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
namespace BTCPayServer.Models.PaymentRequestViewModels
@ -35,6 +36,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
StoreId = data.StoreDataId;
Archived = data.Archived;
var blob = data.GetBlob();
FormId = blob.FormId;
Title = blob.Title;
Amount = blob.Amount;
Currency = blob.Currency;
@ -44,8 +46,14 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
CustomCSSLink = blob.CustomCSSLink;
EmbeddedCSS = blob.EmbeddedCSS;
AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts;
FormResponse = blob.FormResponse is null
? null
: blob.FormResponse.ToObject<Dictionary<string, object>>();
}
[Display(Name = "Request customer data on checkout")]
public string FormId { get; set; }
public bool Archived { get; set; }
public string Id { get; set; }
@ -77,6 +85,8 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public string EmbeddedCSS { get; set; }
[Display(Name = "Allow payee to create invoices in their own denomination")]
public bool AllowCustomPaymentAmounts { get; set; }
public Dictionary<string, object> FormResponse { get; set; }
}
public class ViewPaymentRequestViewModel
@ -165,6 +175,8 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public bool PendingInvoiceHasPayments { get; set; }
public string HubPath { get; set; }
public bool Archived { get; set; }
public string FormId { get; set; }
public bool FormSubmitted { get; set; }
public class PaymentRequestInvoice
{

View file

@ -97,6 +97,8 @@ namespace BTCPayServer.PaymentRequest
AmountDueFormatted = _currencies.FormatCurrency(amountDue, blob.Currency),
CurrencyData = _currencies.GetCurrencyData(blob.Currency, true),
LastUpdated = DateTime.UtcNow,
FormId = blob.FormId,
FormSubmitted = blob.FormResponse is not null,
AnyPendingInvoice = pendingInvoice != null,
PendingInvoiceHasPayments = pendingInvoice != null &&
pendingInvoice.ExceptionStatus != InvoiceExceptionStatus.None,

View file

@ -18,6 +18,7 @@ using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@ -25,6 +26,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Plugins.PointOfSale.Controllers
@ -116,6 +118,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string notificationUrl,
string redirectUrl,
string choiceKey,
string formId = null,
string formData = null,
string posData = null,
RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore,
CancellationToken cancellationToken = default)
@ -214,7 +218,42 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
}
}
var store = await _appService.GetStore(app);
var posFormId = settings.FormId;
JObject formResponse = null;
switch (posFormId)
{
case null:
case { } when string.IsNullOrEmpty(posFormId):
break;
default:
// POST case: Handle form submit
if (!string.IsNullOrEmpty(formData) && formId == posFormId)
{
formResponse = JObject.Parse(formData);
break;
}
var query = new QueryBuilder(Request.Query);
foreach (var keyValuePair in Request.Form)
{
query.Add(keyValuePair.Key, keyValuePair.Value.ToArray());
}
// GET or empty form data case: Redirect to form
return View("PostRedirect", new PostRedirectViewModel
{
AspController = "UIForms",
AspAction = "ViewPublicForm",
FormParameters =
{
{ "formId", posFormId },
{ "redirectUrl", Request.GetCurrentUrl() + query }
}
});
}
try
{
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
@ -235,7 +274,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods,
CheckoutFormId = store.GetStoreBlob().CheckoutFormId,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail
: requiresRefundEmail == RequiresRefundEmail.On,
@ -244,6 +282,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
cancellationToken, (entity) =>
{
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
if (formResponse is not null)
{
var meta = entity.Metadata.ToJObject();
meta.Merge(formResponse);
entity.Metadata = InvoiceMetadata.FromJObject(meta);
}
} );
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Data.Id });
}
@ -298,8 +343,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
RequiresRefundEmail = settings.RequiresRefundEmail,
CheckoutFormId = settings.CheckoutFormId,
UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2
FormId = settings.FormId
};
if (HttpContext?.Request != null)
{
@ -389,14 +433,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically),
RequiresRefundEmail = vm.RequiresRefundEmail
};
if (storeBlob.CheckoutType == Client.Models.CheckoutType.V2)
{
settings.CheckoutFormId = vm.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: vm.CheckoutFormId;
}
settings.FormId = vm.FormId;
app.Name = vm.AppName;
app.SetSettings(settings);
await _appService.UpdateOrCreateApp(app);

View file

@ -101,10 +101,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
[Display(Name = "Require refund email on checkout")]
public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore;
[Display(Name = "Request customer data on checkout")]
public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString();
public bool UseNewCheckout { get; set; }
[Display(Name = "Request customer data on checkout")]
public string FormId { get; set; } = null;
}
}

View file

@ -58,7 +58,7 @@ namespace BTCPayServer.Services.Apps
public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; }
public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString();
public string FormId { get; set; } = null;
public const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF;

View file

@ -446,8 +446,6 @@ namespace BTCPayServer.Services.Invoices
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; }
@ -578,7 +576,6 @@ namespace BTCPayServer.Services.Invoices
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price;
dto.Currency = Currency;
dto.CheckoutFormId = CheckoutFormId;
dto.CheckoutType = CheckoutType;
dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName));

View file

@ -22,7 +22,7 @@ namespace BTCPayServer.Services.PaymentRequests
public async Task<PaymentRequestData> CreateOrUpdatePaymentRequest(PaymentRequestData entity)
{
using var context = _ContextFactory.CreateContext();
await using var context = _ContextFactory.CreateContext();
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = Guid.NewGuid().ToString();

View file

@ -1,17 +1,12 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Services.Stores;
public enum GenericFormOption
{
[Display(Name = "Inherit from store settings")]
InheritFromStore,
[Display(Name = "Do not request any information")]
None,
@ -24,24 +19,14 @@ public enum GenericFormOption
public static class CheckoutFormSelectList
{
public static SelectList ForStore(StoreData store, string selectedFormId, bool isStoreEntity)
public static SelectList WithSelected(string selectedFormId)
{
var choices = new List<SelectListItem>();
if (isStoreEntity)
var choices = new List<SelectListItem>
{
var blob = store.GetStoreBlob();
var inherit = GenericOptionItem(GenericFormOption.InheritFromStore);
inherit.Text += Enum.TryParse<GenericFormOption>(blob.CheckoutFormId, out var item)
? $" ({DisplayName(item)})"
: $" ({blob.CheckoutFormId})";
choices.Add(inherit);
}
choices.Add(GenericOptionItem(GenericFormOption.None));
choices.Add(GenericOptionItem(GenericFormOption.Email));
choices.Add(GenericOptionItem(GenericFormOption.Address));
GenericOptionItem(GenericFormOption.None),
GenericOptionItem(GenericFormOption.Email),
GenericOptionItem(GenericFormOption.Address)
};
var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId);
return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value);

View file

@ -0,0 +1,27 @@
@using BTCPayServer.Abstractions.Form
@using BTCPayServer.Forms
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@inject FormComponentProvider FormComponentProvider
@model BTCPayServer.Abstractions.Form.Field
@{
if (Model is not Fieldset fieldset)
{
fieldset = JObject.FromObject(Model).ToObject<Fieldset>();
}
}
@if (!fieldset.Hidden)
{
<fieldset>
<legend class="h3 mt-4 mb-3">@fieldset.Label</legend>
@foreach (var field in fieldset.Fields)
{
var partial = FormComponentProvider.CanHandle(field);
if (string.IsNullOrEmpty(partial))
{
continue;
}
<partial name="@partial" for="@field"></partial>
}
</fieldset>
}

View file

@ -0,0 +1,33 @@
@using BTCPayServer.Abstractions.Form
@using Newtonsoft.Json.Linq
@model BTCPayServer.Abstractions.Form.Field
@{
if (Model is not HtmlInputField field)
{
field = JObject.FromObject(Model).ToObject<HtmlInputField>();
}
}
<div class="form-group">
@if (field.Required)
{
<label class="form-label" for="@field.Name" data-required>
@field.Label
</label>
}
else
{
<label class="form-label" for="@field.Name">
@field.Label
</label>
}
<input class="form-control @(field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="@field.Type" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="@("HelpText" + field.Name)"/>
@if (!string.IsNullOrEmpty(field.HelpText))
{
<small id="@("HelpText" + field.Name)" class="form-text text-muted">
@field.HelpText
</small>
}
</div>

View file

@ -7,8 +7,7 @@
@{
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
}
<form method="post">
@ -84,18 +83,14 @@
<span asp-validation-for="ButtonText" class="text-danger"></span>
</div>
<div class="form-group">
@if (Model.UseNewCheckout)
{
<label asp-for="CheckoutFormId" class="form-label"></label>
<select asp-for="CheckoutFormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="CheckoutFormId" class="text-danger"></span>
}
else
{
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
<label asp-for="FormId" class="form-label"></label>
<select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="FormId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
</div>
<section id="discounts" class="p-0">
<h3 class="mt-5 mb-4">Discounts</h3>

View file

@ -0,0 +1,60 @@
@model (Dictionary<string, object> Items, int Level)
@functions {
private bool IsValidURL(string source)
{
return Uri.TryCreate(source, UriKind.Absolute, out var uriResult) &&
(uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
}
<table class="table my-0">
@foreach (var (key, value) in Model.Items)
{
<tr>
@if (value is string str)
{
if (!string.IsNullOrEmpty(key))
{
<th class="w-150px">@Safe.Raw(key)</th>
}
<td>
@if (IsValidURL(str))
{
<a href="@Safe.Raw(str)" target="_blank" rel="noreferrer noopener">@Safe.Raw(str)</a>
}
else
{
@Safe.Raw(value?.ToString())
}
</td>
}
else if (value is Dictionary<string, object>subItems)
{
@* This is the array case *@
if (subItems.Count == 1 && subItems.First().Value is string str2)
{
<th class="w-150px">@Safe.Raw(key)</th>
<td>
@if (IsValidURL(str2))
{
<a href="@Safe.Raw(str2)" target="_blank" rel="noreferrer noopener">@Safe.Raw(str2)</a>
}
else
{
@Safe.Raw(subItems.First().Value?.ToString())
}
</td>
}
else
{
<td colspan="2" >
@Safe.Raw($"<h{Model.Level + 3} class=\"mt-4 mb-3\">{key}</h{Model.Level + 3}>")
<partial name="PosData" model="(subItems, Model.Level + 1)"/>
</td>
}
}
</tr>
}
</table>

View file

@ -1,34 +1,14 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Forms
@model BTCPayServer.Abstractions.Form.Form
@inject FormComponentProvider FormComponentProvider
@foreach (var fieldset in Model.Fieldsets)
@foreach (var field in Model.Fields)
{
<fieldset>
<legend class="h3 mt-4 mb-3">@fieldset.Label</legend>
@foreach (var field in fieldset.Fields)
{
@if ("text".Equals(field.Type))
{
<div class="form-group">
@if (field.Required)
{
<label class="form-label" for="@field.Name" data-required>
@field.Label
</label>
}
else
{
<label class="form-label" for="@field.Name">
@field.Label
</label>
}
<input class="form-control @(field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="text" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="HelpText@field.Name"/>
<small id="HelpText@field.Name" class="form-text text-muted">
@field.HelpText
</small>
</div>
}
}
</fieldset>
var partial = FormComponentProvider.CanHandle(field);
if (string.IsNullOrEmpty(partial))
{
continue;
}
<partial name="@partial" for="@field"></partial>
}

View file

@ -0,0 +1,56 @@
@using BTCPayServer.Components.ThemeSwitch
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@inject BTCPayServer.Services.ThemeSettings Theme
@model BTCPayServer.Forms.Models.FormViewModel
@{
Layout = null;
ViewData["Title"] = Model.FormData.Name;
}
<!DOCTYPE html>
<html lang="en" @(env.IsDeveloping ? " data-devenv" : "")>
<head>
<partial name="LayoutHead"/>
<meta name="robots" content="noindex,nofollow">
</head>
<body>
<div class="min-vh-100 d-flex flex-column">
<main class="flex-grow-1 py-5">
<div class="container" style="max-width:720px;">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) {{"Margin", "mb-4"}})"/>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
}
<partial name="_FormTopMessages" model="@Model.Form"/>
<div class="d-flex flex-column justify-content-center gap-4">
<h1 class="h3 text-center">@ViewData["Title"]</h1>
<div class="bg-tile p-3 p-sm-4 rounded">
<form asp-action="SubmitForm" asp-route-formId="@Model.FormData.Id">
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
}
<partial name="_Form" model="@Model.Form"/>
<input type="submit" class="btn btn-primary" value="Submit"/>
</form>
</div>
</div>
</div>
</main>
<footer class="pt-2 pb-4 d-print-none">
<div class="container d-flex flex-wrap align-items-center justify-content-center">
<span class="text-muted mx-2">
Powered by <a href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">BTCPay Server</a>
</span>
@if (!Theme.CustomTheme)
{
<vc:theme-switch css-class="text-muted mx-2" responsive="none"/>
}
</div>
</footer>
</div>
<partial name="LayoutFoot"/>
</body>
</html>

View file

@ -0,0 +1,3 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Views.Stores
@using BTCPayServer.Models.StoreViewModels

View file

@ -0,0 +1,7 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Views
@using BTCPayServer.Views.Stores
@{
ViewData.SetActiveCategory(typeof(StoreNavPages));
}

View file

@ -1,11 +1,10 @@
@model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
@using BTCPayServer.Services.Apps
@using BTCPayServer.Services.Stores
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@{
ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice");
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
}
@section PageFootContent {
@ -90,22 +89,13 @@
<h4 class="mt-5 mb-4">Customer Information</h4>
<div class="form-group">
<label asp-for="BuyerEmail" class="form-label"></label>
<input asp-for="BuyerEmail" class="form-control" />
<input asp-for="BuyerEmail" class="form-control"/>
<span asp-validation-for="BuyerEmail" class="text-danger"></span>
</div>
<div class="form-group">
@if (Model.UseNewCheckout)
{
<label asp-for="CheckoutFormId" class="form-label"></label>
<select asp-for="CheckoutFormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="CheckoutFormId" class="text-danger"></span>
}
else
{
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
</div>
<h4 class="mt-5 mb-2">Additional Options</h4>

View file

@ -47,7 +47,7 @@
}
else if (!isSettled)
{
<div class="lead text-center text-muted py-5 px-4 fw-semibold" id="invoice-unsettled">
<div class="lead text-center text-muted py-5 px-4 fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
@ -78,24 +78,24 @@
<div class="d-flex flex-column">
<dd class="text-muted mb-0 fw-semibold">Order ID</dd>
<dt class="fs-5 mb-0 text-break fw-semibold">
@if (!string.IsNullOrEmpty(Model.OrderUrl))
{
<a href="@Model.OrderUrl" rel="noreferrer noopener" target="_blank">
@if (string.IsNullOrEmpty(Model.OrderId))
{
<span>View Order</span>
}
else
{
@Model.OrderId
}
</a>
}
else
{
<span>@Model.OrderId</span>
}
</dt>
@if (!string.IsNullOrEmpty(Model.OrderUrl))
{
<a href="@Model.OrderUrl" rel="noreferrer noopener" target="_blank">
@if (string.IsNullOrEmpty(Model.OrderId))
{
<span>View Order</span>
}
else
{
@Model.OrderId
}
</a>
}
else
{
<span>@Model.OrderId</span>
}
</dt>
</div>
}
</dl>

View file

@ -1,10 +1,9 @@
@model (Dictionary<string, object> Items, int Level)
@functions{
public bool IsValidURL(string source)
@functions {
private bool IsValidURL(string source)
{
Uri uriResult;
return Uri.TryCreate(source, UriKind.Absolute, out uriResult) &&
return Uri.TryCreate(source, UriKind.Absolute, out var uriResult) &&
(uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
}

View file

@ -1,7 +1,12 @@
@using BTCPayServer.Services.PaymentRequests
@using System.Globalization
@using BTCPayServer.Services.Stores
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
@{
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id);
}
@ -32,7 +37,7 @@
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@ -78,6 +83,18 @@
Receive updates for this payment request.
</p>
</div>
<div class="form-group">
<label asp-for="FormId" class="form-label"></label>
<select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="FormId" class="text-danger"></span>
</div>
@if (Model.FormResponse is not null)
{
<div class="bg-tile rounded py-2 px-3 mb-5">
<partial name="PosData" model="(Model.FormResponse, 1)"/>
</div>
}
</div>
</div>
@ -130,20 +147,19 @@
@if (!string.IsNullOrEmpty(Model.Id))
{
<div class="d-flex gap-3 mt-3">
<a class="btn btn-secondary"
asp-action="ListInvoices"
asp-controller="UIInvoice"
asp-route-storeId="@Model.StoreId"
asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(Model.Id)}")">Invoices</a>
<a class="btn btn-secondary" asp-route-payReqId="@Model.Id" asp-action="ClonePaymentRequest" id="ClonePaymentRequest">Clone</a>
@if (!Model.Archived)
{
<a class="btn btn-secondary" data-bs-toggle="tooltip" title="Archive this payment request so that it does not appear in the payment request list by default" asp-controller="UIPaymentRequest" asp-action="TogglePaymentRequestArchival" asp-route-payReqId="@Model.Id" id="ArchivePaymentRequest">Archive</a>
}
else
{
<a class="btn btn-secondary" data-bs-toggle="tooltip" title="Unarchive this payment request" asp-controller="UIPaymentRequest" asp-action="TogglePaymentRequestArchival" asp-route-payReqId="@Model.Id" id="UnarchivePaymentRequest">Unarchive</a>
}
</div>
<a class="btn btn-secondary"
asp-action="ListInvoices"
asp-controller="UIInvoice"
asp-route-storeId="@Model.StoreId"
asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(Model.Id)}")">Invoices</a>
<a class="btn btn-secondary" asp-route-payReqId="@Model.Id" asp-action="ClonePaymentRequest" id="ClonePaymentRequest">Clone</a>
@if (!Model.Archived)
{
<a class="btn btn-secondary" data-bs-toggle="tooltip" title="Archive this payment request so that it does not appear in the payment request list by default" asp-controller="UIPaymentRequest" asp-action="TogglePaymentRequestArchival" asp-route-payReqId="@Model.Id" id="ArchivePaymentRequest">Archive</a>
}
else
{
<a class="btn btn-secondary" data-bs-toggle="tooltip" title="Unarchive this payment request" asp-controller="UIPaymentRequest" asp-action="TogglePaymentRequestArchival" asp-route-payReqId="@Model.Id" id="UnarchivePaymentRequest">Unarchive</a>
}
</div>
}

View file

@ -144,7 +144,12 @@
</div>
}
</noscript>
<template v-if="srvModel.isPending && !srvModel.archived" class="d-print-none">
<template v-if="srvModel.formId && srvModel.formId != 'None' && !srvModel.formSubmitted">
<a asp-action="ViewPaymentRequestForm" asp-route-payReqId="@Model.Id" class="btn btn-primary w-100 d-flex d-print-none align-items-center justify-content-center text-nowrap btn-lg" data-test="form-button">
Pay Invoice
</a>
</template>
<template v-else-if="srvModel.isPending && !srvModel.archived">
<template v-if="srvModel.allowCustomPaymentAmounts && !srvModel.anyPendingInvoice">
<form v-on:submit="submitCustomAmountForm" class="d-print-none">
<div class="row">

View file

@ -148,7 +148,7 @@
<input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.Enabled" class="form-check-label"></label>
</div>
<div class="form-check my-3">
<div class="form-check my-3">
<input asp-for="ReceiptOptions.ShowPayments" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.ShowPayments" class="form-check-label"></label>
</div>

View file

@ -22,6 +22,7 @@ namespace BTCPayServer.Views.Stores
PayoutProcessors,
[Obsolete("Use StoreNavPages.Plugins instead")]
Integrations,
Emails
Emails,
Forms
}
}

View file

@ -434,9 +434,9 @@
"checkoutType": {
"$ref": "#/components/schemas/CheckoutType"
},
"checkoutFormId": {
"formId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"description": "Form ID to request customer data",
"nullable": true
}
}

View file

@ -1088,11 +1088,6 @@
"V2"
]
},
"checkoutFormId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"nullable": true
},
"defaultLanguage": {
"type": "string",
"nullable": true,

View file

@ -470,6 +470,16 @@
"type": "boolean",
"description": "Whether to allow users to create invoices that partially pay the payment request ",
"nullable": true
},
"formId": {
"type": "string",
"description": "Form ID to request customer data",
"nullable": true
},
"formResponse": {
"type": "object",
"description": "Form data response",
"nullable": true
}
}
}

View file

@ -344,11 +344,6 @@
"checkoutType": {
"$ref": "#/components/schemas/CheckoutType"
},
"checkoutFormId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"nullable": true
},
"receipt": {
"nullable": true,
"$ref": "#/components/schemas/ReceiptOptions",

View file

@ -37,13 +37,13 @@ public class FakeCustodian : ICustodian
var fieldset = new Fieldset();
// Maybe a decimal type field would be better?
var fakeBTCBalance = new TextField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
var fakeBTCBalance = new HtmlInputField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
"Enter the amount of BTC you want to have.");
var fakeLTCBalance = new TextField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
var fakeLTCBalance = new HtmlInputField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
"Enter the amount of LTC you want to have.");
var fakeEURBalance = new TextField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
var fakeEURBalance = new HtmlInputField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
"Enter the amount of EUR you want to have.");
var fakeUSDBalance = new TextField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
var fakeUSDBalance = new HtmlInputField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
"Enter the amount of USD you want to have.");
fieldset.Label = "Your fake balances";
@ -51,7 +51,7 @@ public class FakeCustodian : ICustodian
fieldset.Fields.Add(fakeLTCBalance);
fieldset.Fields.Add(fakeEURBalance);
fieldset.Fields.Add(fakeUSDBalance);
form.Fieldsets.Add(fieldset);
form.Fields.Add(fieldset);
return Task.FromResult(form);
}