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.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Form; 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. // The name of the HTML5 node. Should be used as the key for the posted data.
public string Name; public string Name;
// The translated label of the 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 Label; 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. // 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. // 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; 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 // The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>(); public List<string> ValidationErrors = new List<string>();
public bool Required = false; public virtual bool IsValid()
public 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; namespace BTCPayServer.Abstractions.Form;
public class Fieldset public class Fieldset : Field
{ {
public bool Hidden { get; set; }
public string Label { get; set; }
public Fieldset() 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.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Form; namespace BTCPayServer.Abstractions.Form;
public class 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... // 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(); public List<AlertMessage> TopMessages { get; set; } = new();
// Groups of fields in the form // 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? // Are all the fields valid in the form?
public bool IsValid() public bool IsValid()
{ {
foreach (var fieldset in Fieldsets) return Fields.All(field => field.IsValid());
{
foreach (var field in fieldset.Fields)
{
if (!field.IsValid())
{
return false;
}
}
}
return true;
} }
public Field GetFieldByName(string name) 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)
{ {
foreach (var field in fieldset.Fields) prefix ??= string.Empty;
foreach (var field in fields)
{ {
if (name.Equals(field.Name)) var currentPrefix = prefix;
if (!string.IsNullOrEmpty(field.Name))
{
currentPrefix = $"{prefix}{field.Name}";
if (currentPrefix.Equals(name, StringComparison.InvariantCultureIgnoreCase))
{ {
return field; return field;
} }
currentPrefix += "_";
} }
var subFieldResult = GetFieldByName(name, field.Fields, currentPrefix);
if (subFieldResult is not null)
{
return subFieldResult;
}
} }
return null; return null;
} }
public List<string> GetAllNames() public List<string> GetAllNames()
{ {
var names = new List<string>(); return GetAllNames(Fields);
foreach (var fieldset in Fieldsets) }
private static List<string> GetAllNames(List<Field> fields)
{ {
foreach (var field in fieldset.Fields) var names = new List<string>();
foreach (var field in fields)
{
string prefix = string.Empty;
if (!string.IsNullOrEmpty(field.Name))
{ {
names.Add(field.Name); names.Add(field.Name);
prefix = $"{field.Name}_";
}
if (field.Fields.Any())
{
names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}" ));
} }
} }
return names; 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 string RedirectUrl { get; set; } = null;
public bool? RedirectAutomatically { get; set; } = null; public bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { 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 string EmbeddedCSS { get; set; } = null;
public CheckoutType? CheckoutType { 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? RedirectAutomatically { get; set; }
public bool? RequiresRefundEmail { get; set; } = null; public bool? RequiresRefundEmail { get; set; } = null;
public string DefaultLanguage { get; set; } public string DefaultLanguage { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
public CheckoutType? CheckoutType { get; set; } public CheckoutType? CheckoutType { get; set; }
} }
} }

View file

@ -24,5 +24,9 @@ namespace BTCPayServer.Client.Models
[JsonExtensionData] [JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } 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 DateTimeOffset CreatedTime { get; set; }
public string Id { get; set; } public string Id { get; set; }
public bool Archived { get; set; } public bool Archived { get; set; }
public enum PaymentRequestStatus public enum PaymentRequestStatus
{ {
Pending = 0, Pending = 0,

View file

@ -31,8 +31,6 @@ namespace BTCPayServer.Client.Models
public bool AnyoneCanCreateInvoice { get; set; } public bool AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { get; set; } public string DefaultCurrency { get; set; }
public bool RequiresRefundEmail { get; set; } public bool RequiresRefundEmail { get; set; }
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public CheckoutType CheckoutType { get; set; } public CheckoutType CheckoutType { get; set; }
public bool LightningAmountInSatoshi { 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, RedirectAutomatically = true,
RequiresRefundEmail = true, RequiresRefundEmail = true,
CheckoutFormId = GenericFormOption.Email.ToString()
}, },
AdditionalSearchTerms = new string[] { "Banana" } AdditionalSearchTerms = new string[] { "Banana" }
}); });
Assert.True(newInvoice.Checkout.RedirectAutomatically); Assert.True(newInvoice.Checkout.RedirectAutomatically);
Assert.True(newInvoice.Checkout.RequiresRefundEmail); Assert.True(newInvoice.Checkout.RequiresRefundEmail);
Assert.Equal(GenericFormOption.Email.ToString(), newInvoice.Checkout.CheckoutFormId);
Assert.Equal(user.StoreId, newInvoice.StoreId); Assert.Equal(user.StoreId, newInvoice.StoreId);
//list //list
var invoices = await viewOnly.GetInvoices(user.StoreId); var invoices = await viewOnly.GetInvoices(user.StoreId);

View file

@ -64,6 +64,61 @@ namespace BTCPayServer.Tests
s.Driver.Quit(); 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)] [Fact(Timeout = TestTimeout)]
public async Task CanUseCPFP() public async Task CanUseCPFP()
{ {

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest; using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@ -16,9 +17,11 @@ using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData; using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData; using StoreData = BTCPayServer.Data.StoreData;
@ -145,6 +148,7 @@ namespace BTCPayServer.Controllers
blob.EmbeddedCSS = viewModel.EmbeddedCSS; blob.EmbeddedCSS = viewModel.EmbeddedCSS;
blob.CustomCSSLink = viewModel.CustomCSSLink; blob.CustomCSSLink = viewModel.CustomCSSLink;
blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts; blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts;
blob.FormId = viewModel.FormId;
data.SetBlob(blob); data.SetBlob(blob);
var isNewPaymentRequest = string.IsNullOrEmpty(payReqId); var isNewPaymentRequest = string.IsNullOrEmpty(payReqId);
@ -174,6 +178,51 @@ namespace BTCPayServer.Controllers
return View(result); 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")] [HttpGet("{payReqId}/pay")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> PayPaymentRequest(string payReqId, bool redirectToInvoice = true, public async Task<IActionResult> PayPaymentRequest(string payReqId, bool redirectToInvoice = true,

View file

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

View file

@ -38,7 +38,6 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public CheckoutType CheckoutType { get; set; } public CheckoutType CheckoutType { get; set; }
public string CheckoutFormId { get; set; }
public bool RequiresRefundEmail { get; set; } public bool RequiresRefundEmail { get; set; }
public bool LightningAmountInSatoshi { get; set; } public bool LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { 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.Controllers.Greenfield;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike; using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Logging; using BTCPayServer.Logging;
@ -437,6 +438,7 @@ namespace BTCPayServer.Hosting
//also provide a factory that can impersonate user/store id //also provide a factory that can impersonate user/store id
services.AddSingleton<IBTCPayServerClientFactory, BTCPayServerClientFactory>(); services.AddSingleton<IBTCPayServerClientFactory, BTCPayServerClientFactory>();
services.AddPayoutProcesors(); services.AddPayoutProcesors();
services.AddForms();
services.AddAPIKeyAuthentication(); services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes(); services.AddBtcPayServerAuthenticationSchemes();

View file

@ -82,9 +82,6 @@ namespace BTCPayServer.Models
[JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
public bool? RequiresRefundEmail { get; set; } 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 //Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies
[JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
public IEnumerable<string> PaymentCurrencies { get; set; } public IEnumerable<string> PaymentCurrencies { get; set; }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ using BTCPayServer.ModelBinders;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -25,6 +26,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits; using NicolasDorier.RateLimits;
namespace BTCPayServer.Plugins.PointOfSale.Controllers namespace BTCPayServer.Plugins.PointOfSale.Controllers
@ -116,6 +118,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string notificationUrl, string notificationUrl,
string redirectUrl, string redirectUrl,
string choiceKey, string choiceKey,
string formId = null,
string formData = null,
string posData = null, string posData = null,
RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore, RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@ -214,7 +218,42 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
} }
} }
var store = await _appService.GetStore(app); 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 try
{ {
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
@ -235,7 +274,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
PosData = string.IsNullOrEmpty(posData) ? null : posData, PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically, RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods, SupportedTransactionCurrencies = paymentMethods,
CheckoutFormId = store.GetStoreBlob().CheckoutFormId,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail ? store.GetStoreBlob().RequiresRefundEmail
: requiresRefundEmail == RequiresRefundEmail.On, : requiresRefundEmail == RequiresRefundEmail.On,
@ -244,6 +282,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
cancellationToken, (entity) => cancellationToken, (entity) =>
{ {
entity.Metadata.OrderUrl = Request.GetDisplayUrl(); 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 }); 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)}", SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "", RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
RequiresRefundEmail = settings.RequiresRefundEmail, RequiresRefundEmail = settings.RequiresRefundEmail,
CheckoutFormId = settings.CheckoutFormId, FormId = settings.FormId
UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2
}; };
if (HttpContext?.Request != null) if (HttpContext?.Request != null)
{ {
@ -390,13 +434,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RequiresRefundEmail = vm.RequiresRefundEmail RequiresRefundEmail = vm.RequiresRefundEmail
}; };
if (storeBlob.CheckoutType == Client.Models.CheckoutType.V2) settings.FormId = vm.FormId;
{
settings.CheckoutFormId = vm.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: vm.CheckoutFormId;
}
app.Name = vm.AppName; app.Name = vm.AppName;
app.SetSettings(settings); app.SetSettings(settings);
await _appService.UpdateOrCreateApp(app); await _appService.UpdateOrCreateApp(app);

View file

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

View file

@ -58,7 +58,7 @@ namespace BTCPayServer.Services.Apps
public bool EnableTips { get; set; } public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { 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 const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF; public string ButtonText { get; set; } = BUTTON_TEXT_DEF;

View file

@ -446,8 +446,6 @@ namespace BTCPayServer.Services.Invoices
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; } public CheckoutType? CheckoutType { get; set; }
@ -578,7 +576,6 @@ namespace BTCPayServer.Services.Invoices
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m; dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price; dto.Price = Price;
dto.Currency = Currency; dto.Currency = Currency;
dto.CheckoutFormId = CheckoutFormId;
dto.CheckoutType = CheckoutType; dto.CheckoutType = CheckoutType;
dto.Buyer = new JObject(); dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName)); 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) public async Task<PaymentRequestData> CreateOrUpdatePaymentRequest(PaymentRequestData entity)
{ {
using var context = _ContextFactory.CreateContext(); await using var context = _ContextFactory.CreateContext();
if (string.IsNullOrEmpty(entity.Id)) if (string.IsNullOrEmpty(entity.Id))
{ {
entity.Id = Guid.NewGuid().ToString(); entity.Id = Guid.NewGuid().ToString();

View file

@ -1,17 +1,12 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Services.Stores; namespace BTCPayServer.Services.Stores;
public enum GenericFormOption public enum GenericFormOption
{ {
[Display(Name = "Inherit from store settings")]
InheritFromStore,
[Display(Name = "Do not request any information")] [Display(Name = "Do not request any information")]
None, None,
@ -24,24 +19,14 @@ public enum GenericFormOption
public static class CheckoutFormSelectList 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>(); var choices = new List<SelectListItem>
if (isStoreEntity)
{ {
var blob = store.GetStoreBlob(); GenericOptionItem(GenericFormOption.None),
var inherit = GenericOptionItem(GenericFormOption.InheritFromStore); GenericOptionItem(GenericFormOption.Email),
inherit.Text += Enum.TryParse<GenericFormOption>(blob.CheckoutFormId, out var item) GenericOptionItem(GenericFormOption.Address)
? $" ({DisplayName(item)})" };
: $" ({blob.CheckoutFormId})";
choices.Add(inherit);
}
choices.Add(GenericOptionItem(GenericFormOption.None));
choices.Add(GenericOptionItem(GenericFormOption.Email));
choices.Add(GenericOptionItem(GenericFormOption.Address));
var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId); var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId);
return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value); 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); ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
var store = ViewContext.HttpContext.GetStoreData(); var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
} }
<form method="post"> <form method="post">
@ -84,18 +83,14 @@
<span asp-validation-for="ButtonText" class="text-danger"></span> <span asp-validation-for="ButtonText" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
@if (Model.UseNewCheckout) <label asp-for="FormId" class="form-label"></label>
{ <select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<label asp-for="CheckoutFormId" class="form-label"></label> <span asp-validation-for="FormId" class="text-danger"></span>
<select asp-for="CheckoutFormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select> </div>
<span asp-validation-for="CheckoutFormId" class="text-danger"></span> <div class="form-group">
}
else
{
<label asp-for="RequiresRefundEmail" class="form-label"></label> <label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select> <select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span> <span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
</div> </div>
<section id="discounts" class="p-0"> <section id="discounts" class="p-0">
<h3 class="mt-5 mb-4">Discounts</h3> <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 @model BTCPayServer.Abstractions.Form.Form
@inject FormComponentProvider FormComponentProvider
@foreach (var fieldset in Model.Fieldsets) @foreach (var field in Model.Fields)
{ {
<fieldset> var partial = FormComponentProvider.CanHandle(field);
<legend class="h3 mt-4 mb-3">@fieldset.Label</legend> if (string.IsNullOrEmpty(partial))
@foreach (var field in fieldset.Fields)
{ {
@if ("text".Equals(field.Type)) continue;
{
<div class="form-group">
@if (field.Required)
{
<label class="form-label" for="@field.Name" data-required>
@field.Label
</label>
} }
else <partial name="@partial" for="@field"></partial>
{
<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>
} }

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 @model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
@using BTCPayServer.Services.Apps @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"); ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice");
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
} }
@section PageFootContent { @section PageFootContent {
@ -90,22 +89,13 @@
<h4 class="mt-5 mb-4">Customer Information</h4> <h4 class="mt-5 mb-4">Customer Information</h4>
<div class="form-group"> <div class="form-group">
<label asp-for="BuyerEmail" class="form-label"></label> <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> <span asp-validation-for="BuyerEmail" class="text-danger"></span>
</div> </div>
<div class="form-group"> <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> <label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select> <select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span> <span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
</div> </div>
<h4 class="mt-5 mb-2">Additional Options</h4> <h4 class="mt-5 mb-2">Additional Options</h4>

View file

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

View file

@ -1,7 +1,12 @@
@using BTCPayServer.Services.PaymentRequests @using BTCPayServer.Services.PaymentRequests
@using System.Globalization @using System.Globalization
@using BTCPayServer.Services.Stores
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel @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); ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id);
} }
@ -78,6 +83,18 @@
Receive updates for this payment request. Receive updates for this payment request.
</p> </p>
</div> </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>
</div> </div>
@ -146,4 +163,3 @@
} }
</div> </div>
} }

View file

@ -144,7 +144,12 @@
</div> </div>
} }
</noscript> </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"> <template v-if="srvModel.allowCustomPaymentAmounts && !srvModel.anyPendingInvoice">
<form v-on:submit="submitCustomAmountForm" class="d-print-none"> <form v-on:submit="submitCustomAmountForm" class="d-print-none">
<div class="row"> <div class="row">

View file

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

View file

@ -434,9 +434,9 @@
"checkoutType": { "checkoutType": {
"$ref": "#/components/schemas/CheckoutType" "$ref": "#/components/schemas/CheckoutType"
}, },
"checkoutFormId": { "formId": {
"type": "string", "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 "nullable": true
} }
} }

View file

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

View file

@ -470,6 +470,16 @@
"type": "boolean", "type": "boolean",
"description": "Whether to allow users to create invoices that partially pay the payment request ", "description": "Whether to allow users to create invoices that partially pay the payment request ",
"nullable": true "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": { "checkoutType": {
"$ref": "#/components/schemas/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": { "receipt": {
"nullable": true, "nullable": true,
"$ref": "#/components/schemas/ReceiptOptions", "$ref": "#/components/schemas/ReceiptOptions",

View file

@ -37,13 +37,13 @@ public class FakeCustodian : ICustodian
var fieldset = new Fieldset(); var fieldset = new Fieldset();
// Maybe a decimal type field would be better? // 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."); "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."); "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."); "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."); "Enter the amount of USD you want to have.");
fieldset.Label = "Your fake balances"; fieldset.Label = "Your fake balances";
@ -51,7 +51,7 @@ public class FakeCustodian : ICustodian
fieldset.Fields.Add(fakeLTCBalance); fieldset.Fields.Add(fakeLTCBalance);
fieldset.Fields.Add(fakeEURBalance); fieldset.Fields.Add(fakeEURBalance);
fieldset.Fields.Add(fakeUSDBalance); fieldset.Fields.Add(fakeUSDBalance);
form.Fieldsets.Add(fieldset); form.Fields.Add(fieldset);
return Task.FromResult(form); return Task.FromResult(form);
} }