Compare commits
36 Commits
193-bump
...
qtioqtqotn
Author | SHA1 | Date | |
---|---|---|---|
9a5f1ff712 | |||
3589417b58 | |||
55203e0b64 | |||
745c0cdeba | |||
a918288e3b | |||
e183138d2c | |||
d3e42862ed | |||
8860eec254 | |||
97e7e60cea | |||
44aaf7acbb | |||
9b721fae27 | |||
c3f412e3bb | |||
ee738a29f0 | |||
6c6544bf9b | |||
3d57b944ca | |||
acf003b1b4 | |||
52e108d32f | |||
7b96f96025 | |||
8db5e7e043 | |||
25fb5c1293 | |||
37f0498def | |||
02110f93d7 | |||
195dfc2c47 | |||
541b6cf9eb | |||
2c26b77afc | |||
99bcec5597 | |||
781190a65d | |||
3763480280 | |||
6fad5ebedb | |||
0690194aa1 | |||
03b94e2be3 | |||
18e34b3cbe | |||
a0bb3ace61 | |||
920ad67633 | |||
8b8f72129c | |||
b9b11e722c |
.github
BTCPayServer.Abstractions/Form
BTCPayServer.Client/Models
BTCPayServer.Data/Data
BTCPayServer.Tests
AltcoinTests
Checkoutv2Tests.csFastTests.csGreenfieldAPITests.csPOSTests.csSeleniumTester.csSeleniumTests.csServerTester.csTestAccount.csThirdPartyTests.csUnitTest1.csdocker-compose.altcoins.ymldocker-compose.ymlBTCPayServer
BTCPayServer.csprojSearchString.cs
Components
AppTopItems
MainNav
StoreLightningBalance
StoreRecentInvoices
StoreRecentTransactions
StoreSelector
StoreWalletBalance
TruncateCenter
WalletNav
Controllers
GreenField
GreenfieldAppsController.csGreenfieldInvoiceController.csGreenfieldLightningNodeApiController.csGreenfieldPullPaymentController.csGreenfieldStoreOnChainWalletsController.csGreenfieldStoresController.cs
UIHomeController.csUIInvoiceController.UI.csUIInvoiceController.csUILNURLController.csUIPaymentRequestController.csUIStoresController.csUIUserStoresController.csUIWalletsController.PSBT.csData
EventAggregator.csForms
FieldValueMirror.csFormDataExtensions.csHtmlInputFormProvider.csHtmlSelectFormProvider.csHtmlTextareaFormProvider.csUIFormsController.cs
HostedServices
Hosting
Models
InvoicingModels
StoreViewModels
Payments
PayoutProcessors
Plugins
Crowdfund
NFC
PointOfSale
Services
Views
Shared
Bitcoin
Crowdfund/Public
Forms
LayoutHeadTheme.cshtmlLightning
LightningLikeMethodCheckout-v2.cshtmlLightningLikeMethodCheckout.cshtmlViewLightningLikePaymentData.cshtml
PointOfSale/Public
PosData.cshtmlTemplateEditor.cshtmlUIForms
UIInvoice
CheckoutV2.cshtmlInvoice.cshtmlListInvoices.cshtmlListInvoicesPaymentsPartial.cshtml_RefundModal.cshtml
UIPaymentRequest
UIPayoutProcessors
UIStorePullPayments
UIStores
UIUserStores
UIWallets
wwwroot
checkout-v2
img
js
locales/checkout
main
swagger/v1
vendor/vue-sortable
Build
Changelog.mdbtcpayserver.sln
2
.github/codeql/codeql-config.yml
vendored
Normal file
2
.github/codeql/codeql-config.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
paths-ignore:
|
||||
- 'BTCPayServer/wwwroot/vendor/**/*.js'
|
80
.github/workflows/codeql.yml
vendored
Normal file
80
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,80 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
# Allow running tests manually. Usefull if scan failure, or need to rescan before next scheduled date.
|
||||
workflow_dispatch:
|
||||
# We scan only on a schedule for now, can uncomment the following to scan on commit or PR merge later on if deemed appropriate.
|
||||
# push:
|
||||
# branches: [ "master" ]
|
||||
# pull_request:
|
||||
# branches: [ "master" ]
|
||||
schedule:
|
||||
# Scan every Monday 06:00 UTC.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'csharp' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
@ -69,7 +69,6 @@ public class Form
|
||||
if (!nameReturned.Add(fullName))
|
||||
{
|
||||
errors.Add($"Form contains duplicate field names '{fullName}'");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return errors.Count == 0;
|
||||
@ -86,15 +85,10 @@ public class Form
|
||||
thisPath.Add(field.Name);
|
||||
yield return (thisPath, field);
|
||||
}
|
||||
|
||||
foreach (var child in field.Fields)
|
||||
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
|
||||
{
|
||||
if (field.Constant)
|
||||
child.Constant = true;
|
||||
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
descendant.Field.Constant = field.Constant || descendant.Field.Constant;
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ namespace BTCPayServer.Client.Models
|
||||
{
|
||||
RateThen,
|
||||
CurrentRate,
|
||||
OverpaidAmount,
|
||||
Fiat,
|
||||
Custom
|
||||
}
|
||||
@ -18,8 +19,13 @@ namespace BTCPayServer.Client.Models
|
||||
public string? Name { get; set; } = null;
|
||||
public string? PaymentMethod { get; set; }
|
||||
public string? Description { get; set; } = null;
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public RefundVariant? RefundVariant { get; set; }
|
||||
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal SubtractPercentage { get; set; }
|
||||
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal? CustomAmount { get; set; }
|
||||
public string? CustomCurrency { get; set; }
|
||||
|
@ -16,6 +16,8 @@ namespace BTCPayServer.Client.Models
|
||||
|
||||
public string Website { get; set; }
|
||||
|
||||
public string SupportUrl { get; set; }
|
||||
|
||||
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data;
|
||||
@ -41,4 +43,7 @@ public class LightningAddressDataBlob
|
||||
public decimal? Max { get; set; }
|
||||
|
||||
public JObject InvoiceMetadata { get; set; }
|
||||
|
||||
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Hosting;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.AppViewModels;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
@ -245,7 +246,7 @@ namespace BTCPayServer.Tests
|
||||
await tester.EnsureChannelsSetup();
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess(true);
|
||||
user.RegisterLightningNode("BTC", LightningConnectionType.Charge);
|
||||
user.RegisterLightningNode("BTC");
|
||||
user.RegisterDerivationScheme("BTC");
|
||||
user.RegisterDerivationScheme("LTC");
|
||||
|
||||
@ -651,6 +652,7 @@ donation:
|
||||
price: 1.02
|
||||
custom: true
|
||||
";
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
Assert.Equal("hello", vmpos.Title);
|
||||
@ -662,7 +664,6 @@ donation:
|
||||
Assert.Equal("good apple", vmview.Items[0].Title);
|
||||
Assert.Equal("orange", vmview.Items[1].Title);
|
||||
Assert.Equal(10.0m, vmview.Items[1].Price.Value);
|
||||
Assert.Equal("$5.00", vmview.Items[0].Price.Formatted);
|
||||
Assert.Equal("{0} Purchase", vmview.ButtonText);
|
||||
Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText);
|
||||
Assert.Equal("Wanna tip?", vmview.CustomTipText);
|
||||
@ -723,6 +724,7 @@ donation:
|
||||
price: 1.02
|
||||
custom: true
|
||||
";
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
publicApps = user.GetController<UIPointOfSaleController>();
|
||||
vmview = await publicApps.ViewPointOfSale(app.Id, PosViewType.Cart).AssertViewModelAsync<ViewPointOfSaleViewModel>();
|
||||
@ -750,6 +752,8 @@ inventoryitem:
|
||||
inventory: 1
|
||||
noninventoryitem:
|
||||
price: 10.0";
|
||||
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
|
||||
//inventoryitem has 1 item available
|
||||
@ -780,15 +784,13 @@ noninventoryitem:
|
||||
|
||||
//let's mark the inventoryitem invoice as invalid, this should return the item to back in stock
|
||||
var controller = tester.PayTester.GetController<UIInvoiceController>(user.UserId, user.StoreId);
|
||||
var appService = tester.PayTester.GetService<AppService>();
|
||||
var eventAggregator = tester.PayTester.GetService<EventAggregator>();
|
||||
Assert.IsType<JsonResult>(await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid"));
|
||||
//check that item is back in stock
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
Assert.Equal(1,
|
||||
appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory);
|
||||
AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory);
|
||||
}, 10000);
|
||||
|
||||
//test payment methods option
|
||||
@ -803,6 +805,8 @@ btconly:
|
||||
- BTC
|
||||
normal:
|
||||
price: 1.0";
|
||||
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
Assert.IsType<RedirectToActionResult>(publicApps
|
||||
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "btconly").Result);
|
||||
@ -847,18 +851,19 @@ g:
|
||||
custom: topup
|
||||
";
|
||||
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
Assert.DoesNotContain("custom", vmpos.Template);
|
||||
var items = appService.Parse(vmpos.Template, vmpos.Currency);
|
||||
Assert.Contains(items, item => item.Id == "a" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "b" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "c" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
|
||||
Assert.Contains(items, item => item.Id == "d" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "e" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
|
||||
Assert.Contains(items, item => item.Id == "f" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
|
||||
Assert.Contains(items, item => item.Id == "g" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
|
||||
|
||||
var items = AppService.Parse(vmpos.Template);
|
||||
Assert.Contains(items, item => item.Id == "a" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "b" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "c" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
|
||||
Assert.Contains(items, item => item.Id == "d" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
|
||||
Assert.Contains(items, item => item.Id == "e" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
|
||||
Assert.Contains(items, item => item.Id == "f" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
|
||||
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
|
||||
|
||||
Assert.IsType<RedirectToActionResult>(publicApps
|
||||
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
|
||||
invoices = user.BitPay.GetInvoices();
|
||||
|
@ -1,13 +1,9 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using NBitcoin;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.Extensions;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@ -40,8 +36,10 @@ namespace BTCPayServer.Tests
|
||||
|
||||
// Configure store url
|
||||
var storeUrl = "https://satoshisteaks.com/";
|
||||
var supportUrl = "https://support.satoshisteaks.com/{InvoiceId}/";
|
||||
s.GoToStore();
|
||||
s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl);
|
||||
s.Driver.FindElement(By.Id("StoreSupportUrl")).SendKeys(supportUrl);
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
|
||||
|
||||
@ -64,9 +62,9 @@ namespace BTCPayServer.Tests
|
||||
var qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
|
||||
var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
var payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
|
||||
var copyAddress = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
|
||||
var copyAddress = s.Driver.FindElement(By.CssSelector("#Address_BTC .truncate-center-start")).Text;
|
||||
Assert.Equal($"bitcoin:{address}", payUrl);
|
||||
Assert.StartsWith("bcrt", s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value"));
|
||||
Assert.StartsWith("bcrt", s.Driver.FindElement(By.CssSelector("#Address_BTC .truncate-center-start")).Text);
|
||||
Assert.DoesNotContain("lightning=", payUrl);
|
||||
Assert.Equal(address, copyAddress);
|
||||
Assert.Equal($"bitcoin:{address.ToUpperInvariant()}", qrValue);
|
||||
@ -86,7 +84,7 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
|
||||
Assert.StartsWith("lightning:lnurl", payUrl);
|
||||
Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.Id("Lightning_BTC")).GetAttribute("value"));
|
||||
Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.CssSelector("#Lightning_BTC .truncate-center-start")).Text);
|
||||
s.Driver.ElementDoesNotExist(By.Id("Address_BTC"));
|
||||
});
|
||||
|
||||
@ -101,7 +99,7 @@ namespace BTCPayServer.Tests
|
||||
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
|
||||
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
|
||||
copyAddress = s.Driver.FindElement(By.Id("Lightning_BTC_LightningLike")).GetAttribute("value");
|
||||
copyAddress = s.Driver.FindElement(By.CssSelector("#Lightning_BTC_LightningLike .truncate-center-start")).Text;
|
||||
Assert.Equal($"lightning:{address}", payUrl);
|
||||
Assert.Equal(address, copyAddress);
|
||||
Assert.Equal($"lightning:{address.ToUpperInvariant()}", qrValue);
|
||||
@ -140,8 +138,47 @@ namespace BTCPayServer.Tests
|
||||
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
|
||||
Assert.True(expiredSection.Displayed);
|
||||
Assert.Contains("Invoice Expired", expiredSection.Text);
|
||||
Assert.Contains("resubmit a payment", expiredSection.Text);
|
||||
Assert.DoesNotContain("This invoice expired with partial payment", expiredSection.Text);
|
||||
|
||||
});
|
||||
Assert.True(s.Driver.ElementDoesNotExist(By.Id("receipt-btn")));
|
||||
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ContactLink")));
|
||||
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
|
||||
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
|
||||
|
||||
// Expire paid partial
|
||||
s.GoToHome();
|
||||
invoiceId = s.CreateInvoice(2100, "EUR");
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
|
||||
|
||||
await Task.Delay(200);
|
||||
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
var amountFraction = "0.00001";
|
||||
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
|
||||
Money.Parse(amountFraction));
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
|
||||
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
|
||||
expirySeconds.Clear();
|
||||
expirySeconds.SendKeys("3");
|
||||
s.Driver.FindElement(By.Id("Expire")).Click();
|
||||
|
||||
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
|
||||
Assert.Contains("The invoice hasn't been paid in full.", paymentInfo.Text);
|
||||
Assert.Contains("Please send", paymentInfo.Text);
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
|
||||
Assert.True(expiredSection.Displayed);
|
||||
Assert.Contains("Invoice Expired", expiredSection.Text);
|
||||
Assert.Contains("This invoice expired with partial payment", expiredSection.Text);
|
||||
Assert.DoesNotContain("resubmit a payment", expiredSection.Text);
|
||||
});
|
||||
var contactLink = s.Driver.FindElement(By.Id("ContactLink"));
|
||||
Assert.Equal("Contact us", contactLink.Text);
|
||||
Assert.Matches(supportUrl.Replace("{InvoiceId}", invoiceId), contactLink.GetAttribute("href"));
|
||||
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
|
||||
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
|
||||
|
||||
// Test payment
|
||||
@ -166,7 +203,7 @@ namespace BTCPayServer.Tests
|
||||
// Pay partial amount
|
||||
await Task.Delay(200);
|
||||
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
var amountFraction = "0.00001";
|
||||
amountFraction = "0.00001";
|
||||
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
|
||||
Money.Parse(amountFraction));
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
@ -210,7 +247,8 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains("Invoice Paid", settledSection.Text);
|
||||
});
|
||||
s.Driver.FindElement(By.Id("confetti"));
|
||||
s.Driver.FindElement(By.Id("receipt-btn"));
|
||||
s.Driver.FindElement(By.Id("ReceiptLink"));
|
||||
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ContactLink")));
|
||||
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
|
||||
|
||||
// BIP21
|
||||
@ -229,8 +267,8 @@ namespace BTCPayServer.Tests
|
||||
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
|
||||
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
|
||||
var copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
|
||||
var copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value");
|
||||
var copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC .truncate-center-start")).Text;
|
||||
var copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC .truncate-center-start")).Text;
|
||||
Assert.StartsWith($"bitcoin:{address}?amount=", payUrl);
|
||||
Assert.Contains("?amount=", payUrl);
|
||||
Assert.Contains("&lightning=", payUrl);
|
||||
@ -297,8 +335,8 @@ namespace BTCPayServer.Tests
|
||||
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
|
||||
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
|
||||
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
|
||||
copyAddressOnchain = s.Driver.FindElement(By.Id("Address_BTC")).GetAttribute("value");
|
||||
copyAddressLightning = s.Driver.FindElement(By.Id("Lightning_BTC")).GetAttribute("value");
|
||||
copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC .truncate-center-start")).Text;
|
||||
copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC .truncate-center-start")).Text;
|
||||
Assert.StartsWith($"bitcoin:{address}", payUrl);
|
||||
Assert.Contains("?lightning=lnurl", payUrl);
|
||||
Assert.DoesNotContain("amount=", payUrl);
|
||||
@ -358,6 +396,7 @@ namespace BTCPayServer.Tests
|
||||
s.GoToHome();
|
||||
s.GoToLightningSettings();
|
||||
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), false);
|
||||
s.Driver.ScrollTo(By.Id("save"));
|
||||
s.Driver.FindElement(By.Id("save")).Click();
|
||||
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
|
||||
|
||||
|
@ -1042,14 +1042,13 @@ namespace BTCPayServer.Tests
|
||||
[Fact]
|
||||
public void CanParseFilter()
|
||||
{
|
||||
var storeId = "6DehZnc9S7qC6TUTNWuzJ1pFsHTHvES6An21r3MjvLey";
|
||||
var filter = "storeid:abc, status:abed, blabhbalh ";
|
||||
var search = new SearchString(filter);
|
||||
Assert.Equal("storeid:abc, status:abed, blabhbalh", search.ToString());
|
||||
Assert.Equal("blabhbalh", search.TextSearch);
|
||||
Assert.Single(search.Filters["storeid"]);
|
||||
Assert.Single(search.Filters["status"]);
|
||||
Assert.Equal("abc", search.Filters["storeid"].First());
|
||||
Assert.Equal("abed", search.Filters["status"].First());
|
||||
Assert.Single(search.Filters["storeid"], "abc");
|
||||
Assert.Single(search.Filters["status"], "abed");
|
||||
|
||||
filter = "status:abed, status:abed2";
|
||||
search = new SearchString(filter);
|
||||
@ -1064,6 +1063,48 @@ namespace BTCPayServer.Tests
|
||||
search = new SearchString(filter);
|
||||
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
|
||||
Assert.Equal("hekki", search.TextSearch);
|
||||
|
||||
// modify search
|
||||
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
|
||||
search = new SearchString(filter);
|
||||
Assert.Equal(filter, search.ToString());
|
||||
Assert.Equal("fulltext searchterm", search.TextSearch);
|
||||
Assert.Single(search.Filters["storeid"], storeId);
|
||||
Assert.Single(search.Filters["status"], "settled");
|
||||
Assert.Single(search.Filters["exceptionstatus"], "paidLate");
|
||||
Assert.Single(search.Filters["unusual"], "true");
|
||||
|
||||
// toggle off bool with same value
|
||||
var modified = new SearchString(search.Toggle("unusual", "true"));
|
||||
Assert.Null(modified.GetFilterBool("unusual"));
|
||||
|
||||
// add to array
|
||||
modified = new SearchString(modified.Toggle("status", "processing"));
|
||||
var statusArray = modified.GetFilterArray("status");
|
||||
Assert.Equal(2, statusArray.Length);
|
||||
Assert.Contains("processing", statusArray);
|
||||
Assert.Contains("settled", statusArray);
|
||||
|
||||
// toggle off array with same value
|
||||
modified = new SearchString(modified.Toggle("status", "settled"));
|
||||
statusArray = modified.GetFilterArray("status");
|
||||
Assert.Single(statusArray, "processing");
|
||||
|
||||
// toggle off array with null value
|
||||
modified = new SearchString(modified.Toggle("status", null));
|
||||
Assert.Null(modified.GetFilterArray("status"));
|
||||
|
||||
// toggle off date with null value
|
||||
modified = new SearchString(modified.Toggle("startdate", "-7d"));
|
||||
Assert.Single(modified.GetFilterArray("startdate"), "-7d");
|
||||
modified = new SearchString(modified.Toggle("startdate", null));
|
||||
Assert.Null(modified.GetFilterArray("startdate"));
|
||||
|
||||
// toggle off date with same value
|
||||
modified = new SearchString(modified.Toggle("enddate", "-7d"));
|
||||
Assert.Single(modified.GetFilterArray("enddate"), "-7d");
|
||||
modified = new SearchString(modified.Toggle("enddate", "-7d"));
|
||||
Assert.Null(modified.GetFilterArray("enddate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -1950,6 +1950,82 @@ namespace BTCPayServer.Tests
|
||||
CustomCurrency = "BTC"
|
||||
});
|
||||
Assert.True(pp.AutoApproveClaims);
|
||||
|
||||
// test subtract percentage
|
||||
validationError = await AssertValidationError(new[] { "SubtractPercentage" }, async () =>
|
||||
{
|
||||
await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
|
||||
{
|
||||
PaymentMethod = method.PaymentMethod,
|
||||
RefundVariant = RefundVariant.RateThen,
|
||||
SubtractPercentage = 101
|
||||
});
|
||||
});
|
||||
Assert.Contains("SubtractPercentage: Percentage must be a numeric value between 0 and 100", validationError.Message);
|
||||
|
||||
// should auto-approve
|
||||
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
|
||||
{
|
||||
PaymentMethod = method.PaymentMethod,
|
||||
RefundVariant = RefundVariant.RateThen,
|
||||
SubtractPercentage = 6.15m
|
||||
});
|
||||
Assert.Equal("BTC", pp.Currency);
|
||||
Assert.True(pp.AutoApproveClaims);
|
||||
Assert.Equal(0.9385m, pp.Amount);
|
||||
|
||||
// test RefundVariant.OverpaidAmount
|
||||
validationError = await AssertValidationError(new[] { "RefundVariant" }, async () =>
|
||||
{
|
||||
await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
|
||||
{
|
||||
PaymentMethod = method.PaymentMethod,
|
||||
RefundVariant = RefundVariant.OverpaidAmount
|
||||
});
|
||||
});
|
||||
Assert.Contains("Invoice is not overpaid", validationError.Message);
|
||||
|
||||
// should auto-approve
|
||||
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" });
|
||||
methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id);
|
||||
method = methods.First();
|
||||
|
||||
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
|
||||
{
|
||||
await tester.ExplorerNode.SendToAddressAsync(
|
||||
BitcoinAddress.Create(method.Destination, tester.NetworkProvider.BTC.NBitcoinNetwork),
|
||||
Money.Coins(method.Due * 2)
|
||||
);
|
||||
});
|
||||
|
||||
await tester.ExplorerNode.GenerateAsync(5);
|
||||
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
invoice = await client.GetInvoice(user.StoreId, invoice.Id);
|
||||
Assert.True(invoice.Status == InvoiceStatus.Settled);
|
||||
Assert.True(invoice.AdditionalStatus == InvoiceExceptionStatus.PaidOver);
|
||||
});
|
||||
|
||||
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
|
||||
{
|
||||
PaymentMethod = method.PaymentMethod,
|
||||
RefundVariant = RefundVariant.OverpaidAmount
|
||||
});
|
||||
Assert.Equal("BTC", pp.Currency);
|
||||
Assert.True(pp.AutoApproveClaims);
|
||||
Assert.Equal(method.Due, pp.Amount);
|
||||
|
||||
// once more with subtract percentage
|
||||
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
|
||||
{
|
||||
PaymentMethod = method.PaymentMethod,
|
||||
RefundVariant = RefundVariant.OverpaidAmount,
|
||||
SubtractPercentage = 21m
|
||||
});
|
||||
Assert.Equal("BTC", pp.Currency);
|
||||
Assert.True(pp.AutoApproveClaims);
|
||||
Assert.Equal(0.79m, pp.Amount);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
@ -2257,7 +2333,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.Single(paymentMethods);
|
||||
Assert.True(paymentMethods.First().Activated);
|
||||
|
||||
var invoiceWithdefaultPaymentMethodLN = await client.CreateInvoice(user.StoreId,
|
||||
var invoiceWithDefaultPaymentMethodLN = await client.CreateInvoice(user.StoreId,
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Currency = "USD",
|
||||
@ -2268,9 +2344,9 @@ namespace BTCPayServer.Tests
|
||||
DefaultPaymentMethod = "BTC_LightningLike"
|
||||
}
|
||||
});
|
||||
Assert.Equal("BTC_LightningLike", invoiceWithdefaultPaymentMethodLN.Checkout.DefaultPaymentMethod);
|
||||
Assert.Equal("BTC_LightningLike", invoiceWithDefaultPaymentMethodLN.Checkout.DefaultPaymentMethod);
|
||||
|
||||
var invoiceWithdefaultPaymentMethodOnChain = await client.CreateInvoice(user.StoreId,
|
||||
var invoiceWithDefaultPaymentMethodOnChain = await client.CreateInvoice(user.StoreId,
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Currency = "USD",
|
||||
@ -2281,13 +2357,35 @@ namespace BTCPayServer.Tests
|
||||
DefaultPaymentMethod = "BTC"
|
||||
}
|
||||
});
|
||||
Assert.Equal("BTC", invoiceWithdefaultPaymentMethodOnChain.Checkout.DefaultPaymentMethod);
|
||||
|
||||
Assert.Equal("BTC", invoiceWithDefaultPaymentMethodOnChain.Checkout.DefaultPaymentMethod);
|
||||
|
||||
// reset lazy payment methods
|
||||
store = await client.GetStore(user.StoreId);
|
||||
store.LazyPaymentMethods = false;
|
||||
store = await client.UpdateStore(store.Id,
|
||||
JObject.FromObject(store).ToObject<UpdateStoreRequest>());
|
||||
Assert.False(store.LazyPaymentMethods);
|
||||
|
||||
// use store default payment method
|
||||
store = await client.GetStore(user.StoreId);
|
||||
Assert.Null(store.DefaultPaymentMethod);
|
||||
var storeDefaultPaymentMethod = "BTC-LightningNetwork";
|
||||
store.DefaultPaymentMethod = storeDefaultPaymentMethod;
|
||||
store = await client.UpdateStore(store.Id,
|
||||
JObject.FromObject(store).ToObject<UpdateStoreRequest>());
|
||||
Assert.Equal(storeDefaultPaymentMethod, store.DefaultPaymentMethod);
|
||||
|
||||
var invoiceWithStoreDefaultPaymentMethod = await client.CreateInvoice(user.StoreId,
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Currency = "USD",
|
||||
Amount = 100,
|
||||
Checkout = new CreateInvoiceRequest.CheckoutOptions()
|
||||
{
|
||||
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
|
||||
}
|
||||
});
|
||||
Assert.Equal(storeDefaultPaymentMethod, invoiceWithStoreDefaultPaymentMethod.Checkout.DefaultPaymentMethod);
|
||||
|
||||
//let's see the overdue amount
|
||||
invoice = await client.CreateInvoice(user.StoreId,
|
||||
@ -2344,27 +2442,10 @@ namespace BTCPayServer.Tests
|
||||
Assert.NotNull(merchantInvoice.PaymentHash);
|
||||
Assert.Equal(merchantInvoice.Id, merchantInvoice.PaymentHash);
|
||||
|
||||
// The default client is using charge, so we should not be able to query channels
|
||||
var chargeClient = await user.CreateClient(Policies.CanUseInternalLightningNode);
|
||||
|
||||
var info = await chargeClient.GetLightningNodeInfo("BTC");
|
||||
Assert.Single(info.NodeURIs);
|
||||
Assert.NotEqual(0, info.BlockHeight);
|
||||
Assert.NotNull(info.Alias);
|
||||
Assert.NotNull(info.Color);
|
||||
Assert.NotNull(info.Version);
|
||||
Assert.NotNull(info.PeersCount);
|
||||
Assert.NotNull(info.ActiveChannelsCount);
|
||||
Assert.NotNull(info.InactiveChannelsCount);
|
||||
Assert.NotNull(info.PendingChannelsCount);
|
||||
|
||||
var gex = await AssertAPIError("lightning-node-unavailable", () => chargeClient.ConnectToLightningNode("BTC", new ConnectToNodeRequest(NodeInfo.Parse($"{new Key().PubKey.ToHex()}@localhost:3827"))));
|
||||
Assert.Contains("NotSupported", gex.Message);
|
||||
|
||||
await AssertAPIError("lightning-node-unavailable", () => chargeClient.GetLightningNodeChannels("BTC"));
|
||||
var client = await user.CreateClient(Policies.CanUseInternalLightningNode);
|
||||
// Not permission for the store!
|
||||
await AssertAPIError("missing-permission", () => chargeClient.GetLightningNodeChannels(user.StoreId, "BTC"));
|
||||
var invoiceData = await chargeClient.CreateLightningInvoice("BTC", new CreateLightningInvoiceRequest()
|
||||
await AssertAPIError("missing-permission", () => client.GetLightningNodeChannels(user.StoreId, "BTC"));
|
||||
var invoiceData = await client.CreateLightningInvoice("BTC", new CreateLightningInvoiceRequest()
|
||||
{
|
||||
Amount = LightMoney.Satoshis(1000),
|
||||
Description = "lol",
|
||||
@ -2372,17 +2453,17 @@ namespace BTCPayServer.Tests
|
||||
PrivateRouteHints = false
|
||||
});
|
||||
var chargeInvoice = invoiceData;
|
||||
Assert.NotNull(await chargeClient.GetLightningInvoice("BTC", invoiceData.Id));
|
||||
Assert.NotNull(await client.GetLightningInvoice("BTC", invoiceData.Id));
|
||||
|
||||
// check list for internal node
|
||||
var invoices = await chargeClient.GetLightningInvoices("BTC");
|
||||
var pendingInvoices = await chargeClient.GetLightningInvoices("BTC", true);
|
||||
var invoices = await client.GetLightningInvoices("BTC");
|
||||
var pendingInvoices = await client.GetLightningInvoices("BTC", true);
|
||||
Assert.NotEmpty(invoices);
|
||||
Assert.Contains(invoices, i => i.Id == invoiceData.Id);
|
||||
Assert.NotEmpty(pendingInvoices);
|
||||
Assert.Contains(pendingInvoices, i => i.Id == invoiceData.Id);
|
||||
|
||||
var client = await user.CreateClient($"{Policies.CanUseLightningNodeInStore}:{user.StoreId}");
|
||||
client = await user.CreateClient($"{Policies.CanUseLightningNodeInStore}:{user.StoreId}");
|
||||
// Not permission for the server
|
||||
await AssertAPIError("missing-permission", () => client.GetLightningNodeChannels("BTC"));
|
||||
|
||||
@ -2461,7 +2542,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains(payments, i => i.BOLT11 == merchantInvoice.BOLT11);
|
||||
|
||||
// Node info
|
||||
info = await client.GetLightningNodeInfo(user.StoreId, "BTC");
|
||||
var info = await client.GetLightningNodeInfo(user.StoreId, "BTC");
|
||||
Assert.Single(info.NodeURIs);
|
||||
Assert.NotEqual(0, info.BlockHeight);
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Hosting;
|
||||
using BTCPayServer.Models.AppViewModels;
|
||||
using BTCPayServer.Plugins.PointOfSale;
|
||||
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@ -19,6 +21,74 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Fast", "Fast")]
|
||||
public void CanParseOldYmlCorrectly()
|
||||
{
|
||||
var testOriginalDefaultYmlTemplate = @"
|
||||
green tea:
|
||||
price: 1
|
||||
title: Green Tea
|
||||
description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.
|
||||
image: ~/img/pos-sample/green-tea.jpg
|
||||
|
||||
black tea:
|
||||
price: 1
|
||||
title: Black Tea
|
||||
description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.
|
||||
image: ~/img/pos-sample/black-tea.jpg
|
||||
|
||||
rooibos:
|
||||
price: 1.2
|
||||
title: Rooibos
|
||||
description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.
|
||||
image: ~/img/pos-sample/rooibos.jpg
|
||||
|
||||
pu erh:
|
||||
price: 2
|
||||
title: Pu Erh
|
||||
description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.
|
||||
image: ~/img/pos-sample/pu-erh.jpg
|
||||
|
||||
herbal tea:
|
||||
price: 1.8
|
||||
title: Herbal Tea
|
||||
description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!
|
||||
image: ~/img/pos-sample/herbal-tea.jpg
|
||||
custom: true
|
||||
|
||||
fruit tea:
|
||||
price: 1.5
|
||||
title: Fruit Tea
|
||||
description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!
|
||||
image: ~/img/pos-sample/fruit-tea.jpg
|
||||
inventory: 5
|
||||
custom: true
|
||||
";
|
||||
var parsedDefault = MigrationStartupTask.ParsePOSYML(testOriginalDefaultYmlTemplate);
|
||||
Assert.Equal(6, parsedDefault.Length);
|
||||
Assert.Equal( "Green Tea" ,parsedDefault[0].Title);
|
||||
Assert.Equal( "green tea" ,parsedDefault[0].Id);
|
||||
Assert.Equal( "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years." ,parsedDefault[0].Description);
|
||||
Assert.Null( parsedDefault[0].BuyButtonText);
|
||||
Assert.Equal( "~/img/pos-sample/green-tea.jpg" ,parsedDefault[0].Image);
|
||||
Assert.Equal( 1 ,parsedDefault[0].Price);
|
||||
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Fixed ,parsedDefault[0].PriceType);
|
||||
Assert.Null( parsedDefault[0].AdditionalData);
|
||||
Assert.Null( parsedDefault[0].PaymentMethods);
|
||||
|
||||
|
||||
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
|
||||
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
|
||||
Assert.Equal( "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!" ,parsedDefault[4].Description);
|
||||
Assert.Null( parsedDefault[4].BuyButtonText);
|
||||
Assert.Equal( "~/img/pos-sample/herbal-tea.jpg" ,parsedDefault[4].Image);
|
||||
Assert.Equal( 1.8m ,parsedDefault[4].Price);
|
||||
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Minimum ,parsedDefault[4].PriceType);
|
||||
Assert.Null( parsedDefault[4].AdditionalData);
|
||||
Assert.Null( parsedDefault[4].PaymentMethods);
|
||||
}
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePoSApp1()
|
||||
@ -53,6 +123,7 @@ donation:
|
||||
price: 1.02
|
||||
custom: true
|
||||
";
|
||||
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
var publicApps = user.GetController<UIPointOfSaleController>();
|
||||
|
@ -180,7 +180,7 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
Driver.FindElement(By.Id("StoreSelectorToggle")).Click();
|
||||
}
|
||||
Driver.WaitForElement(By.Id("StoreSelectorCreate")).Click();
|
||||
GoToUrl("/stores/create");
|
||||
var name = "Store" + RandomUtils.GetUInt64();
|
||||
TestLogs.LogInformation($"Created store {name}");
|
||||
Driver.WaitForElement(By.Id("Name")).SendKeys(name);
|
||||
@ -313,8 +313,6 @@ namespace BTCPayServer.Tests
|
||||
|
||||
var connectionString = connectionType switch
|
||||
{
|
||||
LightningConnectionType.Charge =>
|
||||
$"type=charge;server={Server.MerchantCharge.Client.Uri.AbsoluteUri};allowinsecure=true",
|
||||
LightningConnectionType.CLightning =>
|
||||
$"type=clightning;server={((CLightningClient)Server.MerchantLightningD).Address.AbsoluteUri}",
|
||||
LightningConnectionType.LndREST =>
|
||||
|
@ -129,16 +129,20 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains("There are no forms yet.", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("CreateForm")).Click();
|
||||
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 1");
|
||||
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
|
||||
var emailtemplate = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
|
||||
Assert.Contains("buyerEmail", emailtemplate);
|
||||
s.Driver.FindElement(By.Id("ApplyEmailTemplate")).Click();
|
||||
|
||||
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
|
||||
s.Driver.WaitForElement(By.Id("CodeTabPane"));
|
||||
|
||||
var config = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
|
||||
Assert.Contains("buyerEmail", config);
|
||||
|
||||
s.Driver.FindElement(By.Name("FormConfig")).Clear();
|
||||
s.Driver.FindElement(By.Name("FormConfig"))
|
||||
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest"));
|
||||
.SendKeys(config.Replace("Enter your email", "CustomFormInputTest"));
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
s.Driver.FindElement(By.Id("ViewForm")).Click();
|
||||
|
||||
|
||||
var formurl = s.Driver.Url;
|
||||
Assert.Contains("CustomFormInputTest", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
|
||||
@ -157,12 +161,16 @@ namespace BTCPayServer.Tests
|
||||
Assert.DoesNotContain("Custom Form 1", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("CreateForm")).Click();
|
||||
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 2");
|
||||
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
|
||||
s.Driver.FindElement(By.Id("ApplyEmailTemplate")).Click();
|
||||
|
||||
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
|
||||
s.Driver.WaitForElement(By.Id("CodeTabPane"));
|
||||
|
||||
s.Driver.SetCheckbox(By.Name("Public"), true);
|
||||
|
||||
s.Driver.FindElement(By.Name("FormConfig")).Clear();
|
||||
s.Driver.FindElement(By.Name("FormConfig"))
|
||||
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest2"));
|
||||
.SendKeys(config.Replace("Enter your email", "CustomFormInputTest2"));
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
s.Driver.FindElement(By.Id("ViewForm")).Click();
|
||||
formurl = s.Driver.Url;
|
||||
@ -600,7 +608,7 @@ namespace BTCPayServer.Tests
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
s.Driver.Navigate().Refresh();
|
||||
s.Driver.FindElement(By.Id("receipt-btn")).Click();
|
||||
s.Driver.FindElement(By.Id("ReceiptLink")).Click();
|
||||
});
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
@ -612,14 +620,13 @@ namespace BTCPayServer.Tests
|
||||
|
||||
await s.Server.PayTester.InvoiceRepository.MarkInvoiceStatus(i, InvoiceStatus.Settled);
|
||||
|
||||
TestUtils.Eventually(() => s.Driver.FindElement(By.Id("receipt-btn")).Click());
|
||||
TestUtils.Eventually(() => s.Driver.FindElement(By.Id("ReceiptLink")).Click());
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
s.Driver.Navigate().Refresh();
|
||||
Assert.DoesNotContain("invoice-unsettled", s.Driver.PageSource);
|
||||
Assert.DoesNotContain("invoice-processing", s.Driver.PageSource);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
@ -630,21 +637,24 @@ namespace BTCPayServer.Tests
|
||||
s.RegisterNewUser();
|
||||
s.GoToUrl("/");
|
||||
|
||||
// verify redirected to create store page
|
||||
Assert.EndsWith("/stores/create", s.Driver.Url);
|
||||
Assert.Contains("Create your first store", s.Driver.PageSource);
|
||||
Assert.Contains("To start accepting payments, set up a store.", s.Driver.PageSource);
|
||||
Assert.False(s.Driver.PageSource.Contains("id=\"StoreSelectorDropdown\""), "Store selector dropdown should not be present");
|
||||
Assert.True(s.Driver.PageSource.Contains("id=\"StoreSelectorCreate\""), "Store selector create button should be present");
|
||||
|
||||
// verify steps for store creation are displayed correctly
|
||||
s.Driver.FindElement(By.Id("SetupGuide-Store")).Click();
|
||||
Assert.Contains("/stores/create", s.Driver.Url);
|
||||
|
||||
(_, string storeId) = s.CreateNewStore();
|
||||
|
||||
// should redirect to store
|
||||
// should redirect to first store
|
||||
s.GoToUrl("/");
|
||||
|
||||
Assert.Contains($"/stores/{storeId}", s.Driver.Url);
|
||||
Assert.True(s.Driver.PageSource.Contains("id=\"StoreSelectorDropdown\""), "Store selector dropdown should be present");
|
||||
Assert.True(s.Driver.PageSource.Contains("id=\"SetupGuide\""), "Store setup guide should be present");
|
||||
|
||||
s.GoToUrl("/stores/create");
|
||||
Assert.Contains("Create a new store", s.Driver.PageSource);
|
||||
Assert.DoesNotContain("Create your first store", s.Driver.PageSource);
|
||||
Assert.DoesNotContain("To start accepting payments, set up a store.", s.Driver.PageSource);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
@ -718,8 +728,8 @@ namespace BTCPayServer.Tests
|
||||
Assert.DoesNotContain(invoiceId, s.Driver.PageSource);
|
||||
|
||||
// unarchive via list
|
||||
s.Driver.FindElement(By.Id("SearchOptionsToggle")).Click();
|
||||
s.Driver.FindElement(By.Id("SearchOptionsIncludeArchived")).Click();
|
||||
s.Driver.FindElement(By.Id("StatusOptionsToggle")).Click();
|
||||
s.Driver.FindElement(By.Id("StatusOptionsIncludeArchived")).Click();
|
||||
Assert.Contains(invoiceId, s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.CssSelector($".selector[value=\"{invoiceId}\"]")).Click();
|
||||
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
|
||||
@ -954,7 +964,7 @@ namespace BTCPayServer.Tests
|
||||
s.Driver.FindElement(By.Id("ToggleRawEditor")).Click();
|
||||
|
||||
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
|
||||
Assert.Contains("buyButtonText: Take my money", template);
|
||||
Assert.Contains("\"buyButtonText\":\"Take my money\"", template);
|
||||
|
||||
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||
@ -2437,10 +2447,11 @@ retry:
|
||||
_ = await request.SendChallenge(linkingKey, new HttpClient());
|
||||
TestUtils.Eventually(() => s.FindAlertMessage());
|
||||
|
||||
s.CreateNewStore(); // create a store to prevent redirect after login
|
||||
s.Logout();
|
||||
s.LogIn(user, "123456");
|
||||
var section = s.Driver.FindElement(By.Id("lnurlauth-section"));
|
||||
links = section.FindElements(By.CssSelector(".tab-content a")).Select(element => element.GetAttribute("href"));
|
||||
links = section.FindElements(By.CssSelector(".tab-content a")).Select(element => element.GetAttribute("href")).ToList();
|
||||
Assert.Equal(2, links.Count());
|
||||
prevEndpoint = null;
|
||||
foreach (string link in links)
|
||||
@ -2454,7 +2465,7 @@ retry:
|
||||
_ = await request.SendChallenge(linkingKey, new HttpClient());
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
Assert.Equal(s.Driver.Url, s.ServerUri.ToString());
|
||||
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -92,7 +92,7 @@ namespace BTCPayServer.Tests
|
||||
#endif
|
||||
public void ActivateLightning()
|
||||
{
|
||||
ActivateLightning(LightningConnectionType.Charge);
|
||||
ActivateLightning(LightningConnectionType.CLightning);
|
||||
}
|
||||
public void ActivateLightning(LightningConnectionType internalNode)
|
||||
{
|
||||
@ -109,14 +109,7 @@ namespace BTCPayServer.Tests
|
||||
string connectionString = null;
|
||||
if (connectionType is null)
|
||||
return LightningSupportedPaymentMethod.InternalNode;
|
||||
if (connectionType == LightningConnectionType.Charge)
|
||||
{
|
||||
if (isMerchant)
|
||||
connectionString = $"type=charge;server={MerchantCharge.Client.Uri.AbsoluteUri};allowinsecure=true";
|
||||
else
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
else if (connectionType == LightningConnectionType.CLightning)
|
||||
if (connectionType == LightningConnectionType.CLightning)
|
||||
{
|
||||
if (isMerchant)
|
||||
connectionString = "type=clightning;server=" +
|
||||
|
@ -277,7 +277,7 @@ namespace BTCPayServer.Tests
|
||||
|
||||
public bool IsAdmin { get; internal set; }
|
||||
|
||||
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType, bool isMerchant = true)
|
||||
public void RegisterLightningNode(string cryptoCode, LightningConnectionType? connectionType = null, bool isMerchant = true)
|
||||
{
|
||||
RegisterLightningNodeAsync(cryptoCode, connectionType, isMerchant).GetAwaiter().GetResult();
|
||||
}
|
||||
|
@ -358,6 +358,11 @@ retry:
|
||||
version = Regex.Match(actual, "Original file: /npm/dom-confetti@([0-9]+.[0-9]+.[0-9]+)/lib/main.js").Groups[1].Value;
|
||||
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}/lib/main.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
Assert.Equal(expected, actual);
|
||||
|
||||
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sortable", "sortable.min.js").Trim();
|
||||
version = Regex.Match(actual, "Sortable ([0-9]+.[0-9]+.[0-9]+) ").Groups[1].Value;
|
||||
expected = (await (await client.GetAsync($"https://unpkg.com/sortablejs@{version}/Sortable.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
string GetFileContent(params string[] path)
|
||||
|
@ -466,14 +466,6 @@ namespace BTCPayServer.Tests
|
||||
await ProcessLightningPayment(LightningConnectionType.CLightning);
|
||||
}
|
||||
|
||||
[Fact(Timeout = 60 * 2 * 1000)]
|
||||
[Trait("Integration", "Integration")]
|
||||
[Trait("Lightning", "Lightning")]
|
||||
public async Task CanSendLightningPaymentCharge()
|
||||
{
|
||||
await ProcessLightningPayment(LightningConnectionType.Charge);
|
||||
}
|
||||
|
||||
[Fact(Timeout = 60 * 2 * 1000)]
|
||||
[Trait("Integration", "Integration")]
|
||||
[Trait("Lightning", "Lightning")]
|
||||
@ -1634,7 +1626,7 @@ namespace BTCPayServer.Tests
|
||||
var user = tester.NewAccount();
|
||||
var cryptoCode = "BTC";
|
||||
user.GrantAccess(true);
|
||||
user.RegisterLightningNode(cryptoCode, LightningConnectionType.Charge);
|
||||
user.RegisterLightningNode(cryptoCode);
|
||||
user.SetLNUrl(cryptoCode, false);
|
||||
var vm = user.GetController<UIStoresController>().CheckoutAppearance().AssertViewModel<CheckoutAppearanceViewModel>();
|
||||
var criteria = Assert.Single(vm.PaymentMethodCriteria);
|
||||
@ -1654,7 +1646,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.Equal(PaymentTypes.LightningLike.ToString(), invoice.CryptoInfo[0].PaymentType);
|
||||
|
||||
// Activating LNUrl, we should still have only 1 payment criteria that can be set.
|
||||
user.RegisterLightningNode(cryptoCode, LightningConnectionType.Charge);
|
||||
user.RegisterLightningNode(cryptoCode);
|
||||
user.SetLNUrl(cryptoCode, true);
|
||||
vm = user.GetController<UIStoresController>().CheckoutAppearance().AssertViewModel<CheckoutAppearanceViewModel>();
|
||||
criteria = Assert.Single(vm.PaymentMethodCriteria);
|
||||
@ -2154,7 +2146,7 @@ namespace BTCPayServer.Tests
|
||||
txFee = localInvoice.BtcDue - invoice.BtcDue;
|
||||
Assert.Equal("paidPartial", localInvoice.ExceptionStatus.ToString());
|
||||
Assert.Equal(1, localInvoice.CryptoInfo[0].TxCount);
|
||||
Assert.NotEqual(localInvoice.BitcoinAddress, invoice.BitcoinAddress); //New address
|
||||
Assert.Equal(localInvoice.BitcoinAddress, invoice.BitcoinAddress); //Same address
|
||||
Assert.True(IsMapped(invoice, ctx));
|
||||
Assert.True(IsMapped(localInvoice, ctx));
|
||||
|
||||
|
@ -24,7 +24,6 @@ services:
|
||||
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
||||
TEST_MERCHANTLIGHTNINGD: "type=clightning;server=unix://etc/merchant_lightningd_datadir/lightning-rpc"
|
||||
TEST_CUSTOMERLIGHTNINGD: "type=clightning;server=unix://etc/customer_lightningd_datadir/lightning-rpc"
|
||||
TEST_MERCHANTCHARGE: "type=charge;server=http://lightning-charged:9112/;api-token=foiewnccewuify;allowinsecure=true"
|
||||
TEST_MERCHANTLND: "http://lnd:lnd@merchant_lnd:8080/"
|
||||
TESTS_INCONTAINER: "true"
|
||||
TESTS_SSHCONNECTION: "root@sshd:22"
|
||||
@ -56,7 +55,6 @@ services:
|
||||
- postgres
|
||||
- customer_lightningd
|
||||
- merchant_lightningd
|
||||
- lightning-charged
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
- sshd
|
||||
@ -75,7 +73,7 @@ services:
|
||||
- "sshd_datadir:/root/.ssh"
|
||||
|
||||
devlnd:
|
||||
image: btcpayserver/bitcoin:24.0
|
||||
image: btcpayserver/bitcoin:24.1-1
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_WALLETDIR: "/data/wallets"
|
||||
@ -89,14 +87,19 @@ services:
|
||||
- postgres
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:101.0
|
||||
extra_hosts:
|
||||
- "tests:172.23.0.18"
|
||||
expose:
|
||||
- "4444"
|
||||
extra_hosts:
|
||||
- "tests:172.18.0.18"
|
||||
networks:
|
||||
default:
|
||||
custom:
|
||||
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:2.3.58
|
||||
image: nicolasdorier/nbxplorer:2.3.63
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32838:32838"
|
||||
@ -132,7 +135,7 @@ services:
|
||||
|
||||
bitcoind:
|
||||
restart: unless-stopped
|
||||
image: btcpayserver/bitcoin:24.0
|
||||
image: btcpayserver/bitcoin:24.1-1
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_WALLETDIR: "/data/wallets"
|
||||
@ -160,7 +163,7 @@ services:
|
||||
- "bitcoin_datadir:/data"
|
||||
|
||||
customer_lightningd:
|
||||
image: btcpayserver/lightning:v23.02-1-dev
|
||||
image: btcpayserver/lightning:v23.05-dev
|
||||
stop_signal: SIGKILL
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@ -186,30 +189,8 @@ services:
|
||||
depends_on:
|
||||
- bitcoind
|
||||
|
||||
lightning-charged:
|
||||
image: shesek/lightning-charge:0.4.23-1-standalone
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NETWORK: regtest
|
||||
API_TOKEN: foiewnccewuify
|
||||
BITCOIND_RPCCONNECT: bitcoind
|
||||
LN_NET_PATH: /etc/lightning
|
||||
LN_NET: /etc/lightning
|
||||
volumes:
|
||||
- "bitcoin_datadir:/etc/bitcoin"
|
||||
- "lightning_charge_datadir:/data"
|
||||
- "merchant_lightningd_datadir:/etc/lightning"
|
||||
expose:
|
||||
- "9112" # Charge
|
||||
- "9735" # Lightning
|
||||
ports:
|
||||
- "54938:9112" # Charge
|
||||
depends_on:
|
||||
- bitcoind
|
||||
- merchant_lightningd
|
||||
|
||||
merchant_lightningd:
|
||||
image: btcpayserver/lightning:v23.02-1-dev
|
||||
image: btcpayserver/lightning:v23.05-dev
|
||||
stop_signal: SIGKILL
|
||||
environment:
|
||||
EXPOSE_TCP: "true"
|
||||
|
@ -22,7 +22,6 @@ services:
|
||||
TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none}
|
||||
TEST_MERCHANTLIGHTNINGD: "type=clightning;server=unix://etc/merchant_lightningd_datadir/lightning-rpc"
|
||||
TEST_CUSTOMERLIGHTNINGD: "type=clightning;server=unix://etc/customer_lightningd_datadir/lightning-rpc"
|
||||
TEST_MERCHANTCHARGE: "type=charge;server=http://lightning-charged:9112/;api-token=foiewnccewuify;allowinsecure=true"
|
||||
TEST_MERCHANTLND: "http://lnd:lnd@merchant_lnd:8080/"
|
||||
TESTS_INCONTAINER: "true"
|
||||
TESTS_SSHCONNECTION: "root@sshd:22"
|
||||
@ -54,7 +53,6 @@ services:
|
||||
- postgres
|
||||
- customer_lightningd
|
||||
- merchant_lightningd
|
||||
- lightning-charged
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
- sshd
|
||||
@ -72,28 +70,33 @@ services:
|
||||
- "sshd_datadir:/root/.ssh"
|
||||
|
||||
devlnd:
|
||||
image: btcpayserver/bitcoin:24.0
|
||||
image: btcpayserver/bitcoin:24.1-1
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_WALLETDIR: "/data/wallets"
|
||||
BITCOIN_EXTRA_ARGS: |
|
||||
deprecatedrpc=signrawtransaction
|
||||
connect=bitcoind:39388
|
||||
rpcallowip=0.0.0.0/0
|
||||
fallbackfee=0.0002
|
||||
rpcallowip=0.0.0.0/0
|
||||
depends_on:
|
||||
- nbxplorer
|
||||
- postgres
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:101.0
|
||||
extra_hosts:
|
||||
- "tests:172.18.0.18"
|
||||
- "tests:172.23.0.18"
|
||||
expose:
|
||||
- "4444"
|
||||
networks:
|
||||
default:
|
||||
custom:
|
||||
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:2.3.58
|
||||
image: nicolasdorier/nbxplorer:2.3.63
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32838:32838"
|
||||
@ -118,7 +121,7 @@ services:
|
||||
|
||||
bitcoind:
|
||||
restart: unless-stopped
|
||||
image: btcpayserver/bitcoin:24.0
|
||||
image: btcpayserver/bitcoin:24.1-1
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_WALLETDIR: "/data/wallets"
|
||||
@ -146,7 +149,7 @@ services:
|
||||
- "bitcoin_datadir:/data"
|
||||
|
||||
customer_lightningd:
|
||||
image: btcpayserver/lightning:v23.02-1-dev
|
||||
image: btcpayserver/lightning:v23.05-dev
|
||||
stop_signal: SIGKILL
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@ -172,30 +175,8 @@ services:
|
||||
depends_on:
|
||||
- bitcoind
|
||||
|
||||
lightning-charged:
|
||||
image: shesek/lightning-charge:0.4.23-1-standalone
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NETWORK: regtest
|
||||
API_TOKEN: foiewnccewuify
|
||||
BITCOIND_RPCCONNECT: bitcoind
|
||||
LN_NET_PATH: /etc/lightning
|
||||
LN_NET: /etc/lightning
|
||||
volumes:
|
||||
- "bitcoin_datadir:/etc/bitcoin"
|
||||
- "lightning_charge_datadir:/data"
|
||||
- "merchant_lightningd_datadir:/etc/lightning"
|
||||
expose:
|
||||
- "9112" # Charge
|
||||
- "9735" # Lightning
|
||||
ports:
|
||||
- "54938:9112" # Charge
|
||||
depends_on:
|
||||
- bitcoind
|
||||
- merchant_lightningd
|
||||
|
||||
merchant_lightningd:
|
||||
image: btcpayserver/lightning:v23.02-1-dev
|
||||
image: btcpayserver/lightning:v23.05-dev
|
||||
stop_signal: SIGKILL
|
||||
environment:
|
||||
EXPOSE_TCP: "true"
|
||||
|
@ -45,9 +45,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
|
||||
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.23" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.24" />
|
||||
<PackageReference Include="CsvHelper" Version="15.0.5" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Fido2" Version="2.0.2" />
|
||||
@ -75,7 +76,6 @@
|
||||
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.12.1" />
|
||||
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.12.1" />
|
||||
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.9" />
|
||||
</ItemGroup>
|
||||
@ -111,9 +111,6 @@
|
||||
<None Include="wwwroot\vendor\font-awesome\scss\_screen-reader.scss" />
|
||||
<None Include="wwwroot\vendor\font-awesome\scss\_stacked.scss" />
|
||||
<None Include="wwwroot\vendor\font-awesome\scss\_variables.scss" />
|
||||
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.compatibility.js" />
|
||||
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.js" />
|
||||
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.min.js" />
|
||||
<None Include="wwwroot\vendor\jquery\jquery.js" />
|
||||
<None Include="wwwroot\vendor\jquery\jquery.min.js" />
|
||||
</ItemGroup>
|
||||
|
@ -48,7 +48,7 @@
|
||||
<span class="app-item-point ct-point"></span>
|
||||
@entry.Title
|
||||
</span>
|
||||
<span class="app-item-value">
|
||||
<span class="app-item-value" data-sensitive>
|
||||
<span class="text-muted">@entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"),</span>
|
||||
@entry.TotalFormatted
|
||||
</span>
|
||||
|
@ -1,6 +1,5 @@
|
||||
@using BTCPayServer.Views.Server
|
||||
@using BTCPayServer.Views.Stores
|
||||
@using BTCPayServer.Views.Apps
|
||||
@using BTCPayServer.Views.Invoice
|
||||
@using BTCPayServer.Views.Manage
|
||||
@using BTCPayServer.Views.PaymentRequest
|
||||
@ -178,7 +177,7 @@
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item" permission="@Policies.CanModifyServerSettings">
|
||||
<a asp-area="" asp-controller="UIServer" asp-action="ListPlugins" class="nav-link @ViewData.IsActivePage(ServerNavPages.Plugins)" id="Nav-ManagePlugins">
|
||||
<vc:icon symbol="plugin"/>
|
||||
<vc:icon symbol="manage-plugins"/>
|
||||
<span>Manage Plugins</span>
|
||||
</a>
|
||||
</li>
|
||||
@ -239,7 +238,7 @@
|
||||
<span>Account</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu py-0 w-100" aria-labelledby="Nav-Account">
|
||||
<li class="p-3">
|
||||
<li class="p-3 border-bottom">
|
||||
<strong class="d-block text-truncate" style="max-width:195px">@User.Identity.Name</strong>
|
||||
@if (User.IsInRole(Roles.ServerAdmin))
|
||||
{
|
||||
@ -248,10 +247,19 @@
|
||||
</li>
|
||||
@if (!Theme.CustomTheme)
|
||||
{
|
||||
<li class="border-top py-1 px-3">
|
||||
<vc:theme-switch css-class="nav-link"/>
|
||||
<li class="py-1 px-3">
|
||||
<vc:theme-switch css-class="nav-link pb-0"/>
|
||||
</li>
|
||||
}
|
||||
<li class="py-1 px-3">
|
||||
<label class="d-flex align-items-center justify-content-between gap-3 nav-link">
|
||||
<span class="fw-semibold">Hide Sensitive Info</span>
|
||||
<input id="HideSensitiveInfo" name="HideSensitiveInfo" type="checkbox" class="btcpay-toggle" />
|
||||
</label>
|
||||
<script>
|
||||
document.getElementById('HideSensitiveInfo').checked = window.localStorage.getItem('btcpay-hide-sensitive-info') === 'true';
|
||||
</script>
|
||||
</li>
|
||||
<li class="border-top py-1 px-3">
|
||||
<a asp-area="" asp-controller="UIManage" asp-action="Index" class="nav-link @ViewData.IsActiveCategory(typeof(ManageNavPages))" id="Nav-ManageAccount">
|
||||
<span>Manage Account</span>
|
||||
|
@ -23,7 +23,7 @@
|
||||
@if (Model.Balance.OffchainBalance != null)
|
||||
{
|
||||
<div class="balance">
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.TotalOffchain">@Model.TotalOffchain</h3>
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.TotalOffchain" data-sensitive>@Model.TotalOffchain</h3>
|
||||
<span class="text-secondary fw-semibold text-nowrap">
|
||||
<span class="currency">@Model.CryptoCode</span> in channels
|
||||
</span>
|
||||
@ -32,7 +32,7 @@
|
||||
@if (Model.Balance.OffchainBalance.Opening != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Opening">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Opening" data-sensitive>
|
||||
@Model.Balance.OffchainBalance.Opening
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -43,7 +43,7 @@
|
||||
@if (Model.Balance.OffchainBalance.Local != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Local">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Local" data-sensitive>
|
||||
@Model.Balance.OffchainBalance.Local
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -54,7 +54,7 @@
|
||||
@if (Model.Balance.OffchainBalance.Remote != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Remote">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Remote" data-sensitive>
|
||||
@Model.Balance.OffchainBalance.Remote
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -65,7 +65,7 @@
|
||||
@if (Model.Balance.OffchainBalance.Closing != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Closing">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OffchainBalance.Closing" data-sensitive>
|
||||
@Model.Balance.OffchainBalance.Closing
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -79,7 +79,7 @@
|
||||
@if (Model.Balance.OnchainBalance != null)
|
||||
{
|
||||
<div class="balance">
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.TotalOnchain">@Model.TotalOnchain</h3>
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.TotalOnchain" data-sensitive>@Model.TotalOnchain</h3>
|
||||
<span class="text-secondary fw-semibold text-nowrap">
|
||||
<span class="currency">@Model.CryptoCode</span> on-chain
|
||||
</span>
|
||||
@ -87,7 +87,7 @@
|
||||
@if (Model.Balance.OnchainBalance.Confirmed != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Confirmed">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Confirmed" data-sensitive>
|
||||
@Model.Balance.OnchainBalance.Confirmed
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -98,7 +98,7 @@
|
||||
@if (Model.Balance.OnchainBalance.Unconfirmed != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Unconfirmed">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Unconfirmed" data-sensitive>
|
||||
@Model.Balance.OnchainBalance.Unconfirmed
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
@ -109,7 +109,7 @@
|
||||
@if (Model.Balance.OnchainBalance.Reserved != null)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Reserved">
|
||||
<span class="fw-semibold" data-balance="@Model.Balance.OnchainBalance.Reserved" data-sensitive>
|
||||
@Model.Balance.OnchainBalance.Reserved
|
||||
</span>
|
||||
<span class="text-secondary text-nowrap">
|
||||
|
@ -65,7 +65,9 @@
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</td>
|
||||
<td class="text-end">
|
||||
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
@ -72,11 +72,15 @@
|
||||
</td>
|
||||
@if (tx.Positive)
|
||||
{
|
||||
<td class="text-end text-success">@DisplayFormatter.Currency(tx.Balance, tx.Currency)</td>
|
||||
<td class="text-end text-success">
|
||||
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
|
||||
</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td class="text-end text-danger">@DisplayFormatter.Currency(tx.Balance, tx.Currency)</td>
|
||||
<td class="text-end text-danger">
|
||||
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
|
@ -37,10 +37,9 @@ else
|
||||
{
|
||||
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
}
|
||||
|
||||
<div id="StoreSelector">
|
||||
@if (Model.Options.Any())
|
||||
{
|
||||
@if (Model.Options.Any())
|
||||
{
|
||||
<div id="StoreSelector">
|
||||
<div id="StoreSelectorDropdown" class="dropdown only-for-js">
|
||||
<button id="StoreSelectorToggle" class="btn btn-secondary dropdown-toggle rounded-pill px-3 @(Model.CurrentStoreId == null ? "empty-state" : "")" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@if (!string.IsNullOrEmpty(Model.CurrentStoreLogoFileId))
|
||||
@ -72,9 +71,5 @@ else
|
||||
<li><a asp-controller="UIUserStores" asp-action="CreateStore" class="dropdown-item" id="StoreSelectorCreate">Create Store</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
else if (SignInManager.IsSignedIn(User))
|
||||
{
|
||||
<a asp-controller="UIUserStores" asp-action="CreateStore" class="btn btn-primary w-100 rounded-pill text-nowrap" id="StoreSelectorCreate">Create Store</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -18,7 +18,7 @@
|
||||
@if (Model.Balance != null)
|
||||
{
|
||||
<div class="balance">
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.Balance">@Model.Balance</h3>
|
||||
<h3 class="d-inline-block me-1" data-balance="@Model.Balance" data-sensitive>@Model.Balance</h3>
|
||||
<span class="text-secondary fw-semibold currency">@Model.CryptoCode</span>
|
||||
</div>
|
||||
}
|
||||
@ -92,8 +92,7 @@
|
||||
window.setTimeout(() => {
|
||||
const yLabels = [...document.querySelectorAll('.ct-label.ct-vertical.ct-start')];
|
||||
if (yLabels) {
|
||||
const factor = rate ? 6 : 8;
|
||||
const width = Math.max(...(yLabels.map(l => l.innerText.length * factor)));
|
||||
const width = Math.max(...(yLabels.map(l => l.innerText.length * 7.5)));
|
||||
const opts = Object.assign({}, renderOpts, {
|
||||
axisY: Object.assign({}, renderOpts.axisY, { offset: width })
|
||||
});
|
||||
|
@ -1,10 +1,29 @@
|
||||
@model BTCPayServer.Components.TruncateCenter.TruncateCenterViewModel
|
||||
<span class="truncate-center @Model.Classes">
|
||||
<span class="truncate-center-truncated" @(Model.Truncated != Model.Text ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>@Model.Truncated</span>
|
||||
<span class="truncate-center-text">@Model.Text</span>
|
||||
@{
|
||||
var classes = string.IsNullOrEmpty(Model.Classes) ? string.Empty : Model.Classes.Trim();
|
||||
@if (Model.Copy) classes += " truncate-center--copy";
|
||||
@if (Model.Elastic) classes += " truncate-center--elastic";
|
||||
}
|
||||
<span class="truncate-center @classes">
|
||||
@if (Model.IsVue)
|
||||
{
|
||||
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title=@Safe.Json(Model.Text)>
|
||||
<span class="truncate-center-start" v-text=@Safe.Json(Model.Text)></span>
|
||||
<span class="truncate-center-end" v-text=@Safe.Json($"{Model.Text}.slice(-{Model.Padding})")></span>
|
||||
</span>
|
||||
<span class="truncate-center-text" v-text=@Safe.Json(Model.Text)></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="truncate-center-truncated" @(!string.IsNullOrEmpty(Model.Start) ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>
|
||||
<span class="truncate-center-start">@(Model.Elastic ? Model.Text : $"{Model.Start}…")</span>
|
||||
<span class="truncate-center-end">@Model.End</span>
|
||||
</span>
|
||||
<span class="truncate-center-text">@Model.Text</span>
|
||||
}
|
||||
@if (Model.Copy)
|
||||
{
|
||||
<button type="button" class="btn btn-link p-0" data-clipboard="@Model.Text">
|
||||
<button type="button" class="btn btn-link p-0" @(Model.IsVue ? ":" : string.Empty)data-clipboard=@Safe.Json(Model.Text)>
|
||||
<vc:icon symbol="copy" />
|
||||
</button>
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ namespace BTCPayServer.Components.TruncateCenter;
|
||||
/// <returns>HTML with truncated string</returns>
|
||||
public class TruncateCenter : ViewComponent
|
||||
{
|
||||
public IViewComponentResult Invoke(string text, string link = null, string classes = null, int padding = 7, bool copy = true)
|
||||
public IViewComponentResult Invoke(string text, string link = null, string classes = null, int padding = 7, bool copy = true, bool elastic = false, bool isVue = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return new HtmlContentViewComponentResult(new StringHtmlContent(string.Empty));
|
||||
@ -23,11 +23,17 @@ public class TruncateCenter : ViewComponent
|
||||
{
|
||||
Classes = classes,
|
||||
Padding = padding,
|
||||
Elastic = elastic,
|
||||
IsVue = isVue,
|
||||
Copy = copy,
|
||||
Text = text,
|
||||
Link = link,
|
||||
Truncated = text.Length > 2 * padding ? $"{text[..padding]}…{text[^padding..]}" : text
|
||||
Link = link
|
||||
};
|
||||
if (!isVue && text.Length > 2 * padding)
|
||||
{
|
||||
vm.Start = text[..padding];
|
||||
vm.End = text[^padding..];
|
||||
}
|
||||
return View(vm);
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,13 @@ namespace BTCPayServer.Components.TruncateCenter
|
||||
public class TruncateCenterViewModel
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public string Truncated { get; set; }
|
||||
public string Start { get; set; }
|
||||
public string End { get; set; }
|
||||
public string Classes { get; set; }
|
||||
public string Link { get; set; }
|
||||
public int Padding { get; set; }
|
||||
public bool Copy { get; set; }
|
||||
public bool Elastic { get; set; }
|
||||
public bool IsVue { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<div class="d-sm-flex align-items-center justify-content-between">
|
||||
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" class="unobtrusive-link">
|
||||
<h2 class="mb-1">@Model.Label</h2>
|
||||
<div class="text-muted fw-semibold">
|
||||
<div class="text-muted fw-semibold" data-sensitive>
|
||||
@Model.Balance @Model.Network.CryptoCode
|
||||
</div>
|
||||
</a>
|
||||
|
@ -245,7 +245,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
EmbeddedCSS = request.EmbeddedCSS?.Trim(),
|
||||
NotificationUrl = request.NotificationUrl?.Trim(),
|
||||
Tagline = request.Tagline?.Trim(),
|
||||
PerksTemplate = request.PerksTemplate is not null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate.Trim(), request.TargetCurrency!)) : null,
|
||||
PerksTemplate = request.PerksTemplate is not null ? AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate.Trim())) : null,
|
||||
// If Disqus shortname is not null or empty we assume that Disqus should be enabled
|
||||
DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()),
|
||||
DisqusShortname = request.DisqusShortname?.Trim(),
|
||||
@ -272,7 +272,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
ShowDiscount = request.ShowDiscount,
|
||||
EnableTips = request.EnableTips,
|
||||
Currency = request.Currency,
|
||||
Template = request.Template != null ? _appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency)) : null,
|
||||
Template = request.Template != null ? AppService.SerializeTemplate(AppService.Parse(request.Template)) : null,
|
||||
ButtonText = request.FixedAmountPayButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF,
|
||||
CustomButtonText = request.CustomAmountPayButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF,
|
||||
CustomTipText = request.TipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF,
|
||||
@ -331,7 +331,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
Currency = settings.Currency,
|
||||
Items = JsonConvert.DeserializeObject(
|
||||
JsonConvert.SerializeObject(
|
||||
_appService.Parse(settings.Template, settings.Currency),
|
||||
AppService.Parse(settings.Template),
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
|
||||
@ -363,8 +363,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
try
|
||||
{
|
||||
// Just checking if we can serialize, we don't care about the currency
|
||||
_appService.SerializeTemplate(_appService.Parse(request.Template, "USD"));
|
||||
// Just checking if we can serialize
|
||||
AppService.SerializeTemplate(AppService.Parse(request.Template));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@ -406,7 +406,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
Tagline = settings.Tagline,
|
||||
Perks = JsonConvert.DeserializeObject(
|
||||
JsonConvert.SerializeObject(
|
||||
_appService.Parse(settings.PerksTemplate, settings.TargetCurrency),
|
||||
AppService.Parse(settings.PerksTemplate),
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
|
||||
@ -453,8 +453,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
|
||||
try
|
||||
{
|
||||
// Just checking if we can serialize, we don't care about the currency
|
||||
_appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, "USD"));
|
||||
// Just checking if we can serialize
|
||||
AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@ -183,7 +183,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Amount), "The amount should be 0 or more.");
|
||||
}
|
||||
request.Checkout = request.Checkout ?? new CreateInvoiceRequest.CheckoutOptions();
|
||||
request.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
|
||||
if (request.Checkout.PaymentMethods?.Any() is true)
|
||||
{
|
||||
for (int i = 0; i < request.Checkout.PaymentMethods.Length; i++)
|
||||
@ -226,7 +226,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return this.CreateValidationError(ModelState);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(request, store,
|
||||
@ -383,14 +383,15 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
}
|
||||
if (invoicePaymentMethod is null)
|
||||
{
|
||||
this.ModelState.AddModelError(nameof(request.PaymentMethod), "Please select one of the payment methods which were available for the original invoice");
|
||||
ModelState.AddModelError(nameof(request.PaymentMethod), "Please select one of the payment methods which were available for the original invoice");
|
||||
}
|
||||
if (request.RefundVariant is null)
|
||||
this.ModelState.AddModelError(nameof(request.RefundVariant), "`refundVariant` is mandatory");
|
||||
ModelState.AddModelError(nameof(request.RefundVariant), "`refundVariant` is mandatory");
|
||||
if (!ModelState.IsValid || invoicePaymentMethod is null || paymentMethodId is null)
|
||||
return this.CreateValidationError(ModelState);
|
||||
|
||||
var cryptoPaid = invoicePaymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC);
|
||||
var accounting = invoicePaymentMethod.Calculate();
|
||||
var cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
|
||||
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
|
||||
var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
|
||||
var rateResult = await _rateProvider.FetchRate(
|
||||
@ -398,8 +399,10 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
store.GetStoreBlob().GetRateRules(_networkProvider),
|
||||
cancellationToken
|
||||
);
|
||||
var cryptoCode = invoicePaymentMethod.GetId().CryptoCode;
|
||||
var paymentMethodDivisibility = _currencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8;
|
||||
var createPullPayment = new HostedServices.CreatePullPayment()
|
||||
var paidAmount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
|
||||
var createPullPayment = new CreatePullPayment
|
||||
{
|
||||
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
|
||||
Name = request.Name ?? $"Refund {invoice.Id}",
|
||||
@ -411,37 +414,61 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
if (request.RefundVariant != RefundVariant.Custom)
|
||||
{
|
||||
if (request.CustomAmount is not null)
|
||||
this.ModelState.AddModelError(nameof(request.CustomAmount), "CustomAmount should only be set if the refundVariant is Custom");
|
||||
ModelState.AddModelError(nameof(request.CustomAmount), "CustomAmount should only be set if the refundVariant is Custom");
|
||||
if (request.CustomCurrency is not null)
|
||||
this.ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom");
|
||||
if (!ModelState.IsValid)
|
||||
return this.CreateValidationError(ModelState);
|
||||
ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom");
|
||||
}
|
||||
if (request.SubtractPercentage is < 0 or > 100)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.SubtractPercentage), "Percentage must be a numeric value between 0 and 100");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
var appliedDivisibility = paymentMethodDivisibility;
|
||||
switch (request.RefundVariant)
|
||||
{
|
||||
case RefundVariant.RateThen:
|
||||
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
|
||||
createPullPayment.Amount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
|
||||
createPullPayment.Currency = cryptoCode;
|
||||
createPullPayment.Amount = paidAmount;
|
||||
createPullPayment.AutoApproveClaims = true;
|
||||
break;
|
||||
|
||||
case RefundVariant.CurrentRate:
|
||||
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
|
||||
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
||||
createPullPayment.Currency = cryptoCode;
|
||||
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, appliedDivisibility);
|
||||
createPullPayment.AutoApproveClaims = true;
|
||||
break;
|
||||
|
||||
case RefundVariant.Fiat:
|
||||
appliedDivisibility = cdCurrency.Divisibility;
|
||||
createPullPayment.Currency = invoice.Currency;
|
||||
createPullPayment.Amount = paidCurrency;
|
||||
createPullPayment.AutoApproveClaims = false;
|
||||
break;
|
||||
|
||||
case RefundVariant.OverpaidAmount:
|
||||
if (invoice.ExceptionStatus != InvoiceExceptionStatus.PaidOver)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.RefundVariant), "Invoice is not overpaid");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
var dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
|
||||
createPullPayment.Currency = cryptoCode;
|
||||
createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility);
|
||||
createPullPayment.AutoApproveClaims = true;
|
||||
break;
|
||||
|
||||
case RefundVariant.Custom:
|
||||
if (request.CustomAmount is null || (request.CustomAmount is decimal v && v <= 0))
|
||||
{
|
||||
this.ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0");
|
||||
ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0");
|
||||
}
|
||||
|
||||
if (
|
||||
@ -472,6 +499,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
ModelState.AddModelError(nameof(request.RefundVariant), "Please select a valid refund option");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
// reduce by percentage
|
||||
if (request.SubtractPercentage is > 0 and <= 100)
|
||||
{
|
||||
var reduceByAmount = createPullPayment.Amount * (request.SubtractPercentage / 100);
|
||||
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility);
|
||||
}
|
||||
|
||||
var ppId = await _pullPaymentService.CreatePullPayment(createPullPayment);
|
||||
|
||||
|
@ -133,7 +133,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
"A valid node info was not provided to open a channel with");
|
||||
}
|
||||
|
||||
if (request.ChannelAmount == null)
|
||||
if (request?.ChannelAmount is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.ChannelAmount), "ChannelAmount is missing");
|
||||
}
|
||||
@ -142,7 +142,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
ModelState.AddModelError(nameof(request.ChannelAmount), "ChannelAmount must be more than 0");
|
||||
}
|
||||
|
||||
if (request.FeeRate == null)
|
||||
if (request?.FeeRate is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.FeeRate), "FeeRate is missing");
|
||||
}
|
||||
|
@ -352,7 +352,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> CreatePayoutThroughStore(string storeId, CreatePayoutThroughStoreRequest request)
|
||||
{
|
||||
if (request.Approved is true)
|
||||
if (request?.Approved is true)
|
||||
{
|
||||
if (!(await _authorizationService.AuthorizeAsync(User, null,
|
||||
new PolicyRequirement(Policies.CanCreatePullPayments))).Succeeded)
|
||||
|
@ -585,6 +585,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
await _delayedTransactionBroadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0),
|
||||
transaction, network);
|
||||
_payjoinClient.MinimumFeeRate = minRelayFee;
|
||||
var payjoinPSBT = await _payjoinClient.RequestPayjoin(
|
||||
new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork),
|
||||
new PayjoinWallet(derivationScheme),
|
||||
|
@ -115,11 +115,12 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
internal static Client.Models.StoreData FromModel(Data.StoreData data)
|
||||
{
|
||||
var storeBlob = data.GetStoreBlob();
|
||||
return new Client.Models.StoreData()
|
||||
return new Client.Models.StoreData
|
||||
{
|
||||
Id = data.Id,
|
||||
Name = data.StoreName,
|
||||
Website = data.StoreWebsite,
|
||||
SupportUrl = storeBlob.StoreSupportUrl,
|
||||
SpeedPolicy = data.SpeedPolicy,
|
||||
DefaultPaymentMethod = data.GetDefaultPaymentId()?.ToStringNormalized(),
|
||||
//blob
|
||||
@ -163,7 +164,6 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
var blob = model.GetStoreBlob();
|
||||
model.StoreName = restModel.Name;
|
||||
model.StoreName = restModel.Name;
|
||||
model.StoreWebsite = restModel.Website;
|
||||
model.SpeedPolicy = restModel.SpeedPolicy;
|
||||
model.SetDefaultPaymentId(defaultPaymentMethod);
|
||||
@ -186,6 +186,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
blob.ShowRecommendedFee = restModel.ShowRecommendedFee;
|
||||
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget;
|
||||
blob.DefaultLang = restModel.DefaultLang;
|
||||
blob.StoreSupportUrl = restModel.SupportUrl;
|
||||
blob.MonitoringExpiration = restModel.MonitoringExpiration;
|
||||
blob.InvoiceExpiration = restModel.InvoiceExpiration;
|
||||
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer;
|
||||
@ -238,7 +239,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
ModelState.AddModelError(nameof(request.DisplayExpirationTimer), "DisplayExpirationTimer can only be between 1 and 34560 mins");
|
||||
if (request.MonitoringExpiration < TimeSpan.FromMinutes(10) && request.MonitoringExpiration > TimeSpan.FromMinutes(60 * 24 * 24))
|
||||
ModelState.AddModelError(nameof(request.MonitoringExpiration), "MonitoringExpiration can only be between 10 and 34560 mins");
|
||||
if (request.PaymentTolerance < 0 && request.PaymentTolerance > 100)
|
||||
if (request.PaymentTolerance < 0 || request.PaymentTolerance > 100)
|
||||
ModelState.AddModelError(nameof(request.PaymentTolerance), "PaymentTolerance can only be between 0 and 100 percent");
|
||||
|
||||
if (request.PaymentMethodCriteria?.Any() is true)
|
||||
|
@ -97,18 +97,9 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
var stores = await _storeRepository.GetStoresByUserId(userId);
|
||||
if (stores.Any())
|
||||
{
|
||||
// redirect to first store
|
||||
return RedirectToStore(stores.First());
|
||||
}
|
||||
|
||||
var vm = new HomeViewModel
|
||||
{
|
||||
HasStore = stores.Any()
|
||||
};
|
||||
|
||||
return View("Home", vm);
|
||||
return stores.Any()
|
||||
? RedirectToStore(stores.First())
|
||||
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
|
||||
}
|
||||
|
||||
return Challenge();
|
||||
|
@ -347,23 +347,39 @@ namespace BTCPayServer.Controllers
|
||||
RateRules rules;
|
||||
RateResult rateResult;
|
||||
CreatePullPayment createPullPayment;
|
||||
PaymentMethodAccounting accounting;
|
||||
var pms = invoice.GetPaymentMethods();
|
||||
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
|
||||
var appliedDivisibility = paymentMethodDivisibility;
|
||||
decimal dueAmount = default;
|
||||
decimal paidAmount = default;
|
||||
decimal cryptoPaid = default;
|
||||
|
||||
//TODO: Make this clean
|
||||
if (paymentMethod is null && paymentMethodId.PaymentType == LightningPaymentType.Instance)
|
||||
{
|
||||
paymentMethod = pms[new PaymentMethodId(paymentMethodId.CryptoCode, PaymentTypes.LNURLPay)];
|
||||
}
|
||||
|
||||
if (paymentMethod != null)
|
||||
{
|
||||
accounting = paymentMethod.Calculate();
|
||||
cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
|
||||
dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
|
||||
paidAmount = cryptoPaid.RoundToSignificant(appliedDivisibility);
|
||||
}
|
||||
|
||||
var isPaidOver = invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver;
|
||||
decimal? overpaidAmount = isPaidOver ? Math.Round(paidAmount - dueAmount, appliedDivisibility) : null;
|
||||
|
||||
switch (model.RefundStep)
|
||||
{
|
||||
case RefundSteps.SelectPaymentMethod:
|
||||
model.RefundStep = RefundSteps.SelectRate;
|
||||
model.Title = "How much to refund?";
|
||||
var pms = invoice.GetPaymentMethods();
|
||||
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
|
||||
|
||||
//TODO: Make this clean
|
||||
if (paymentMethod is null && paymentMethodId.PaymentType == LightningPaymentType.Instance)
|
||||
if (paymentMethod != null && cryptoPaid != default)
|
||||
{
|
||||
paymentMethod = pms[new PaymentMethodId(paymentMethodId.CryptoCode, PaymentTypes.LNURLPay)];
|
||||
}
|
||||
|
||||
if (paymentMethod != null)
|
||||
{
|
||||
var cryptoPaid = paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC);
|
||||
var paidCurrency = Math.Round(cryptoPaid * paymentMethod.Rate, cdCurrency.Divisibility);
|
||||
model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
|
||||
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
|
||||
@ -383,8 +399,15 @@ namespace BTCPayServer.Controllers
|
||||
model.CurrentRateText = _displayFormatter.Currency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
|
||||
model.FiatAmount = paidCurrency;
|
||||
}
|
||||
model.CryptoCode = paymentMethodId.CryptoCode;
|
||||
model.CryptoDivisibility = paymentMethodDivisibility;
|
||||
model.InvoiceDivisibility = cdCurrency.Divisibility;
|
||||
model.InvoiceCurrency = invoice.Currency;
|
||||
model.CustomAmount = model.FiatAmount;
|
||||
model.CustomCurrency = invoice.Currency;
|
||||
model.SubtractPercentage = 0;
|
||||
model.OverpaidAmount = overpaidAmount;
|
||||
model.OverpaidAmountText = overpaidAmount != null ? _displayFormatter.Currency(overpaidAmount.Value, paymentMethodId.CryptoCode) : null;
|
||||
model.FiatText = _displayFormatter.Currency(model.FiatAmount, invoice.Currency);
|
||||
return View("_RefundModal", model);
|
||||
|
||||
@ -399,6 +422,15 @@ namespace BTCPayServer.Controllers
|
||||
var authorizedForAutoApprove = (await
|
||||
_authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments))
|
||||
.Succeeded;
|
||||
if (model.SubtractPercentage is < 0 or > 100)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SubtractPercentage), "Percentage must be a numeric value between 0 and 100");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View("_RefundModal", model);
|
||||
}
|
||||
|
||||
switch (model.SelectedRefundOption)
|
||||
{
|
||||
case "RateThen":
|
||||
@ -414,27 +446,47 @@ namespace BTCPayServer.Controllers
|
||||
break;
|
||||
|
||||
case "Fiat":
|
||||
appliedDivisibility = cdCurrency.Divisibility;
|
||||
createPullPayment.Currency = invoice.Currency;
|
||||
createPullPayment.Amount = model.FiatAmount;
|
||||
createPullPayment.AutoApproveClaims = false;
|
||||
break;
|
||||
|
||||
case "OverpaidAmount":
|
||||
model.Title = "How much to refund?";
|
||||
model.RefundStep = RefundSteps.SelectRate;
|
||||
|
||||
if (isPaidOver)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Invoice is not overpaid");
|
||||
}
|
||||
if (overpaidAmount == null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Overpaid amount cannot be calculated");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
createPullPayment.Currency = paymentMethodId.CryptoCode;
|
||||
createPullPayment.Amount = overpaidAmount!.Value;
|
||||
createPullPayment.AutoApproveClaims = true;
|
||||
break;
|
||||
|
||||
case "Custom":
|
||||
model.Title = "How much to refund?";
|
||||
|
||||
model.RefundStep = RefundSteps.SelectRate;
|
||||
|
||||
if (model.CustomAmount <= 0)
|
||||
{
|
||||
model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(model.CustomCurrency) ||
|
||||
_CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View("_RefundModal", model);
|
||||
@ -468,6 +520,13 @@ namespace BTCPayServer.Controllers
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
// reduce by percentage
|
||||
if (model.SubtractPercentage is > 0 and <= 100)
|
||||
{
|
||||
var reduceByAmount = createPullPayment.Amount * (model.SubtractPercentage / 100);
|
||||
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility);
|
||||
}
|
||||
|
||||
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
@ -795,16 +854,23 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
var isAltcoinsBuild = false;
|
||||
#if ALTCOINS
|
||||
isAltcoinsBuild = true;
|
||||
isAltcoinsBuild = true;
|
||||
#endif
|
||||
|
||||
var orderId = invoice.Metadata.OrderId;
|
||||
var supportUrl = !string.IsNullOrEmpty(storeBlob.StoreSupportUrl)
|
||||
? storeBlob.StoreSupportUrl
|
||||
.Replace("{OrderId}", string.IsNullOrEmpty(orderId) ? string.Empty : Uri.EscapeDataString(orderId))
|
||||
.Replace("{InvoiceId}", Uri.EscapeDataString(invoice.Id))
|
||||
: null;
|
||||
|
||||
var model = new PaymentModel
|
||||
{
|
||||
Activated = paymentMethodDetails.Activated,
|
||||
CryptoCode = network.CryptoCode,
|
||||
RootPath = Request.PathBase.Value.WithTrailingSlash(),
|
||||
OrderId = invoice.Metadata.OrderId,
|
||||
InvoiceId = invoice.Id,
|
||||
OrderId = orderId,
|
||||
InvoiceId = invoiceId,
|
||||
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
|
||||
ShowPayInWalletButton = storeBlob.ShowPayInWalletButton,
|
||||
ShowStoreHeader = storeBlob.ShowStoreHeader,
|
||||
@ -836,6 +902,7 @@ namespace BTCPayServer.Controllers
|
||||
ReceiptLink = receiptUrl,
|
||||
RedirectAutomatically = invoice.RedirectAutomatically,
|
||||
StoreName = store.StoreName,
|
||||
StoreSupportUrl = supportUrl,
|
||||
TxCount = accounting.TxRequired,
|
||||
TxCountForFee = storeBlob.NetworkFeeMode switch
|
||||
{
|
||||
@ -1006,34 +1073,44 @@ namespace BTCPayServer.Controllers
|
||||
public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
|
||||
{
|
||||
model = this.ParseListQuery(model ?? new InvoicesModel());
|
||||
var fs = new SearchString(model.SearchTerm);
|
||||
var timezoneOffset = model.TimezoneOffset ?? 0;
|
||||
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
|
||||
var fs = new SearchString(searchTerm, timezoneOffset);
|
||||
string? storeId = model.StoreId;
|
||||
var storeIds = new HashSet<string>();
|
||||
if (fs.GetFilterArray("storeid") is string[] l)
|
||||
if (storeId is not null)
|
||||
{
|
||||
storeIds.Add(storeId);
|
||||
}
|
||||
if (fs.GetFilterArray("storeid") is { } l)
|
||||
{
|
||||
foreach (var i in l)
|
||||
storeIds.Add(i);
|
||||
}
|
||||
if (storeId is not null)
|
||||
{
|
||||
storeIds.Add(storeId);
|
||||
model.StoreId = storeId;
|
||||
}
|
||||
model.StoreIds = storeIds.ToArray();
|
||||
|
||||
InvoiceQuery invoiceQuery = GetInvoiceQuery(model.SearchTerm, model.TimezoneOffset ?? 0);
|
||||
invoiceQuery.StoreId = model.StoreIds;
|
||||
model.Search = fs;
|
||||
model.SearchText = fs.TextSearch;
|
||||
|
||||
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
|
||||
invoiceQuery.StoreId = storeIds.ToArray();
|
||||
invoiceQuery.Take = model.Count;
|
||||
invoiceQuery.Skip = model.Skip;
|
||||
invoiceQuery.IncludeRefunds = true;
|
||||
var list = await _InvoiceRepository.GetInvoices(invoiceQuery);
|
||||
|
||||
model.IncludeArchived = invoiceQuery.IncludeArchived;
|
||||
// Apps
|
||||
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
|
||||
model.Apps = apps.Select(a => new InvoiceAppModel
|
||||
{
|
||||
Id = a.Id,
|
||||
AppName = a.AppName,
|
||||
AppType = a.AppType,
|
||||
AppOrderId = AppService.GetAppOrderId(a.AppType, a.Id)
|
||||
}).ToList();
|
||||
|
||||
foreach (var invoice in list)
|
||||
{
|
||||
var state = invoice.GetInvoiceState();
|
||||
model.Invoices.Add(new InvoiceModel()
|
||||
model.Invoices.Add(new InvoiceModel
|
||||
{
|
||||
Status = state,
|
||||
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
|
||||
@ -1052,10 +1129,9 @@ namespace BTCPayServer.Controllers
|
||||
return View(model);
|
||||
}
|
||||
|
||||
private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffset = 0)
|
||||
private InvoiceQuery GetInvoiceQuery(SearchString fs, int timezoneOffset = 0)
|
||||
{
|
||||
var fs = new SearchString(searchTerm);
|
||||
var invoiceQuery = new InvoiceQuery()
|
||||
return new InvoiceQuery
|
||||
{
|
||||
TextSearch = fs.TextSearch,
|
||||
UserId = GetUserId(),
|
||||
@ -1069,7 +1145,6 @@ namespace BTCPayServer.Controllers
|
||||
StartDate = fs.GetFilterDate("startdate", timezoneOffset),
|
||||
EndDate = fs.GetFilterDate("enddate", timezoneOffset)
|
||||
};
|
||||
return invoiceQuery;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -1080,17 +1155,17 @@ namespace BTCPayServer.Controllers
|
||||
var model = new InvoiceExport(_CurrencyNameTable);
|
||||
var fs = new SearchString(searchTerm);
|
||||
var storeIds = new HashSet<string>();
|
||||
if (fs.GetFilterArray("storeid") is string[] l)
|
||||
{
|
||||
foreach (var i in l)
|
||||
storeIds.Add(i);
|
||||
}
|
||||
if (storeId is not null)
|
||||
{
|
||||
storeIds.Add(storeId);
|
||||
}
|
||||
if (fs.GetFilterArray("storeid") is { } l)
|
||||
{
|
||||
foreach (var i in l)
|
||||
storeIds.Add(i);
|
||||
}
|
||||
|
||||
InvoiceQuery invoiceQuery = GetInvoiceQuery(searchTerm, timezoneOffset);
|
||||
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
|
||||
invoiceQuery.StoreId = storeIds.ToArray();
|
||||
invoiceQuery.Skip = 0;
|
||||
invoiceQuery.Take = int.MaxValue;
|
||||
@ -1301,10 +1376,15 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
case JTokenType.Array:
|
||||
var items = item.Value.AsEnumerable().ToList();
|
||||
var arrayResult = new List<object>();
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
result.TryAdd($"{item.Key}[{i}]", ParsePosData(items[i]));
|
||||
arrayResult.Add(items[i] is JObject
|
||||
? ParsePosData(items[i])
|
||||
: items[i].ToString());
|
||||
}
|
||||
|
||||
result.TryAdd(item.Key, arrayResult);
|
||||
|
||||
break;
|
||||
case JTokenType.Object:
|
||||
|
@ -58,6 +58,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly InvoiceActivator _invoiceActivator;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly AppService _appService;
|
||||
|
||||
public WebhookSender WebhookNotificationManager { get; }
|
||||
|
||||
@ -81,6 +82,7 @@ namespace BTCPayServer.Controllers
|
||||
UIWalletsController walletsController,
|
||||
InvoiceActivator invoiceActivator,
|
||||
LinkGenerator linkGenerator,
|
||||
AppService appService,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
_displayFormatter = displayFormatter;
|
||||
@ -102,6 +104,7 @@ namespace BTCPayServer.Controllers
|
||||
_invoiceActivator = invoiceActivator;
|
||||
_linkGenerator = linkGenerator;
|
||||
_authorizationService = authorizationService;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
|
||||
@ -154,6 +157,7 @@ namespace BTCPayServer.Controllers
|
||||
entity.Type = InvoiceType.TopUp;
|
||||
}
|
||||
|
||||
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
|
||||
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
|
||||
entity.RedirectAutomatically =
|
||||
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
|
||||
@ -245,7 +249,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
|
||||
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
|
||||
entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod;
|
||||
entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod ?? store.GetDefaultPaymentId()?.ToStringNormalized() ?? new PaymentMethodId(_NetworkProvider.DefaultNetwork.CryptoCode, PaymentTypes.BTCLike).ToStringNormalized();
|
||||
entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically;
|
||||
entity.CheckoutType = invoice.Checkout.CheckoutType;
|
||||
entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail;
|
||||
|
@ -257,12 +257,12 @@ namespace BTCPayServer
|
||||
case CrowdfundAppType.AppType:
|
||||
var cfS = app.GetSettings<CrowdfundSettings>();
|
||||
currencyCode = cfS.TargetCurrency;
|
||||
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
|
||||
items = AppService.Parse(cfS.PerksTemplate);
|
||||
break;
|
||||
case PointOfSaleAppType.AppType:
|
||||
posS = app.GetSettings<PointOfSaleSettings>();
|
||||
currencyCode = posS.Currency;
|
||||
items = _appService.Parse(posS.Template, posS.Currency);
|
||||
items = AppService.Parse(posS.Template);
|
||||
break;
|
||||
default:
|
||||
//TODO: Allow other apps to define lnurl support
|
||||
@ -361,11 +361,6 @@ namespace BTCPayServer
|
||||
|
||||
public ConcurrentDictionary<string, LightningAddressItem> Items { get; } = new();
|
||||
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; } = new();
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("~/.well-known/lnurlp/{username}")]
|
||||
|
@ -78,7 +78,7 @@ namespace BTCPayServer.Controllers
|
||||
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
|
||||
|
||||
var store = GetCurrentStore();
|
||||
var includeArchived = new SearchString(model.SearchTerm).GetFilterBool("includearchived") == true;
|
||||
var includeArchived = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0).GetFilterBool("includearchived") == true;
|
||||
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
|
||||
{
|
||||
UserId = GetUserId(),
|
||||
|
@ -611,6 +611,7 @@ namespace BTCPayServer.Controllers
|
||||
Id = store.Id,
|
||||
StoreName = store.StoreName,
|
||||
StoreWebsite = store.StoreWebsite,
|
||||
StoreSupportUrl = storeBlob.StoreSupportUrl,
|
||||
LogoFileId = storeBlob.LogoFileId,
|
||||
CssFileId = storeBlob.CssFileId,
|
||||
BrandColor = storeBlob.BrandColor,
|
||||
@ -646,6 +647,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
var blob = CurrentStore.GetStoreBlob();
|
||||
blob.StoreSupportUrl = model.StoreSupportUrl;
|
||||
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
|
||||
blob.NetworkFeeMode = model.NetworkFeeMode;
|
||||
blob.PaymentTolerance = model.PaymentTolerance;
|
||||
@ -887,8 +889,11 @@ namespace BTCPayServer.Controllers
|
||||
var userId = GetUserId();
|
||||
if (userId == null)
|
||||
return Challenge(AuthenticationSchemes.Cookie);
|
||||
storeId = model.StoreId;
|
||||
var store = CurrentStore ?? await _Repo.FindStore(storeId, userId);
|
||||
var store = model.StoreId switch
|
||||
{
|
||||
null => CurrentStore,
|
||||
string id => await _Repo.FindStore(storeId, userId)
|
||||
};
|
||||
if (store == null)
|
||||
return Challenge(AuthenticationSchemes.Cookie);
|
||||
var tokenRequest = new TokenRequest()
|
||||
@ -906,7 +911,7 @@ namespace BTCPayServer.Controllers
|
||||
Id = tokenRequest.PairingCode,
|
||||
Label = model.Label,
|
||||
});
|
||||
await _TokenRepository.PairWithStoreAsync(tokenRequest.PairingCode, storeId);
|
||||
await _TokenRepository.PairWithStoreAsync(tokenRequest.PairingCode, store.Id);
|
||||
pairingCode = tokenRequest.PairingCode;
|
||||
}
|
||||
else
|
||||
|
@ -39,10 +39,12 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpGet("create")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
|
||||
public IActionResult CreateStore()
|
||||
public async Task<IActionResult> CreateStore()
|
||||
{
|
||||
var stores = await _repo.GetStoresByUserId(GetUserId());
|
||||
var vm = new CreateStoreViewModel
|
||||
{
|
||||
IsFirstStore = !stores.Any(),
|
||||
DefaultCurrency = StoreBlob.StandardDefaultCurrency,
|
||||
Exchanges = GetExchangesSelectList(null)
|
||||
};
|
||||
@ -56,6 +58,8 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var stores = await _repo.GetStoresByUserId(GetUserId());
|
||||
vm.IsFirstStore = !stores.Any();
|
||||
vm.Exchanges = GetExchangesSelectList(vm.PreferredExchange);
|
||||
return View(vm);
|
||||
}
|
||||
|
@ -318,6 +318,8 @@ namespace BTCPayServer.Controllers
|
||||
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
var minRelayFee = _dashboard.Get(btcPayNetwork.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee;
|
||||
_payjoinClient.MinimumFeeRate = minRelayFee;
|
||||
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
|
||||
}
|
||||
|
||||
|
@ -65,6 +65,8 @@ namespace BTCPayServer.Data
|
||||
_DefaultCurrency = _DefaultCurrency.Trim().ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public string StoreSupportUrl { get; set; }
|
||||
|
||||
CurrencyPair[] _DefaultCurrencyPairs;
|
||||
[JsonProperty("defaultCurrencyPairs", ItemConverterType = typeof(CurrencyPairJsonConverter))]
|
||||
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -86,9 +87,6 @@ namespace BTCPayServer
|
||||
}
|
||||
}
|
||||
|
||||
var log = evt.ToString();
|
||||
if (!String.IsNullOrEmpty(log))
|
||||
Logs.Events.LogInformation(log);
|
||||
foreach (var sub in actionList)
|
||||
{
|
||||
try
|
||||
|
26
BTCPayServer/Forms/FieldValueMirror.cs
Normal file
26
BTCPayServer/Forms/FieldValueMirror.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
|
||||
namespace BTCPayServer.Forms;
|
||||
|
||||
public class FieldValueMirror : IFormComponentProvider
|
||||
{
|
||||
public string View { get; } = null;
|
||||
public void Validate(Form form, Field field)
|
||||
{
|
||||
if (form.GetFieldByFullName(field.Value) is null)
|
||||
{
|
||||
field.ValidationErrors = new List<string> { $"{field.Name} requires {field.Value} to be present" };
|
||||
}
|
||||
}
|
||||
|
||||
public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
|
||||
{
|
||||
typeToComponentProvider.Add("mirror", this);
|
||||
}
|
||||
|
||||
public string GetValue(Form form, Field field)
|
||||
{
|
||||
return form.GetFieldByFullName(field.Value)?.Value;
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ public static class FormDataExtensions
|
||||
serviceCollection.AddSingleton<FormDataService>();
|
||||
serviceCollection.AddSingleton<FormComponentProviders>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlTextareaFormProvider>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlSelectFormProvider>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, FieldValueMirror>();
|
||||
|
@ -5,27 +5,6 @@ using BTCPayServer.Validation;
|
||||
|
||||
namespace BTCPayServer.Forms;
|
||||
|
||||
public class FieldValueMirror : IFormComponentProvider
|
||||
{
|
||||
public string View { get; } = null;
|
||||
public void Validate(Form form, Field field)
|
||||
{
|
||||
if (form.GetFieldByFullName(field.Value) is null)
|
||||
{
|
||||
field.ValidationErrors = new List<string>() { $"{field.Name} requires {field.Value} to be present" };
|
||||
}
|
||||
}
|
||||
|
||||
public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
|
||||
{
|
||||
typeToComponentProvider.Add("mirror", this);
|
||||
}
|
||||
|
||||
public string GetValue(Form form, Field field)
|
||||
{
|
||||
return form.GetFieldByFullName(field.Value)?.Value;
|
||||
}
|
||||
}
|
||||
public class HtmlInputFormProvider : FormComponentProviderBase
|
||||
{
|
||||
public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
|
||||
|
@ -9,10 +9,9 @@ public class HtmlSelectFormProvider : FormComponentProviderBase
|
||||
{
|
||||
public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
|
||||
{
|
||||
foreach (var t in new[] {
|
||||
"select"})
|
||||
typeToComponentProvider.Add(t, this);
|
||||
typeToComponentProvider.Add("select", this);
|
||||
}
|
||||
|
||||
public override string View => "Forms/SelectElement";
|
||||
|
||||
public override void Validate(Form form, Field field)
|
||||
|
23
BTCPayServer/Forms/HtmlTextareaFormProvider.cs
Normal file
23
BTCPayServer/Forms/HtmlTextareaFormProvider.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
|
||||
namespace BTCPayServer.Forms;
|
||||
|
||||
public class HtmlTextareaFormProvider : FormComponentProviderBase
|
||||
{
|
||||
public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
|
||||
{
|
||||
typeToComponentProvider.Add("textarea", this);
|
||||
}
|
||||
|
||||
public override string View => "Forms/TextareaElement";
|
||||
|
||||
public override void Validate(Form form, Field field)
|
||||
{
|
||||
if (field.Required)
|
||||
{
|
||||
ValidateField<RequiredAttribute>(field);
|
||||
}
|
||||
}
|
||||
}
|
@ -77,7 +77,6 @@ public class UIFormsController : Controller
|
||||
|
||||
if (!_formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error))
|
||||
{
|
||||
|
||||
ModelState.AddModelError(nameof(modifyForm.FormConfig),
|
||||
$"Form config was invalid: {error})");
|
||||
}
|
||||
@ -86,7 +85,6 @@ public class UIFormsController : Controller
|
||||
modifyForm.FormConfig = form.ToString();
|
||||
}
|
||||
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(modifyForm);
|
||||
|
@ -40,11 +40,11 @@ namespace BTCPayServer.HostedServices
|
||||
case PointOfSaleAppType.AppType:
|
||||
var possettings = data.GetSettings<PointOfSaleSettings>();
|
||||
return (Data: data, Settings: (object)possettings,
|
||||
Items: _appService.Parse(possettings.Template, possettings.Currency));
|
||||
Items: AppService.Parse(possettings.Template));
|
||||
case CrowdfundAppType.AppType:
|
||||
var cfsettings = data.GetSettings<CrowdfundSettings>();
|
||||
return (Data: data, Settings: (object)cfsettings,
|
||||
Items: _appService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency));
|
||||
Items: AppService.Parse(cfsettings.PerksTemplate));
|
||||
default:
|
||||
return (null, null, null);
|
||||
}
|
||||
@ -70,11 +70,11 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
case PointOfSaleAppType.AppType:
|
||||
((PointOfSaleSettings)valueTuple.Settings).Template =
|
||||
_appService.SerializeTemplate(valueTuple.Items);
|
||||
AppService.SerializeTemplate(valueTuple.Items);
|
||||
break;
|
||||
case CrowdfundAppType.AppType:
|
||||
((CrowdfundSettings)valueTuple.Settings).PerksTemplate =
|
||||
_appService.SerializeTemplate(valueTuple.Items);
|
||||
AppService.SerializeTemplate(valueTuple.Items);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException();
|
||||
|
@ -261,7 +261,6 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
if (e.Name == InvoiceEvent.Expired ||
|
||||
e.Name == InvoiceEvent.PaidInFull ||
|
||||
e.Name == InvoiceEvent.FailedToConfirm ||
|
||||
e.Name == InvoiceEvent.MarkedInvalid ||
|
||||
e.Name == InvoiceEvent.MarkedCompleted ||
|
||||
e.Name == InvoiceEvent.FailedToConfirm ||
|
||||
|
@ -380,7 +380,8 @@ namespace BTCPayServer.HostedServices
|
||||
if ((onChainPaymentData.ConfirmationCount < network.MaxTrackedConfirmation && payment.Accounted)
|
||||
&& (onChainPaymentData.Legacy || invoice.MonitoringExpiration < DateTimeOffset.UtcNow))
|
||||
{
|
||||
var transactionResult = await _explorerClientProvider.GetExplorerClient(payment.GetCryptoCode())?.GetTransactionAsync(onChainPaymentData.Outpoint.Hash);
|
||||
var client = _explorerClientProvider.GetExplorerClient(payment.GetCryptoCode());
|
||||
var transactionResult = client is null ? null : await client.GetTransactionAsync(onChainPaymentData.Outpoint.Hash);
|
||||
var confirmationCount = transactionResult?.Confirmations ?? 0;
|
||||
onChainPaymentData.ConfirmationCount = confirmationCount;
|
||||
payment.SetCryptoPaymentData(onChainPaymentData);
|
||||
|
@ -60,9 +60,6 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
httpContext.Response.SetHeader("Access-Control-Allow-Origin", "*");
|
||||
httpContext.SetBitpayAuth(bitpayAuth);
|
||||
}
|
||||
if (isBitpayAPI)
|
||||
{
|
||||
await _Next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -26,11 +27,14 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NBXplorer;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PeterO.Cbor;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using LightningAddressData = BTCPayServer.Data.LightningAddressData;
|
||||
using Serializer = NBXplorer.Serializer;
|
||||
|
||||
namespace BTCPayServer.Hosting
|
||||
{
|
||||
@ -246,6 +250,11 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
await FixMappedDomainAppType();
|
||||
settings.FixMappedDomainAppType = true;
|
||||
}
|
||||
if (!settings.MigrateAppYmlToJson)
|
||||
{
|
||||
await MigrateAppYmlToJson();
|
||||
settings.MigrateAppYmlToJson = true;
|
||||
await _Settings.UpdateSetting(settings);
|
||||
}
|
||||
}
|
||||
@ -304,6 +313,134 @@ namespace BTCPayServer.Hosting
|
||||
return;
|
||||
await ToPostgresMigrationStartupTask.UpdateSequenceInvoiceSearch(ctx);
|
||||
}
|
||||
private async Task MigrateAppYmlToJson()
|
||||
{
|
||||
await using var ctx = _DBContextFactory.CreateContext();
|
||||
var apps = await ctx.Apps.Where(data => CrowdfundAppType.AppType == data.AppType || PointOfSaleAppType.AppType == data.AppType)
|
||||
.ToListAsync();
|
||||
foreach (var app in apps)
|
||||
{
|
||||
switch (app.AppType)
|
||||
{
|
||||
case CrowdfundAppType.AppType :
|
||||
var cfSettings = app.GetSettings<CrowdfundSettings>();
|
||||
if (!string.IsNullOrEmpty(cfSettings?.PerksTemplate))
|
||||
{
|
||||
cfSettings.PerksTemplate = AppService.SerializeTemplate(ParsePOSYML(cfSettings?.PerksTemplate));
|
||||
app.SetSettings(cfSettings);
|
||||
}
|
||||
break;
|
||||
case PointOfSaleAppType.AppType:
|
||||
var pSettings = app.GetSettings<PointOfSaleSettings>();
|
||||
if (!string.IsNullOrEmpty(pSettings?.Template))
|
||||
{
|
||||
pSettings.Template = AppService.SerializeTemplate(ParsePOSYML(pSettings?.Template));
|
||||
app.SetSettings(pSettings);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
}
|
||||
public static ViewPointOfSaleViewModel.Item[] ParsePOSYML(string yaml)
|
||||
{
|
||||
var items = new List<ViewPointOfSaleViewModel.Item>();
|
||||
var stream = new YamlStream();
|
||||
stream.Load(new StringReader(yaml));
|
||||
|
||||
var root = stream.Documents.First().RootNode as YamlMappingNode;
|
||||
foreach (var posItem in root.Children)
|
||||
{
|
||||
var trimmedKey = ((YamlScalarNode)posItem.Key).Value?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var currentItem = new ViewPointOfSaleViewModel.Item
|
||||
{
|
||||
Id = trimmedKey, Title = trimmedKey, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed
|
||||
};
|
||||
var itemSpecs = (YamlMappingNode)posItem.Value;
|
||||
foreach (var spec in itemSpecs)
|
||||
{
|
||||
if (spec.Key is not YamlScalarNode {Value: string keyString} || string.IsNullOrEmpty(keyString))
|
||||
continue;
|
||||
var scalarValue = spec.Value as YamlScalarNode;
|
||||
switch (keyString)
|
||||
{
|
||||
case "title":
|
||||
currentItem.Title = scalarValue?.Value ?? trimmedKey;
|
||||
break;
|
||||
case "inventory":
|
||||
if (int.TryParse(scalarValue?.Value, out var inv))
|
||||
{
|
||||
currentItem.Inventory = inv;
|
||||
}
|
||||
break;
|
||||
case "description":
|
||||
currentItem.Description = scalarValue?.Value;
|
||||
break;
|
||||
case "image":
|
||||
currentItem.Image = scalarValue?.Value;
|
||||
break;
|
||||
case "payment_methods" when spec.Value is YamlSequenceNode pmSequenceNode:
|
||||
|
||||
currentItem.PaymentMethods = pmSequenceNode.Children
|
||||
.Select(node => (node as YamlScalarNode)?.Value?.Trim())
|
||||
.Where(node => !string.IsNullOrEmpty(node)).ToArray();
|
||||
break;
|
||||
case "price_type":
|
||||
case "custom":
|
||||
if (bool.TryParse(scalarValue?.Value, out var customBoolValue))
|
||||
{
|
||||
if (customBoolValue)
|
||||
{
|
||||
currentItem.PriceType = currentItem.Price is null or 0
|
||||
? ViewPointOfSaleViewModel.ItemPriceType.Topup
|
||||
: ViewPointOfSaleViewModel.ItemPriceType.Minimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentItem.PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed;
|
||||
}
|
||||
}
|
||||
else if (Enum.TryParse<ViewPointOfSaleViewModel.ItemPriceType>(scalarValue?.Value, true,
|
||||
out var customPriceType))
|
||||
{
|
||||
currentItem.PriceType = customPriceType;
|
||||
}
|
||||
|
||||
break;
|
||||
case "price":
|
||||
if (decimal.TryParse(scalarValue?.Value, out var price))
|
||||
{
|
||||
currentItem.Price = price;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "buybuttontext":
|
||||
currentItem.BuyButtonText = scalarValue?.Value;
|
||||
break;
|
||||
|
||||
case "disabled":
|
||||
if (bool.TryParse(scalarValue?.Value, out var disabled))
|
||||
{
|
||||
currentItem.Disabled = disabled;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(currentItem);
|
||||
}
|
||||
|
||||
return items.ToArray();
|
||||
}
|
||||
|
||||
#pragma warning disable CS0612 // Type or member is obsolete
|
||||
|
||||
@ -521,8 +658,8 @@ WHERE cte.""Id""=p.""Id""
|
||||
settings1.TargetCurrency = app.StoreData.GetStoreBlob().DefaultCurrency;
|
||||
app.SetSettings(settings1);
|
||||
}
|
||||
items = _appService.Parse(settings1.PerksTemplate, settings1.TargetCurrency);
|
||||
newTemplate = _appService.SerializeTemplate(items);
|
||||
items = AppService.Parse(settings1.PerksTemplate);
|
||||
newTemplate = AppService.SerializeTemplate(items);
|
||||
if (settings1.PerksTemplate != newTemplate)
|
||||
{
|
||||
settings1.PerksTemplate = newTemplate;
|
||||
@ -538,8 +675,8 @@ WHERE cte.""Id""=p.""Id""
|
||||
settings2.Currency = app.StoreData.GetStoreBlob().DefaultCurrency;
|
||||
app.SetSettings(settings2);
|
||||
}
|
||||
items = _appService.Parse(settings2.Template, settings2.Currency);
|
||||
newTemplate = _appService.SerializeTemplate(items);
|
||||
items = AppService.Parse(settings2.Template);
|
||||
newTemplate = AppService.SerializeTemplate(items);
|
||||
if (settings2.Template != newTemplate)
|
||||
{
|
||||
settings2.Template = newTemplate;
|
||||
|
@ -1,17 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
|
||||
namespace BTCPayServer.Models.InvoicingModels
|
||||
{
|
||||
public class InvoicesModel : BasePagingViewModel
|
||||
{
|
||||
public List<InvoiceModel> Invoices { get; set; } = new List<InvoiceModel>();
|
||||
public List<InvoiceModel> Invoices { get; set; } = new ();
|
||||
public override int CurrentPageCount => Invoices.Count;
|
||||
public string[] StoreIds { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public bool IncludeArchived { get; set; }
|
||||
|
||||
public string SearchText { get; set; }
|
||||
public SearchString Search { get; set; }
|
||||
public List<InvoiceAppModel> Apps { get; set; }
|
||||
}
|
||||
|
||||
public class InvoiceModel
|
||||
@ -34,4 +35,12 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public InvoiceDetailsModel Details { get; set; }
|
||||
public bool HasRefund { get; set; }
|
||||
}
|
||||
|
||||
public class InvoiceAppModel
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string AppName { get; set; }
|
||||
public string AppType { get; set; }
|
||||
public string AppOrderId { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public int TxCount { get; set; }
|
||||
public int TxCountForFee { get; set; }
|
||||
public string BtcPaid { get; set; }
|
||||
public string StoreEmail { get; set; }
|
||||
public string StoreSupportUrl { get; set; }
|
||||
|
||||
public string OrderId { get; set; }
|
||||
public decimal NetworkFee { get; set; }
|
||||
|
@ -24,9 +24,16 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public string RateThenText { get; set; }
|
||||
public string FiatText { get; set; }
|
||||
public decimal FiatAmount { get; set; }
|
||||
public decimal? OverpaidAmount { get; set; }
|
||||
public string OverpaidAmountText { get; set; }
|
||||
public decimal SubtractPercentage { get; set; }
|
||||
|
||||
[Display(Name = "Specify the amount and currency for the refund")]
|
||||
public decimal CustomAmount { get; set; }
|
||||
public string CustomCurrency { get; set; }
|
||||
public string InvoiceCurrency { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
public int CryptoDivisibility { get; set; }
|
||||
public int InvoiceDivisibility { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
{
|
||||
public class CreateStoreViewModel
|
||||
{
|
||||
public bool IsFirstStore { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
[MinLength(1)]
|
||||
|
@ -22,6 +22,10 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[MaxLength(500)]
|
||||
public string StoreWebsite { get; set; }
|
||||
|
||||
[Display(Name = "Support URL")]
|
||||
[MaxLength(500)]
|
||||
public string StoreSupportUrl { get; set; }
|
||||
|
||||
[Display(Name = "Brand Color")]
|
||||
public string BrandColor { get; set; }
|
||||
|
||||
|
@ -414,20 +414,6 @@ namespace BTCPayServer.Payments.Bitcoin
|
||||
if (invoice == null)
|
||||
return null;
|
||||
var paymentMethod = invoice.GetPaymentMethod(wallet.Network, PaymentTypes.BTCLike);
|
||||
if (paymentMethod != null &&
|
||||
paymentMethod.GetPaymentMethodDetails() is BitcoinLikeOnChainPaymentMethod btc &&
|
||||
btc.Activated &&
|
||||
btc.GetDepositAddress(wallet.Network.NBitcoinNetwork).ScriptPubKey == paymentData.ScriptPubKey &&
|
||||
paymentMethod.Calculate().Due > Money.Zero)
|
||||
{
|
||||
var address = await wallet.ReserveAddressAsync(invoice.StoreId, strategy, "invoice");
|
||||
btc.DepositAddress = address.Address.ToString();
|
||||
btc.KeyPath = address.KeyPath;
|
||||
await _InvoiceRepository.NewPaymentDetails(invoice.Id, btc, wallet.Network);
|
||||
_Aggregator.Publish(new InvoiceNewPaymentDetailsEvent(invoice.Id, btc, paymentMethod.GetId()));
|
||||
paymentMethod.SetPaymentMethodDetails(btc);
|
||||
invoice.SetPaymentMethod(paymentMethod);
|
||||
}
|
||||
wallet.InvalidateCache(strategy);
|
||||
_Aggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment });
|
||||
return invoice;
|
||||
|
@ -85,7 +85,9 @@ namespace BTCPayServer.Payments.Lightning
|
||||
var paymentMethodId = paymentMethod.GetId();
|
||||
var network = _networkProvider.GetNetwork<BTCPayNetwork>(model.CryptoCode);
|
||||
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
|
||||
var lnurl = cryptoInfo.PaymentUrls?.AdditionalData["LNURLP"].ToObject<string>();
|
||||
var lnurl = cryptoInfo.PaymentUrls?.AdditionalData.TryGetValue("LNURLP", out var lnurlpObj) is true
|
||||
? lnurlpObj?.ToObject<string>()
|
||||
: null;
|
||||
|
||||
model.PaymentMethodName = GetPaymentMethodName(network);
|
||||
model.BtcAddress = lnurl?.Replace(UriScheme, "");
|
||||
|
@ -12,7 +12,7 @@ namespace BTCPayServer.Payments
|
||||
{
|
||||
public class LNURLPayPaymentType : LightningPaymentType
|
||||
{
|
||||
public new static LNURLPayPaymentType Instance { get; } = new LNURLPayPaymentType();
|
||||
public new static LNURLPayPaymentType Instance { get; } = new();
|
||||
public override string ToPrettyString() => "LNURL-Pay";
|
||||
public override string GetId() => "LNURLPAY";
|
||||
public override string ToStringNormalized() => "LNURLPAY";
|
||||
@ -34,10 +34,21 @@ namespace BTCPayServer.Payments
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var lnurlPaymentMethodDetails = (LNURLPayPaymentMethodDetails)paymentMethodDetails;
|
||||
var uri = new Uri(
|
||||
$"{serverUri.WithTrailingSlash()}{network.CryptoCode}/UILNURL/pay/i/{invoice.Id}");
|
||||
return LNURL.LNURL.EncodeUri(uri, "payRequest", lnurlPaymentMethodDetails.Bech32Mode).ToString();
|
||||
|
||||
try
|
||||
{
|
||||
var lnurlPaymentMethodDetails = (LNURLPayPaymentMethodDetails)paymentMethodDetails;
|
||||
var uri = new Uri(
|
||||
$"{serverUri.WithTrailingSlash()}{network.CryptoCode}/UILNURL/pay/i/{invoice.Id}");
|
||||
return LNURL.LNURL.EncodeUri(uri, "payRequest", lnurlPaymentMethodDetails.Bech32Mode).ToString();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// TODO: we need to switch payment types from static singletons to DI
|
||||
// _logger.LogError(e, "Error generating LNURL payment link");
|
||||
Console.WriteLine($"Error generating LNURL payment link: {e.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override string InvoiceViewPaymentPartialName { get; } = "Lightning/ViewLightningLikePaymentData";
|
||||
|
@ -14,4 +14,5 @@ public interface IPayoutProcessorFactory
|
||||
public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request);
|
||||
public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods();
|
||||
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings);
|
||||
public Task<bool> CanRemove() => Task.FromResult(true);
|
||||
}
|
||||
|
@ -136,7 +136,16 @@ public class PayoutProcessorService : EventHostedServiceBase
|
||||
if (matchedProcessor is not null)
|
||||
{
|
||||
await StopProcessor(data.Id, cancellationToken);
|
||||
var processor = await matchedProcessor.ConstructProcessor(data);
|
||||
IHostedService processor = null;
|
||||
try
|
||||
{
|
||||
processor = await matchedProcessor.ConstructProcessor(data);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
Logs.PayServer.LogWarning(ex, $"Payout processor ({data.PaymentMethod}) failed to start. Skipping...");
|
||||
return;
|
||||
}
|
||||
await processor.StartAsync(cancellationToken);
|
||||
Services.TryAdd(data.Id, processor);
|
||||
}
|
||||
|
@ -127,13 +127,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
|
||||
ViewPointOfSaleViewModel.Item choice = null;
|
||||
if (!string.IsNullOrEmpty(request.ChoiceKey))
|
||||
{
|
||||
var choices = _appService.GetPOSItems(settings.PerksTemplate, settings.TargetCurrency);
|
||||
var choices = AppService.Parse(settings.PerksTemplate, false);
|
||||
choice = choices?.FirstOrDefault(c => c.Id == request.ChoiceKey);
|
||||
if (choice == null)
|
||||
return NotFound("Incorrect option provided");
|
||||
title = choice.Title;
|
||||
|
||||
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
|
||||
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
price = null;
|
||||
}
|
||||
@ -174,6 +174,8 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
|
||||
|
||||
try
|
||||
{
|
||||
var appPath = await _appService.ViewLink(app);
|
||||
var appUrl = HttpContext.Request.GetAbsoluteUri(appPath);
|
||||
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
|
||||
{
|
||||
OrderId = AppService.GetAppOrderId(app),
|
||||
@ -186,12 +188,12 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
|
||||
FullNotifications = true,
|
||||
ExtendedNotifications = true,
|
||||
SupportedTransactionCurrencies = paymentMethods,
|
||||
RedirectURL = request.RedirectUrl ?? Request.GetDisplayUrl(),
|
||||
RedirectURL = request.RedirectUrl ?? appUrl,
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(),
|
||||
new List<string>() { AppService.GetAppInternalTag(appId) },
|
||||
cancellationToken, (entity) =>
|
||||
new List<string> { AppService.GetAppInternalTag(appId) },
|
||||
cancellationToken, entity =>
|
||||
{
|
||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
||||
entity.Metadata.OrderUrl = appUrl;
|
||||
});
|
||||
|
||||
if (request.RedirectToCheckout)
|
||||
@ -271,7 +273,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
|
||||
|
||||
try
|
||||
{
|
||||
vm.PerksTemplate = _appService.SerializeTemplate(_appService.Parse(vm.PerksTemplate, vm.TargetCurrency));
|
||||
vm.PerksTemplate = AppService.SerializeTemplate(AppService.Parse(vm.PerksTemplate));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@ -74,14 +74,14 @@ namespace BTCPayServer.Plugins.Crowdfund
|
||||
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
|
||||
{
|
||||
var cfS = app.GetSettings<CrowdfundSettings>();
|
||||
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, cfS.PerksTemplate, cfS.TargetCurrency);
|
||||
var items = AppService.Parse( cfS.PerksTemplate);
|
||||
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
|
||||
{
|
||||
var settings = appData.GetSettings<CrowdfundSettings>();
|
||||
var perks = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
|
||||
var perks = AppService.Parse( settings.PerksTemplate);
|
||||
var perkCount = paidInvoices
|
||||
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
|
||||
// we need the item code to know which perk it is and group by that
|
||||
@ -176,7 +176,7 @@ namespace BTCPayServer.Plugins.Crowdfund
|
||||
})));
|
||||
}
|
||||
|
||||
var perks = AppService.GetPOSItems(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
|
||||
var perks = AppService.Parse( settings.PerksTemplate, false);
|
||||
if (settings.SortPerksByPopularity)
|
||||
{
|
||||
var ordered = perkCount.OrderByDescending(pair => pair.Value);
|
||||
|
@ -58,7 +58,7 @@ namespace BTCPayServer.Plugins.NFC
|
||||
if (!methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LNURLPay), out var lnurlPaymentMethod) &&
|
||||
!methods.TryGetValue(new PaymentMethodId("BTC", PaymentTypes.LightningLike), out lnPaymentMethod))
|
||||
{
|
||||
return BadRequest("Destination for lnurlw was not specified");
|
||||
return BadRequest("Destination for LNURL-Withdraw was not specified");
|
||||
}
|
||||
|
||||
Uri uri;
|
||||
@ -81,10 +81,8 @@ namespace BTCPayServer.Plugins.NFC
|
||||
return BadRequest("LNURL was not LNURL-Withdraw");
|
||||
}
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient(uri.IsOnion()
|
||||
? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient
|
||||
: LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient);
|
||||
LNURLWithdrawRequest info;
|
||||
var httpClient = CreateHttpClient(uri);
|
||||
try
|
||||
{
|
||||
info = await LNURL.LNURL.FetchInformation(uri, tag, httpClient) as LNURLWithdrawRequest;
|
||||
@ -100,10 +98,6 @@ namespace BTCPayServer.Plugins.NFC
|
||||
return BadRequest("Could not fetch info from LNURL-Withdraw");
|
||||
}
|
||||
|
||||
httpClient = _httpClientFactory.CreateClient(info.Callback.IsOnion()
|
||||
? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient
|
||||
: LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient);
|
||||
|
||||
string bolt11 = null;
|
||||
if (lnPaymentMethod is not null)
|
||||
{
|
||||
@ -155,23 +149,31 @@ namespace BTCPayServer.Plugins.NFC
|
||||
due = lnurlPaymentMethod.Calculate().Due;
|
||||
}
|
||||
|
||||
var amount = LightMoney.Satoshis(due.Satoshi);
|
||||
var actionPath = Url.Action(nameof(UILNURLController.GetLNURLForInvoice), "UILNURL",
|
||||
new { invoiceId = request.InvoiceId, cryptoCode = "BTC", amount = amount.MilliSatoshi });
|
||||
var url = Request.GetAbsoluteUri(actionPath);
|
||||
var resp = await httpClient.GetAsync(url);
|
||||
var response = await resp.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
httpClient = CreateHttpClient(info.Callback);
|
||||
var amount = LightMoney.Satoshis(due.Satoshi);
|
||||
var actionPath = Url.Action(nameof(UILNURLController.GetLNURLForInvoice), "UILNURL",
|
||||
new { invoiceId = request.InvoiceId, cryptoCode = "BTC", amount = amount.MilliSatoshi });
|
||||
var url = Request.GetAbsoluteUri(actionPath);
|
||||
var resp = await httpClient.GetAsync(url);
|
||||
var response = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
var res = JObject.Parse(response).ToObject<LNURLPayRequest.LNURLPayRequestCallbackResponse>();
|
||||
bolt11 = res.Pr;
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
var res = JObject.Parse(response).ToObject<LNURLPayRequest.LNURLPayRequestCallbackResponse>();
|
||||
bolt11 = res.Pr;
|
||||
}
|
||||
else
|
||||
{
|
||||
var res = JObject.Parse(response).ToObject<LNUrlStatusResponse>();
|
||||
return BadRequest($"Could not fetch BOLT11 invoice to pay to: {res.Reason}");
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
var res = JObject.Parse(response).ToObject<LNUrlStatusResponse>();
|
||||
return BadRequest(
|
||||
$"Could not fetch BOLT11 invoice to pay to: {res.Reason}");
|
||||
return BadRequest($"Could not fetch BOLT11 invoice to pay to: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,13 +182,27 @@ namespace BTCPayServer.Plugins.NFC
|
||||
return BadRequest("Could not fetch BOLT11 invoice to pay to.");
|
||||
}
|
||||
|
||||
var result = await info.SendRequest(bolt11, httpClient);
|
||||
if (!string.IsNullOrEmpty(result.Status) && result.Status.Equals("ok", StringComparison.InvariantCultureIgnoreCase))
|
||||
try
|
||||
{
|
||||
return Ok(result.Reason);
|
||||
}
|
||||
var result = await info.SendRequest(bolt11, httpClient);
|
||||
if (!string.IsNullOrEmpty(result.Status) && result.Status.Equals("ok", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return Ok(result.Reason);
|
||||
}
|
||||
|
||||
return BadRequest(result.Reason ?? "Unknown error");
|
||||
return BadRequest(result.Reason ?? "Unknown error");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient(Uri uri)
|
||||
{
|
||||
return _httpClientFactory.CreateClient(uri.IsOnion()
|
||||
? LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient
|
||||
: LightningLikePayoutHandler.LightningLikePayoutHandlerClearnetNamedClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,7 +106,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
|
||||
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
|
||||
},
|
||||
Items = _appService.GetPOSItems(settings.Template, settings.Currency),
|
||||
Items = AppService.Parse(settings.Template, false),
|
||||
ButtonText = settings.ButtonText,
|
||||
CustomButtonText = settings.CustomButtonText,
|
||||
CustomTipText = settings.CustomTipText,
|
||||
@ -165,12 +165,12 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
ViewPointOfSaleViewModel.Item[] choices = null;
|
||||
if (!string.IsNullOrEmpty(choiceKey))
|
||||
{
|
||||
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
|
||||
choices = AppService.Parse(settings.Template, false);
|
||||
choice = choices.FirstOrDefault(c => c.Id == choiceKey);
|
||||
if (choice == null)
|
||||
return NotFound();
|
||||
title = choice.Title;
|
||||
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
|
||||
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
price = null;
|
||||
}
|
||||
@ -204,7 +204,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
if (currentView == PosViewType.Cart &&
|
||||
AppService.TryParsePosCartItems(jposData, out cartItems))
|
||||
{
|
||||
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
|
||||
choices = AppService.Parse(settings.Template, false);
|
||||
var expectedMinimumAmount = 0m;
|
||||
foreach (var cartItem in cartItems)
|
||||
{
|
||||
@ -224,9 +224,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
}
|
||||
|
||||
decimal expectedCartItemPrice = 0;
|
||||
if (itemChoice.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
|
||||
if (itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
expectedCartItemPrice = itemChoice.Price.Value ?? 0;
|
||||
expectedCartItemPrice = itemChoice.Price ?? 0;
|
||||
}
|
||||
|
||||
expectedMinimumAmount += expectedCartItemPrice * cartItem.Value;
|
||||
@ -327,7 +327,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
if (selectedChoices.TryGetValue(cartItem.Key, out var selectedChoice))
|
||||
{
|
||||
cartData.Add(selectedChoice.Title ?? selectedChoice.Id,
|
||||
$"{(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}");
|
||||
$"{(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}");
|
||||
|
||||
}
|
||||
}
|
||||
@ -533,7 +533,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
}
|
||||
try
|
||||
{
|
||||
var items = _appService.Parse(settings.Template, settings.Currency);
|
||||
var items = AppService.Parse(settings.Template);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
|
||||
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
|
||||
@ -568,7 +568,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
ModelState.AddModelError(nameof(vm.Currency), "Invalid currency");
|
||||
try
|
||||
{
|
||||
vm.Template = _appService.SerializeTemplate(_appService.Parse(vm.Template, vm.Currency));
|
||||
vm.Template = AppService.SerializeTemplate(AppService.Parse(vm.Template));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@ -1,34 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.PointOfSale.Models
|
||||
{
|
||||
public class ViewPointOfSaleViewModel
|
||||
{
|
||||
public enum ItemPriceType
|
||||
{
|
||||
Topup,
|
||||
Minimum,
|
||||
Fixed
|
||||
}
|
||||
|
||||
public class Item
|
||||
{
|
||||
public class ItemPrice
|
||||
{
|
||||
public enum ItemPriceType
|
||||
{
|
||||
Topup,
|
||||
Minimum,
|
||||
Fixed
|
||||
}
|
||||
|
||||
public ItemPriceType Type { get; set; }
|
||||
public string Formatted { get; set; }
|
||||
public decimal? Value { get; set; }
|
||||
}
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Description { get; set; }
|
||||
public string Id { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Image { get; set; }
|
||||
public ItemPrice Price { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public ItemPriceType PriceType { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal? Price { get; set; }
|
||||
public string Title { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string BuyButtonText { get; set; }
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? Inventory { get; set; } = null;
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[] PaymentMethods { get; set; }
|
||||
public bool Disabled { get; set; } = false;
|
||||
|
||||
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||
}
|
||||
|
||||
public class CurrencyInfoData
|
||||
|
@ -82,14 +82,14 @@ namespace BTCPayServer.Plugins.PointOfSale
|
||||
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
|
||||
{
|
||||
var posS = app.GetSettings<PointOfSaleSettings>();
|
||||
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, posS.Template, posS.Currency);
|
||||
var items = AppService.Parse(posS.Template);
|
||||
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
|
||||
{
|
||||
var settings = appData.GetSettings<PointOfSaleSettings>();
|
||||
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.Template, settings.Currency);
|
||||
var items = AppService.Parse( settings.Template);
|
||||
var itemCount = paidInvoices
|
||||
.Where(entity => entity.Currency.Equals(settings.Currency, StringComparison.OrdinalIgnoreCase) && (
|
||||
// The POS data is present for the cart view, where multiple items can be bought
|
||||
|
@ -2,73 +2,129 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ExchangeSharp;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
public class SearchString
|
||||
{
|
||||
readonly string _OriginalString;
|
||||
public SearchString(string str)
|
||||
private const char FilterSeparator = ',';
|
||||
private const char ValueSeparator = ':';
|
||||
|
||||
private readonly string _originalString;
|
||||
private readonly int _timezoneOffset;
|
||||
|
||||
public SearchString(string str, int timezoneOffset = 0)
|
||||
{
|
||||
str = str ?? string.Empty;
|
||||
str ??= string.Empty;
|
||||
str = str.Trim();
|
||||
_OriginalString = str.Trim();
|
||||
TextSearch = _OriginalString;
|
||||
var splitted = str.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
_originalString = str;
|
||||
_timezoneOffset = timezoneOffset;
|
||||
TextSearch = _originalString;
|
||||
var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries);
|
||||
Filters
|
||||
= splitted
|
||||
.Select(t => t.Split(new char[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Where(kv => kv.Length == 2)
|
||||
.Select(kv => new KeyValuePair<string, string>(kv[0].ToLowerInvariant().Trim(), kv[1]))
|
||||
.Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
|
||||
.ToMultiValueDictionary(o => o.Key, o => o.Value);
|
||||
|
||||
var val = splitted.FirstOrDefault(a => a?.IndexOf(':', StringComparison.OrdinalIgnoreCase) == -1);
|
||||
if (val != null)
|
||||
TextSearch = val.Trim();
|
||||
else
|
||||
TextSearch = "";
|
||||
var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
|
||||
TextSearch = val != null ? val.Trim() : string.Empty;
|
||||
}
|
||||
|
||||
public string TextSearch { get; private set; }
|
||||
|
||||
public MultiValueDictionary<string, string> Filters { get; private set; }
|
||||
public MultiValueDictionary<string, string> Filters { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return _OriginalString;
|
||||
return _originalString;
|
||||
}
|
||||
|
||||
internal string[] GetFilterArray(string key)
|
||||
public string Toggle(string key, string value)
|
||||
{
|
||||
key = UnifyKey(key);
|
||||
var keyValue = $"{key}{ValueSeparator}{value}";
|
||||
var prependOnInsert = string.IsNullOrEmpty(ToString()) ? string.Empty : $"{ToString()}{FilterSeparator}";
|
||||
if (!ContainsFilter(key)) return Finalize($"{prependOnInsert}{keyValue}");
|
||||
|
||||
var boolFilter = GetFilterBool(key);
|
||||
if (boolFilter != null)
|
||||
{
|
||||
return Finalize(ToString().Replace(keyValue, string.Empty));
|
||||
}
|
||||
|
||||
var dateFilter = GetFilterDate(key, _timezoneOffset);
|
||||
if (dateFilter != null)
|
||||
{
|
||||
var current = GetFilterArray(key).First();
|
||||
var oldValue = $"{key}{ValueSeparator}{current}";
|
||||
var newValue = string.IsNullOrEmpty(value) || current == value ? string.Empty : keyValue;
|
||||
return Finalize(_originalString.Replace(oldValue, newValue));
|
||||
}
|
||||
|
||||
var arrayFilter = GetFilterArray(key);
|
||||
if (arrayFilter != null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return Finalize(arrayFilter.Aggregate(ToString(), (current, filter) =>
|
||||
current.Replace($"{key}{ValueSeparator}{filter}", string.Empty)));
|
||||
}
|
||||
return Finalize(arrayFilter.Contains(value)
|
||||
? ToString().Replace(keyValue, string.Empty)
|
||||
: $"{prependOnInsert}{keyValue}"
|
||||
);
|
||||
}
|
||||
|
||||
return Finalize(ToString());
|
||||
}
|
||||
|
||||
public string WithoutSearchText()
|
||||
{
|
||||
return string.IsNullOrEmpty(TextSearch)
|
||||
? Finalize(ToString())
|
||||
: Finalize(ToString()).Replace(TextSearch, string.Empty);
|
||||
}
|
||||
|
||||
public string[] GetFilterArray(string key)
|
||||
{
|
||||
key = UnifyKey(key);
|
||||
return Filters.ContainsKey(key) ? Filters[key].ToArray() : null;
|
||||
}
|
||||
|
||||
internal bool? GetFilterBool(string key)
|
||||
public bool? GetFilterBool(string key)
|
||||
{
|
||||
key = UnifyKey(key);
|
||||
if (!Filters.ContainsKey(key))
|
||||
return null;
|
||||
|
||||
return bool.TryParse(Filters[key].First(), out var r) ?
|
||||
r : (bool?)null;
|
||||
return bool.TryParse(Filters[key].First(), out var r) ? r : null;
|
||||
}
|
||||
|
||||
internal DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
|
||||
public DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
|
||||
{
|
||||
key = UnifyKey(key);
|
||||
if (!Filters.ContainsKey(key))
|
||||
return null;
|
||||
|
||||
var val = Filters[key].First();
|
||||
|
||||
// handle special string values
|
||||
if (val == "-24h")
|
||||
return DateTimeOffset.UtcNow.AddHours(-24).AddMinutes(timezoneOffset);
|
||||
else if (val == "-3d")
|
||||
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
|
||||
else if (val == "-7d")
|
||||
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
|
||||
switch (val)
|
||||
{
|
||||
// handle special string values
|
||||
case "-24h":
|
||||
case "-1d":
|
||||
return DateTimeOffset.UtcNow.AddDays(-1).AddMinutes(timezoneOffset);
|
||||
case "-3d":
|
||||
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
|
||||
case "-7d":
|
||||
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
|
||||
}
|
||||
|
||||
// default parsing logic
|
||||
var success = DateTimeOffset.TryParse(val, null as IFormatProvider, DateTimeStyles.AssumeUniversal, out var r);
|
||||
var success = DateTimeOffset.TryParse(val, null, DateTimeStyles.AssumeUniversal, out var r);
|
||||
if (success)
|
||||
{
|
||||
r = r.AddMinutes(timezoneOffset);
|
||||
@ -78,6 +134,20 @@ namespace BTCPayServer
|
||||
return null;
|
||||
}
|
||||
|
||||
internal bool ContainsFilter(string key) => Filters.ContainsKey(key);
|
||||
public bool ContainsFilter(string key)
|
||||
{
|
||||
return Filters.ContainsKey(UnifyKey(key));
|
||||
}
|
||||
|
||||
private string UnifyKey(string key)
|
||||
{
|
||||
return key.ToLowerInvariant().Trim();
|
||||
}
|
||||
|
||||
private static string Finalize(string str)
|
||||
{
|
||||
var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
|
||||
return string.IsNullOrEmpty(value) ? " " : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,18 +13,12 @@ using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using ExchangeSharp;
|
||||
using Ganss.XSS;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBitpayClient;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
|
||||
namespace BTCPayServer.Services.Apps
|
||||
@ -32,6 +26,17 @@ namespace BTCPayServer.Services.Apps
|
||||
public class AppService
|
||||
{
|
||||
private readonly Dictionary<string, AppBaseType> _appTypes;
|
||||
static AppService()
|
||||
{
|
||||
_defaultSerializer = new JsonSerializerSettings()
|
||||
{
|
||||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
|
||||
Formatting = Formatting.None
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonSerializerSettings _defaultSerializer;
|
||||
|
||||
readonly ApplicationDbContextFactory _ContextFactory;
|
||||
private readonly InvoiceRepository _InvoiceRepository;
|
||||
readonly CurrencyNameTable _Currencies;
|
||||
@ -342,169 +347,20 @@ namespace BTCPayServer.Services.Apps
|
||||
{
|
||||
return _storeRepository.FindStore(app.StoreDataId);
|
||||
}
|
||||
|
||||
|
||||
public string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
|
||||
public static string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
|
||||
{
|
||||
var mappingNode = new YamlMappingNode();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var itemNode = new YamlMappingNode();
|
||||
itemNode.Add("title", new YamlScalarNode(item.Title));
|
||||
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup && item.Price.Value is not null)
|
||||
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
|
||||
if (!string.IsNullOrEmpty(item.Description))
|
||||
{
|
||||
itemNode.Add("description", new YamlScalarNode(item.Description)
|
||||
{
|
||||
Style = ScalarStyle.DoubleQuoted
|
||||
});
|
||||
}
|
||||
if (!string.IsNullOrEmpty(item.Image))
|
||||
{
|
||||
itemNode.Add("image", new YamlScalarNode(item.Image));
|
||||
}
|
||||
itemNode.Add("price_type", new YamlScalarNode(item.Price.Type.ToStringLowerInvariant()));
|
||||
itemNode.Add("disabled", new YamlScalarNode(item.Disabled.ToStringLowerInvariant()));
|
||||
if (item.Inventory.HasValue)
|
||||
{
|
||||
itemNode.Add("inventory", new YamlScalarNode(item.Inventory.ToString()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(item.BuyButtonText))
|
||||
{
|
||||
itemNode.Add("buyButtonText", new YamlScalarNode(item.BuyButtonText));
|
||||
}
|
||||
|
||||
if (item.PaymentMethods?.Any() is true)
|
||||
{
|
||||
itemNode.Add("payment_methods", new YamlSequenceNode(item.PaymentMethods.Select(s => new YamlScalarNode(s))));
|
||||
}
|
||||
mappingNode.Add(item.Id, itemNode);
|
||||
}
|
||||
|
||||
var serializer = new SerializerBuilder().Build();
|
||||
return serializer.Serialize(mappingNode);
|
||||
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
|
||||
}
|
||||
|
||||
public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency)
|
||||
{
|
||||
return Parse(_HtmlSanitizer, _displayFormatter, template, currency);
|
||||
}
|
||||
|
||||
|
||||
public ViewPointOfSaleViewModel.Item[] GetPOSItems(string template, string currency)
|
||||
{
|
||||
return GetPOSItems(_HtmlSanitizer, _displayFormatter, template, currency);
|
||||
}
|
||||
public static ViewPointOfSaleViewModel.Item[] Parse(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
|
||||
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
return Array.Empty<ViewPointOfSaleViewModel.Item>();
|
||||
using var input = new StringReader(template);
|
||||
YamlStream stream = new();
|
||||
stream.Load(input);
|
||||
var root = (YamlMappingNode)stream.Documents[0].RootNode;
|
||||
return root
|
||||
.Children
|
||||
.Select(kv => new PosHolder(htmlSanitizer) { Key = htmlSanitizer.Sanitize((kv.Key as YamlScalarNode)?.Value), Value = kv.Value as YamlMappingNode })
|
||||
.Where(kv => kv.Value != null)
|
||||
.Select(c =>
|
||||
{
|
||||
ViewPointOfSaleViewModel.Item.ItemPrice price = new();
|
||||
var pValue = c.GetDetail("price")?.FirstOrDefault();
|
||||
|
||||
switch (c.GetDetailString("custom") ?? c.GetDetailString("price_type")?.ToLowerInvariant())
|
||||
{
|
||||
case "topup":
|
||||
case null when pValue is null:
|
||||
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup;
|
||||
break;
|
||||
case "true":
|
||||
case "minimum":
|
||||
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum;
|
||||
if (pValue != null && !string.IsNullOrEmpty(pValue.Value?.Value))
|
||||
{
|
||||
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
|
||||
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
}
|
||||
break;
|
||||
case "fixed":
|
||||
case "false":
|
||||
case null:
|
||||
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed;
|
||||
if (pValue?.Value.Value is not null)
|
||||
{
|
||||
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
|
||||
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return new ViewPointOfSaleViewModel.Item
|
||||
{
|
||||
Description = c.GetDetailString("description"),
|
||||
Id = c.Key,
|
||||
Image = c.GetDetailString("image"),
|
||||
Title = c.GetDetailString("title") ?? c.Key,
|
||||
Price = price,
|
||||
BuyButtonText = c.GetDetailString("buyButtonText"),
|
||||
Inventory =
|
||||
string.IsNullOrEmpty(c.GetDetailString("inventory"))
|
||||
? null
|
||||
: int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture),
|
||||
PaymentMethods = c.GetDetailStringList("payment_methods"),
|
||||
Disabled = c.GetDetailString("disabled") == "true"
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static ViewPointOfSaleViewModel.Item[] GetPOSItems(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
|
||||
{
|
||||
return Parse(htmlSanitizer, displayFormatter, template, currency).Where(c => !c.Disabled).ToArray();
|
||||
return JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!.Where(item => includeDisabled || !item.Disabled).ToArray();
|
||||
}
|
||||
#nullable restore
|
||||
private class PosHolder
|
||||
{
|
||||
private readonly HtmlSanitizer _htmlSanitizer;
|
||||
|
||||
public PosHolder(
|
||||
HtmlSanitizer htmlSanitizer)
|
||||
{
|
||||
_htmlSanitizer = htmlSanitizer;
|
||||
}
|
||||
|
||||
public string Key { get; set; }
|
||||
public YamlMappingNode Value { get; set; }
|
||||
|
||||
public IEnumerable<PosScalar> GetDetail(string field)
|
||||
{
|
||||
var res = Value.Children
|
||||
.Where(kv => kv.Value != null)
|
||||
.Select(kv => new PosScalar { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlScalarNode })
|
||||
.Where(cc => cc.Key == field);
|
||||
return res;
|
||||
}
|
||||
|
||||
public string GetDetailString(string field)
|
||||
{
|
||||
var raw = GetDetail(field).FirstOrDefault()?.Value?.Value;
|
||||
return raw is null ? null : _htmlSanitizer.Sanitize(raw);
|
||||
}
|
||||
public string[] GetDetailStringList(string field)
|
||||
{
|
||||
if (!Value.Children.ContainsKey(field) || !(Value.Children[field] is YamlSequenceNode sequenceNode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return sequenceNode.Children.Select(node => (node as YamlScalarNode)?.Value).Where(s => s != null).Select(s => _htmlSanitizer.Sanitize(s)).ToArray();
|
||||
}
|
||||
}
|
||||
private class PosScalar
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public YamlScalarNode Value { get; set; }
|
||||
}
|
||||
#nullable enable
|
||||
public async Task<AppData?> GetAppDataIfOwner(string userId, string appId, string? type = null)
|
||||
{
|
||||
|
@ -1,5 +1,5 @@
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
||||
|
||||
namespace BTCPayServer.Services.Apps
|
||||
@ -9,40 +9,71 @@ namespace BTCPayServer.Services.Apps
|
||||
public PointOfSaleSettings()
|
||||
{
|
||||
Title = "Tea shop";
|
||||
Template =
|
||||
"green tea:\n" +
|
||||
" price: 1\n" +
|
||||
" title: Green Tea\n" +
|
||||
" description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.\n" +
|
||||
" image: ~/img/pos-sample/green-tea.jpg\n\n" +
|
||||
"black tea:\n" +
|
||||
" price: 1\n" +
|
||||
" title: Black Tea\n" +
|
||||
" description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.\n" +
|
||||
" image: ~/img/pos-sample/black-tea.jpg\n\n" +
|
||||
"rooibos:\n" +
|
||||
" price: 1.2\n" +
|
||||
" title: Rooibos\n" +
|
||||
" description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.\n" +
|
||||
" image: ~/img/pos-sample/rooibos.jpg\n\n" +
|
||||
"pu erh:\n" +
|
||||
" price: 2\n" +
|
||||
" title: Pu Erh\n" +
|
||||
" description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.\n" +
|
||||
" image: ~/img/pos-sample/pu-erh.jpg\n\n" +
|
||||
"herbal tea:\n" +
|
||||
" price: 1.8\n" +
|
||||
" title: Herbal Tea\n" +
|
||||
" description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!\n" +
|
||||
" image: ~/img/pos-sample/herbal-tea.jpg\n" +
|
||||
" custom: true\n\n" +
|
||||
"fruit tea:\n" +
|
||||
" price: 1.5\n" +
|
||||
" title: Fruit Tea\n" +
|
||||
" description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" +
|
||||
" image: ~/img/pos-sample/fruit-tea.jpg\n" +
|
||||
" inventory: 5\n" +
|
||||
" custom: true";
|
||||
Template = AppService.SerializeTemplate(new ViewPointOfSaleViewModel.Item[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "green-tea",
|
||||
Title = "Green Tea",
|
||||
Description =
|
||||
"Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
|
||||
Image = "~/img/pos-sample/green-tea.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
|
||||
Price = 1
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "black-tea",
|
||||
Title = "Black Tea",
|
||||
Description =
|
||||
"Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
|
||||
Image = "~/img/pos-sample/black-tea.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
|
||||
Price = 1
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "rooibos",
|
||||
Title = "Rooibos",
|
||||
Description =
|
||||
"Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.",
|
||||
Image = "~/img/pos-sample/rooibos.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
|
||||
Price = 1.2m
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "pu-erh",
|
||||
Title = "Pu Erh",
|
||||
Description =
|
||||
"This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.",
|
||||
Image = "~/img/pos-sample/pu-erh.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
|
||||
Price = 2
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "herbal-tea",
|
||||
Title = "Herbal Tea",
|
||||
Description =
|
||||
"Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!",
|
||||
Image = "~/img/pos-sample/herbal-tea.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Minimum,
|
||||
Price = 1.8m,
|
||||
Disabled = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "fruit-tea",
|
||||
Title = "Fruit Tea",
|
||||
Description =
|
||||
"The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!",
|
||||
Image = "~/img/pos-sample/fruit-tea.jpg",
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup,
|
||||
Inventory = 5,
|
||||
Disabled = true
|
||||
}
|
||||
});
|
||||
DefaultView = PosViewType.Static;
|
||||
ShowCustomAmount = false;
|
||||
ShowDiscount = true;
|
||||
|
@ -408,6 +408,8 @@ namespace BTCPayServer.Services.Invoices
|
||||
// public bool Refundable { get; set; }
|
||||
public bool? RequiresRefundEmail { get; set; } = null;
|
||||
public string RefundMail { get; set; }
|
||||
|
||||
public string StoreSupportUrl { get; set; }
|
||||
[JsonProperty("redirectURL")]
|
||||
public string RedirectURLTemplate { get; set; }
|
||||
|
||||
|
@ -430,11 +430,6 @@ namespace BTCPayServer.Services.Invoices
|
||||
{
|
||||
using var context = _applicationDbContextFactory.CreateContext();
|
||||
var items = context.Invoices.Where(a => invoiceIds.Contains(a.Id));
|
||||
if (items == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (InvoiceData invoice in items)
|
||||
{
|
||||
invoice.Archived = archive;
|
||||
|
@ -58,5 +58,5 @@ public class PosAppCartItemPrice
|
||||
public decimal Value { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "type")]
|
||||
public ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType Type { get; set; }
|
||||
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
|
||||
}
|
||||
|
@ -37,5 +37,6 @@ namespace BTCPayServer.Services
|
||||
public bool FileSystemStorageAsDefault { get; set; }
|
||||
public bool FixSeqAfterSqliteMigration { get; set; }
|
||||
public bool FixMappedDomainAppType { get; set; }
|
||||
public bool MigrateAppYmlToJson { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,12 @@ using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Npgsql;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
@ -365,6 +367,11 @@ namespace BTCPayServer.Services
|
||||
{
|
||||
SortWalletObjectLinks(ref a, ref b);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
await UpdateWalletObjectLink(a, b, data, ctx, true);
|
||||
}
|
||||
|
||||
private static async Task UpdateWalletObjectLink(WalletObjectId a, WalletObjectId b, JObject? data, ApplicationDbContext ctx, bool doNothingIfExists)
|
||||
{
|
||||
var l = new WalletObjectLinkData()
|
||||
{
|
||||
WalletId = a.WalletId.ToString(),
|
||||
@ -374,13 +381,34 @@ namespace BTCPayServer.Services
|
||||
BId = b.Id,
|
||||
Data = data?.ToString(Formatting.None)
|
||||
};
|
||||
ctx.WalletObjectLinks.Add(l);
|
||||
try
|
||||
if (!ctx.Database.IsNpgsql())
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
var e = ctx.WalletObjectLinks.Add(l);
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
{
|
||||
if (!doNothingIfExists)
|
||||
{
|
||||
e.State = EntityState.Modified;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
else
|
||||
{
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
var conflict = doNothingIfExists ? "ON CONFLICT DO NOTHING" : "ON CONFLICT ON CONSTRAINT \"PK_WalletObjectLinks\" DO UPDATE SET \"Data\"=EXCLUDED.\"Data\"";
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync("INSERT INTO \"WalletObjectLinks\" VALUES (@WalletId, @AType, @AId, @BType, @BId, @Data::JSONB) " + conflict, l);
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.ForeignKeyViolation)
|
||||
{
|
||||
throw new DbUpdateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -411,25 +439,7 @@ namespace BTCPayServer.Services
|
||||
|
||||
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var l = new WalletObjectLinkData()
|
||||
{
|
||||
WalletId = a.WalletId.ToString(),
|
||||
AType = a.Type,
|
||||
AId = a.Id,
|
||||
BType = b.Type,
|
||||
BId = b.Id,
|
||||
Data = data?.ToString(Formatting.None)
|
||||
};
|
||||
var e = ctx.WalletObjectLinks.Add(l);
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
{
|
||||
e.State = EntityState.Modified;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
await UpdateWalletObjectLink(a, b, data, ctx, false);
|
||||
}
|
||||
|
||||
public static int MaxCommentSize = 200;
|
||||
@ -558,30 +568,47 @@ namespace BTCPayServer.Services
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(id);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var o = NewWalletObjectData(id, data);
|
||||
ctx.WalletObjects.Add(o);
|
||||
try
|
||||
var wo = NewWalletObjectData(id, data);
|
||||
if (!ctx.Database.IsNpgsql())
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.WalletObjects.Add(wo);
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
{
|
||||
ctx.Entry(wo).State = EntityState.Modified;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
else
|
||||
{
|
||||
ctx.Entry(o).State = EntityState.Modified;
|
||||
await ctx.SaveChangesAsync();
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
await connection.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT ON CONSTRAINT \"PK_WalletObjects\" DO UPDATE SET \"Data\"=EXCLUDED.\"Data\"", wo);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnsureWalletObject(WalletObjectId id, JObject? data = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(id);
|
||||
var wo = NewWalletObjectData(id, data);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
ctx.WalletObjects.Add(NewWalletObjectData(id, data));
|
||||
try
|
||||
if (!ctx.Database.IsNpgsql())
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.WalletObjects.Add(wo);
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (DbUpdateException) // already exists
|
||||
else
|
||||
{
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
await connection.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT DO NOTHING", wo);
|
||||
}
|
||||
}
|
||||
#nullable restore
|
||||
|
@ -1,4 +1,6 @@
|
||||
@using BTCPayServer.BIP78.Sender
|
||||
@using BTCPayServer.Components.TruncateCenter
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@model BTCPayServer.Models.InvoicingModels.PaymentModel
|
||||
|
||||
<template id="bitcoin-method-checkout-template">
|
||||
@ -11,22 +13,16 @@
|
||||
<img class="qr-icon" :src="model.cryptoImage" :alt="model.paymentMethodName"/>
|
||||
</div>
|
||||
<div v-if="model.btcAddress" class="input-group mt-3">
|
||||
<div class="form-floating">
|
||||
<input id="Address_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="model.btcAddress">
|
||||
<label for="Address_@Model.PaymentMethodId" v-t="{ path: 'address', args: { paymentMethod: model.paymentMethodName }}"></label>
|
||||
<div class="form-floating" id="Address_@Model.PaymentMethodId">
|
||||
<vc:truncate-center text="model.btcAddress" is-vue="true" padding="15" elastic="true" classes="form-control-plaintext" />
|
||||
<label v-t="{ path: 'address', args: { paymentMethod: model.paymentMethodName }}"></label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link" data-clipboard-target="#Address_@Model.PaymentMethodId">
|
||||
<vc:icon symbol="copy" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="lightning" class="input-group mt-3">
|
||||
<div class="form-floating">
|
||||
<input id="Lightning_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="lightning" />
|
||||
<label for="Lightning_@Model.PaymentMethodId" v-t="'lightning'"></label>
|
||||
<div class="form-floating" id="Lightning_@Model.PaymentMethodId">
|
||||
<vc:truncate-center text="lightning" is-vue="true" padding="15" elastic="true" classes="form-control-plaintext" />
|
||||
<label v-t="'lightning'"></label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link" data-clipboard-target="#Lightning_@Model.PaymentMethodId">
|
||||
<vc:icon symbol="copy" />
|
||||
</button>
|
||||
</div>
|
||||
<a v-if="model.invoiceBitcoinUrl && model.showPayInWalletButton" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" id="PayInWallet"
|
||||
:href="model.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')" v-t="'pay_in_wallet'"></a>
|
||||
|
@ -96,7 +96,7 @@
|
||||
}
|
||||
<td class="text-end">@payment.Confirmations</td>
|
||||
<td class="payment-value text-end text-nowrap">
|
||||
@DisplayFormatter.Currency(payment.CryptoPaymentData.GetValue(), payment.Crypto)
|
||||
<span data-sensitive>@DisplayFormatter.Currency(payment.CryptoPaymentData.GetValue(), payment.Crypto)</span>
|
||||
@if (!string.IsNullOrEmpty(payment.AdditionalInformation))
|
||||
{
|
||||
<div>(@payment.AdditionalInformation)</div>
|
||||
|
@ -33,15 +33,15 @@
|
||||
<span>@item.Price.Value</span>
|
||||
<span>@vm.TargetCurrency</span>
|
||||
|
||||
if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum)
|
||||
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
|
||||
{
|
||||
@Safe.Raw("or more")
|
||||
}
|
||||
}
|
||||
else if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed )
|
||||
else if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed )
|
||||
{
|
||||
@Safe.Raw("Any amount")
|
||||
}else if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)
|
||||
}else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)
|
||||
{
|
||||
@Safe.Raw("Free")
|
||||
}
|
||||
@ -56,7 +56,7 @@
|
||||
{
|
||||
case null:
|
||||
break;
|
||||
case var i when i <= 0:
|
||||
case <= 0:
|
||||
<span>Sold out</span>
|
||||
break;
|
||||
default:
|
||||
|
28
BTCPayServer/Views/Shared/Forms/TextareaElement.cshtml
Normal file
28
BTCPayServer/Views/Shared/Forms/TextareaElement.cshtml
Normal file
@ -0,0 +1,28 @@
|
||||
@model BTCPayServer.Abstractions.Form.Field
|
||||
@{
|
||||
var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
|
||||
var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
|
||||
}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
|
||||
@Model.Label
|
||||
</label>
|
||||
<textarea id="@Model.Name" class="form-control @(errors is null ? "" : "is-invalid")"
|
||||
name="@Model.Name" data-val="true"
|
||||
@if (!string.IsNullOrEmpty(Model.HelpText))
|
||||
{
|
||||
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
|
||||
}
|
||||
@if (Model.Required)
|
||||
{
|
||||
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
|
||||
}
|
||||
>@Model.Value</textarea>
|
||||
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
|
||||
@if (!string.IsNullOrEmpty(Model.HelpText))
|
||||
{
|
||||
<div id="@($"HelpText-{Model.Name}")" class="form-text">
|
||||
@Safe.Raw(Model.HelpText)
|
||||
</div>
|
||||
}
|
||||
</div>
|
@ -25,6 +25,7 @@ else
|
||||
{
|
||||
<link href="~/main/themes/default.css" asp-append-version="true" rel="stylesheet" />
|
||||
<link href="~/main/themes/default-dark.css" asp-append-version="true" rel="stylesheet" id="DarkThemeLinkTag" />
|
||||
<script>if (window.localStorage.getItem('btcpay-hide-sensitive-info') === 'true') { document.documentElement.setAttribute('data-hide-sensitive-info', 'true')}</script>
|
||||
<script src="~/js/theme-switch.js" asp-append-version="true"></script>
|
||||
<noscript><style>.btcpay-theme-switch { display: none !important; }</style></noscript>
|
||||
}
|
||||
|
@ -10,16 +10,14 @@
|
||||
<img class="qr-icon" :src="model.cryptoImage" :alt="model.paymentMethodName"/>
|
||||
</div>
|
||||
<div v-if="model.btcAddress" class="input-group mt-3">
|
||||
<div class="form-floating">
|
||||
<input id="Lightning_@Model.PaymentMethodId" class="form-control-plaintext" readonly="readonly" :value="model.btcAddress">
|
||||
<label for="Lightning_@Model.PaymentMethodId" v-t="'lightning'"></label>
|
||||
<div class="form-floating" id="Lightning_@Model.PaymentMethodId">
|
||||
<vc:truncate-center text="model.btcAddress" is-vue="true" padding="15" elastic="true" classes="form-control-plaintext" />
|
||||
<label v-t="'lightning'"></label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link" data-clipboard-target="#Lightning_@Model.PaymentMethodId">
|
||||
<vc:icon symbol="copy" />
|
||||
</button>
|
||||
</div>
|
||||
<a v-if="model.invoiceBitcoinUrl && model.showPayInWalletButton" class="btn btn-primary rounded-pill w-100 mt-4" target="_top" id="PayInWallet"
|
||||
:href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a>
|
||||
<div v-if="!model.invoiceBitcoinUrl && !model.btcAddress" class="alert alert-danger">This payment method is not available when using an insecure connection. Please use HTTPS or Tor.</div>
|
||||
@await Component.InvokeAsync("UiExtensionPoint", new {location = "checkout-v2-lightning-post-content", model = Model})
|
||||
</div>
|
||||
</template>
|
||||
|
@ -2,6 +2,7 @@
|
||||
<script type="text/x-template" id="lightning-method-checkout-template">
|
||||
<div>
|
||||
<div class="bp-view payment scan" id="scan" v-bind:class="{ 'active': currentTab == 'scan'}">
|
||||
<div v-if="!srvModel.invoiceBitcoinUrl && !srvModel.btcAddress" class="alert alert-danger">This payment method is not available when using an insecure connection. Please use HTTPS or Tor.</div>
|
||||
<div class="wrapBtnGroup" v-bind:class="{ invisible: !scanDisplayQr }">
|
||||
<div class="btnGroupLnd"
|
||||
v-if="srvModel.peerInfo" >
|
||||
@ -33,29 +34,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-view payment manual-flow" id="copy" v-bind:class="{ 'active': currentTab == 'copy'}">
|
||||
<div class="manual__step-two__instructions">
|
||||
<span v-html="$t('CompletePay_Body', srvModel)"></span>
|
||||
</div>
|
||||
<div class="copyLabelPopup">
|
||||
<span>{{$t("Copied")}}</span>
|
||||
</div>
|
||||
<nav class="copyBox">
|
||||
<div class="copySectionBox bottomBorder">
|
||||
<label>{{$t(srvModel.paymentMethodId.endsWith('LNURLPAY')? "LNURL": "BOLT 11 Invoice")}}</label>
|
||||
<div class="inputWithIcon _copyInput">
|
||||
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.btcAddress" readonly="readonly"/>
|
||||
<img v-bind:src="srvModel.cryptoImage"/>
|
||||
</div>
|
||||
<div v-if="!srvModel.invoiceBitcoinUrl && !srvModel.btcAddress" class="alert alert-danger">This payment method is not available when using an insecure connection. Please use HTTPS or Tor.</div>
|
||||
<template v-else>
|
||||
<div class="manual__step-two__instructions">
|
||||
<span v-html="$t('CompletePay_Body', srvModel)"></span>
|
||||
</div>
|
||||
<div class="separatorGem" v-if="srvModel.peerInfo" ></div>
|
||||
<div class="copySectionBox" v-if="srvModel.peerInfo">
|
||||
<label>{{$t("Node Info")}}</label>
|
||||
<div class="inputWithIcon _copyInput">
|
||||
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.peerInfo" readonly="readonly"/>
|
||||
<img v-bind:src="srvModel.cryptoImage"/>
|
||||
</div>
|
||||
<div class="copyLabelPopup">
|
||||
<span>{{$t("Copied")}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="copyBox">
|
||||
<div class="copySectionBox bottomBorder">
|
||||
<label>{{$t(srvModel.paymentMethodId.endsWith('LNURLPAY')? "LNURL": "BOLT 11 Invoice")}}</label>
|
||||
<div class="inputWithIcon _copyInput">
|
||||
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.btcAddress" readonly="readonly"/>
|
||||
<img v-bind:src="srvModel.cryptoImage"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="separatorGem" v-if="srvModel.peerInfo" ></div>
|
||||
<div class="copySectionBox" v-if="srvModel.peerInfo">
|
||||
<label>{{$t("Node Info")}}</label>
|
||||
<div class="inputWithIcon _copyInput">
|
||||
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.peerInfo" readonly="readonly"/>
|
||||
<img v-bind:src="srvModel.cryptoImage"/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
@await Component.InvokeAsync("UiExtensionPoint" , new { location="checkout-lightning-post-content", model = Model})
|
||||
</div>
|
||||
|
@ -52,7 +52,7 @@
|
||||
<vc:truncate-center text="@payment.PaymentProof" classes="truncate-center-id" />
|
||||
</td>
|
||||
<td class="payment-value text-end text-nowrap">
|
||||
@payment.Amount
|
||||
<span data-sensitive>@payment.Amount</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@{
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
var customTipPercentages = Model.CustomTipPercentages;
|
||||
@ -253,11 +255,12 @@
|
||||
|
||||
<span class="text-muted small">
|
||||
@{
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
buttonText = buttonText.Replace("{0}",item.Price.Formatted)
|
||||
?.Replace("{Price}",item.Price.Formatted);
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
buttonText = buttonText.Replace("{0}",formatted)
|
||||
?.Replace("{Price}",formatted);
|
||||
}
|
||||
}
|
||||
@Safe.Raw(buttonText)
|
||||
|
@ -1,11 +1,12 @@
|
||||
@using BTCPayServer.Payments.Lightning
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@using BTCPayServer.Services.Stores
|
||||
@using LNURL
|
||||
@inject BTCPayNetworkProvider BTCPayNetworkProvider
|
||||
@inject StoreRepository StoreRepository
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@{
|
||||
var store = await StoreRepository.FindStore(Model.StoreId);
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
@ -68,11 +69,7 @@ else
|
||||
Description = "Create invoice to pay custom amount",
|
||||
Title = "Custom amount",
|
||||
BuyButtonText = Model.CustomButtonText,
|
||||
Price = new ViewPointOfSaleViewModel.Item.ItemPrice()
|
||||
{
|
||||
Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup,
|
||||
|
||||
}
|
||||
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup
|
||||
}
|
||||
}).ToArray();
|
||||
}
|
||||
@ -90,16 +87,19 @@ else
|
||||
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
|
||||
}
|
||||
<div class="w-100 mb-3 fs-5 text-center">
|
||||
@switch (item.Price.Type)
|
||||
@{
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
}
|
||||
@switch (item.PriceType)
|
||||
{
|
||||
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup:
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
|
||||
<span>Any amount</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum:
|
||||
<span>@item.Price.Formatted minimum</span>
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
|
||||
<span>@formatted minimum</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed:
|
||||
@item.Price.Formatted
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
|
||||
@formatted
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
|
@ -1,5 +1,7 @@
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@{
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
|
||||
@ -17,8 +19,9 @@
|
||||
@for (var x = 0; x < Model.Items.Length; x++)
|
||||
{
|
||||
var item = Model.Items[x];
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
buttonText = buttonText.Replace("{0}", item.Price.Formatted).Replace("{Price}", item.Price.Formatted);
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
|
||||
|
||||
<div class="card px-0" data-id="@x">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Image))
|
||||
@ -29,11 +32,11 @@
|
||||
<div class="card-footer bg-transparent border-0 pb-3">
|
||||
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
|
||||
{
|
||||
@if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
|
||||
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy autocomplete="off">
|
||||
<input type="hidden" name="choiceKey" value="@item.Id" />
|
||||
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.Price.Type, item.Price.Value, item.Price.Value);}
|
||||
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.PriceType, item.Price.Value, item.Price.Value);}
|
||||
</form>
|
||||
}
|
||||
else
|
||||
@ -72,7 +75,7 @@
|
||||
@{CardBody("Custom Amount", "Create invoice to pay custom amount");}
|
||||
<div class="card-footer bg-transparent border-0 pb-3">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
|
||||
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);}
|
||||
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.ItemPriceType.Minimum);}
|
||||
</form>
|
||||
@if (anyInventoryItems)
|
||||
{
|
||||
@ -91,9 +94,9 @@
|
||||
</div>
|
||||
|
||||
@functions {
|
||||
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
|
||||
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
|
||||
{
|
||||
if (itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed && priceValue == 0)
|
||||
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
|
||||
{
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" readonly value="Free"/>
|
||||
@ -105,7 +108,7 @@
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">@Model.CurrencySymbol</span>
|
||||
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
|
||||
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)">
|
||||
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)">
|
||||
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
|
||||
</div>
|
||||
}
|
||||
|
@ -1,47 +1,79 @@
|
||||
@using System.Text.RegularExpressions
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@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);
|
||||
return Uri.TryCreate(source, UriKind.Absolute, out var uriResult) &&
|
||||
(uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@if (Model.Items.Count > 0)
|
||||
{
|
||||
<table class="table my-0" v-pre>
|
||||
@foreach (var (key, value) in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
@if (value is string str)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
<table class="table my-0" v-pre>
|
||||
@foreach (var (key, value) in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
@if (value is string str)
|
||||
{
|
||||
<th class="w-150px">@key</th>
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
<th class="w-150px">@key</th>
|
||||
}
|
||||
<td style="white-space:pre-wrap">
|
||||
@if (IsValidURL(str))
|
||||
{
|
||||
<a href="@str" target="_blank" rel="noreferrer noopener">@str</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@str.Trim()
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td>
|
||||
@if (IsValidURL(str))
|
||||
{
|
||||
<a href="@str" target="_blank" rel="noreferrer noopener">@str</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@value?.ToString()
|
||||
}
|
||||
</td>
|
||||
}
|
||||
else if (value is Dictionary<string, object> {Count: > 0 } subItems)
|
||||
{
|
||||
<td colspan="2" >
|
||||
@{
|
||||
Write(Html.Raw($"<h{Model.Level + 3} class=\"mt-4 mb-3\">"));
|
||||
Write(key);
|
||||
Write(Html.Raw($"</h{Model.Level + 3}>"));
|
||||
}
|
||||
<partial name="PosData" model="@((subItems, Model.Level + 1))"/>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
else if (value is Dictionary<string, object> {Count: > 0 } subItems)
|
||||
{
|
||||
<td colspan="2">
|
||||
@{
|
||||
@if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
Write(Html.Raw($"<h{Model.Level + 3} class=\"mt-4 mb-3\">"));
|
||||
Write(key);
|
||||
Write(Html.Raw($"</h{Model.Level + 3}>"));
|
||||
}
|
||||
}
|
||||
<partial name="PosData" model="@((subItems, Model.Level + 1))" />
|
||||
</td>
|
||||
}
|
||||
else if (value is IEnumerable<object> valueArray)
|
||||
{
|
||||
<td colspan="2">
|
||||
@{
|
||||
@if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
Write(Html.Raw($"<h{Model.Level + 3} class=\"mt-4 mb-3\">"));
|
||||
Write(key);
|
||||
Write(Html.Raw($"</h{Model.Level + 3}>"));
|
||||
}
|
||||
}
|
||||
@foreach (var item in valueArray)
|
||||
{
|
||||
@if (item is Dictionary<string, object> {Count: > 0 } subItems2)
|
||||
{
|
||||
<partial name="PosData" model="@((subItems2, Model.Level + 1))" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<partial name="PosData" model="@((new Dictionary<string, object>() {{"", item}}, Model.Level + 1))" />
|
||||
}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
}
|
||||
|
@ -61,11 +61,11 @@
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Price</label>
|
||||
<select class="form-select" v-model="editingItem.custom">
|
||||
<select class="form-select" v-model="editingItem.priceType">
|
||||
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6" v-show="editingItem.custom !== 'topup'">
|
||||
<div class="col-sm-6" v-show="editingItem.priceType !== 'Topup'">
|
||||
<label class="form-label"> </label>
|
||||
<div class="input-group mb-2">
|
||||
<input class="form-control"
|
||||
@ -129,9 +129,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
items: [],
|
||||
editingItem: null,
|
||||
customPriceOptions: [
|
||||
{ text: 'Fixed', value: "fixed" },
|
||||
{ text: 'Minimum', value: "minimum" },
|
||||
{ text: 'Custom', value: 'topup' },
|
||||
{ text: 'Fixed', value: "Fixed" },
|
||||
{ text: 'Minimum', value: "Minimum" },
|
||||
{ text: 'Custom', value: 'Topup' },
|
||||
],
|
||||
elementId: "@Model.templateId"
|
||||
},
|
||||
@ -141,8 +141,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
},
|
||||
mounted: function() {
|
||||
this.loadYml();
|
||||
this.getInputElement().on("input change", this.loadYml.bind(this));
|
||||
this.load();
|
||||
this.getInputElement().on("input change", this.load.bind(this));
|
||||
this.getModalElement().on("hide.bs.modal", this.clearEditingItem.bind(this));
|
||||
},
|
||||
methods: {
|
||||
@ -156,148 +156,22 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
},
|
||||
getInputElement : function(){ return $("#" + this.elementId); },
|
||||
getModalElement : function(){ return $("#product-modal"); },
|
||||
loadYml: function(){
|
||||
var result = [];
|
||||
var template = this.getInputElement().val().trim();
|
||||
var lines = [];
|
||||
var items = template.split("\n");
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i] === ""){
|
||||
continue;
|
||||
}
|
||||
if (items[i].startsWith(" ")){
|
||||
lines[lines.length-1]+=items[i] + "\n";
|
||||
} else {
|
||||
lines.push(items[i] + "\n");
|
||||
}
|
||||
load: function(){
|
||||
const template = this.getInputElement().val().trim();
|
||||
if (!template){
|
||||
this.items = [];
|
||||
} else {
|
||||
this.items = JSON.parse(template);
|
||||
}
|
||||
|
||||
// Split products from the template
|
||||
for (var kl in lines) {
|
||||
var line = lines[kl], product = line.split("\n"), goingThroughMethods = false,
|
||||
id = null, price = null, title = null, description = null, image = null,
|
||||
custom = null, buyButtonText = null, inventory = null, paymentMethods = [],
|
||||
disabled = false;
|
||||
|
||||
for (var kp in product) {
|
||||
var productProperty = product[kp].trim();
|
||||
|
||||
if (kp == 0) {
|
||||
id = productProperty.replace(":", "");
|
||||
}
|
||||
if (productProperty.startsWith("-") && goingThroughMethods) {
|
||||
paymentMethods.push(productProperty.substr(1));
|
||||
} else {
|
||||
goingThroughMethods = false;
|
||||
}
|
||||
|
||||
if (productProperty.indexOf('price:') !== -1) {
|
||||
price = parseFloat(productProperty.replace('price:', '').trim()).noExponents();
|
||||
}
|
||||
if (productProperty.indexOf('title:') !== -1) {
|
||||
title = productProperty.replace('title:', '').trim();
|
||||
}
|
||||
if (productProperty.indexOf('description:') !== -1) {
|
||||
description =productProperty.replace('description:', '').trim();
|
||||
if (description.startsWith('"') && description.endsWith('"')){
|
||||
description = description.substr(1, description.length-2);
|
||||
}
|
||||
description = description
|
||||
.replaceAll("<br>", "\n")
|
||||
.replaceAll("<br/>", "\n")
|
||||
.replaceAll('\\"', '"')
|
||||
}
|
||||
if (productProperty.indexOf('image:') !== -1) {
|
||||
image = productProperty.replace('image:', '').trim();
|
||||
}
|
||||
if (productProperty.indexOf('price_type:') !== -1) {
|
||||
custom = productProperty.replace('price_type:', '').trim();
|
||||
}
|
||||
if (productProperty.indexOf('buyButtonText:') !== -1) {
|
||||
buyButtonText = productProperty.replace('buyButtonText:', '').trim();
|
||||
}
|
||||
if (productProperty.indexOf('inventory:') !== -1) {
|
||||
inventory = parseInt(productProperty.replace('inventory:', '').trim(),10);
|
||||
}
|
||||
if (productProperty.indexOf('payment_methods:') !== -1) {
|
||||
goingThroughMethods = true;
|
||||
}
|
||||
if (productProperty.indexOf('disabled:') !== -1) {
|
||||
disabled = productProperty.replace('disabled:', '').trim() === "true";
|
||||
}
|
||||
}
|
||||
|
||||
if (title != null) {
|
||||
// Add product to the list
|
||||
result.push({
|
||||
id: id,
|
||||
title: title,
|
||||
price: price,
|
||||
image: image || null,
|
||||
description: description || '',
|
||||
custom: custom || "fixed",
|
||||
buyButtonText: buyButtonText,
|
||||
inventory: isNaN(inventory)? null: inventory,
|
||||
paymentMethods: paymentMethods,
|
||||
disabled: disabled
|
||||
});
|
||||
}
|
||||
}
|
||||
this.items = result;
|
||||
},
|
||||
toYml: function(){
|
||||
let template = '';
|
||||
// Construct template from the product list
|
||||
for (const key in this.items) {
|
||||
const product = this.items[key],
|
||||
id = product.id,
|
||||
title = product.title,
|
||||
price = product.custom === 'topup'? null : product.price??0,
|
||||
image = product.image,
|
||||
description = product.description,
|
||||
custom = product.custom,
|
||||
buyButtonText = product.buyButtonText,
|
||||
inventory = product.inventory,
|
||||
paymentMethods = product.paymentMethods,
|
||||
disabled = product.disabled;
|
||||
let itemTemplate = id+":\n";
|
||||
itemTemplate += ( product.custom === 'topup'? '' : (' price: ' + parseFloat(price).noExponents() + '\n'));
|
||||
itemTemplate+= ' title: ' + title + '\n';
|
||||
|
||||
if (description) {
|
||||
itemTemplate += ' description: "' + description.replaceAll("\n", "<br/>").replaceAll('"', '\\"') + '"\n';
|
||||
}
|
||||
if (image) {
|
||||
itemTemplate += ' image: ' + image + '\n';
|
||||
}
|
||||
if (inventory) {
|
||||
itemTemplate += ' inventory: ' + inventory + '\n';
|
||||
}
|
||||
if (custom != null) {
|
||||
itemTemplate += ' price_type: "' + custom + '" \n';
|
||||
}
|
||||
if (buyButtonText != null && buyButtonText.length > 0) {
|
||||
itemTemplate += ' buyButtonText: ' + buyButtonText + '\n';
|
||||
}
|
||||
if (disabled != null) {
|
||||
itemTemplate += ' disabled: ' + disabled.toString() + '\n';
|
||||
}
|
||||
if(paymentMethods != null && paymentMethods.length > 0){
|
||||
itemTemplate+= ' payment_methods:\n';
|
||||
for (var method of paymentMethods){
|
||||
itemTemplate+= ' - '+method+'\n';
|
||||
}
|
||||
}
|
||||
itemTemplate += '\n';
|
||||
template+=itemTemplate;
|
||||
}
|
||||
this.getInputElement().val(template);
|
||||
save: function(){
|
||||
let template = JSON.stringify(this.items);
|
||||
this.getInputElement().val(template);
|
||||
},
|
||||
editItem: function(index){
|
||||
this.errors = [];
|
||||
if(index < 0){
|
||||
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", custom: "fixed", inventory: null, paymentMethods: [], disabled: false};
|
||||
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", priceType: "Fixed", inventory: null, disabled: false};
|
||||
}else{
|
||||
this.editingItem = {...this.items[index], index};
|
||||
}
|
||||
@ -307,7 +181,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
},
|
||||
removeItem: function(index){
|
||||
this.items.splice(index,1);
|
||||
this.toYml();
|
||||
this.save();
|
||||
},
|
||||
clearEditingItem: function(){
|
||||
this.editingItem = null;
|
||||
@ -343,7 +217,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
this.errors.push("Image cannot start with \"- \"");
|
||||
}
|
||||
|
||||
if (this.editingItem.custom !== "topup" && !this.$refs.txtPrice.checkValidity()) {
|
||||
if (this.editingItem["priceType"] !== "Topup" && !this.$refs.txtPrice.checkValidity()) {
|
||||
this.errors.push("Price must be a valid number");
|
||||
}
|
||||
if (!this.$refs.txtTitle.checkValidity()) {
|
||||
@ -375,7 +249,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}else{
|
||||
this.items.splice(this.editingItem.index,1,this.editingItem);
|
||||
}
|
||||
this.toYml();
|
||||
this.save();
|
||||
this.getModalElement().modal("hide");
|
||||
},
|
||||
escape: function(item) {
|
||||
|
@ -1,45 +1,171 @@
|
||||
@using BTCPayServer.Forms
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using Newtonsoft.Json
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model BTCPayServer.Forms.ModifyForm
|
||||
|
||||
@{
|
||||
Csp.UnsafeEval();
|
||||
var formId = Context.GetRouteValue("id");
|
||||
var isNew = formId is null;
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData["NavPartialName"] = "../UIStores/_Nav";
|
||||
ViewData.SetActivePage(StoreNavPages.Forms, $"{(isNew ? "Create" : "Edit")} Form", Model.Name);
|
||||
|
||||
var storeId = Context.GetCurrentStoreId();
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial"/>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const $config = document.getElementById("FormConfig");
|
||||
delegate("click", "[data-form-template]", e => {
|
||||
const { formTemplate: id } = e.target.dataset
|
||||
const $template = document.getElementById(`form-template-${id}`)
|
||||
$config.value = $template.innerHTML.trim()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@section PageHeadCOntent {
|
||||
<style>
|
||||
#FormEditor .nav-link { background: none; padding: 0; font-weight: var(--btcpay-font-weight-semibold); font-size: 1.125rem; }
|
||||
#FormEditor .nav-link.active { color: var(--btcpay-primary); }
|
||||
#FormEditor .list-group-item:not(.active) { background: none; }
|
||||
#FormEditor .list-group-item { border: none !important; margin-top: 0 !important; padding: var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-s) !important; }
|
||||
#FormEditor fieldset .list-group-item { padding: var(--btcpay-space-m) 0 var(--btcpay-space-m) !important; }
|
||||
#FormEditor .control { color: var(--btcpay-body-text-muted); border: none !important; padding: 0 var(--btcpay-space-xs); }
|
||||
#FormEditor .control.drag[disabled] { visibility: hidden; }
|
||||
#FormEditor .control.drag:hover { color: var(--btcpay-primary); }
|
||||
#FormEditor .control.remove:hover { color: var(--btcpay-danger); }
|
||||
#FormEditor .field .form-group:last-child { margin-bottom: 0; }
|
||||
#FormEditor .nested-fields { margin: 0 -3.1rem 0 -.5rem; }
|
||||
#FormEditor .nested-fields .list-group-item { padding-right: 1rem !important; }
|
||||
</style>
|
||||
}
|
||||
|
||||
<template id="form-template-email">
|
||||
@FormDataService.StaticFormEmail
|
||||
</template>
|
||||
<template id="form-template-address">
|
||||
@FormDataService.StaticFormAddress
|
||||
</template>
|
||||
@section PageFootContent {
|
||||
<template id="form-template-email">
|
||||
@FormDataService.StaticFormEmail
|
||||
</template>
|
||||
<template id="form-template-address">
|
||||
@FormDataService.StaticFormAddress
|
||||
</template>
|
||||
<template id="field-editor">
|
||||
<div class="field" v-if="field">
|
||||
<div class="form-group">
|
||||
<label for="field-editor-field-type" class="form-label" data-required>Type</label>
|
||||
<select id="field-editor-field-type" class="form-select" required v-model="field.type">
|
||||
<option v-for="option in fieldTypeOptions" :key="option" :value="option" v-text="option.charAt(0).toUpperCase() + option.slice(1)"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="field-editor-field-label" class="form-label" data-required>Label</label>
|
||||
<input id="field-editor-field-label" class="form-control" required v-model="field.label" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="field-editor-field-name" class="form-label" data-required>Name</label>
|
||||
<input id="field-editor-field-name" class="form-control" required v-model="field.name" />
|
||||
<div class="form-text">The name of the field in the invoice's metadata</div>
|
||||
</div>
|
||||
<div class="form-group" v-if="field.type === 'select'">
|
||||
<h5 class="mt-2">Options</h5>
|
||||
<div class="options" v-sortable="{ handle: '.drag', onUpdate: sortOptions }">
|
||||
<div v-for="(option, index) in field.options" :key="option.value" class="d-flex align-items-start gap-2 pt-3">
|
||||
<button type="button" class="btn b-0 control drag">
|
||||
<vc:icon symbol="drag" />
|
||||
</button>
|
||||
<div class="field flex-grow-1">
|
||||
<label :for="`field-option-value-${index}`" class="form-label">Value</label>
|
||||
<input :for="`field-option-value-${index}`" class="form-control" v-model="option.value" />
|
||||
</div>
|
||||
<div class="field flex-grow-1">
|
||||
<label :for="`field-option-text-${index}`" class="form-label">Text</label>
|
||||
<input :for="`field-option-text-${index}`" class="form-control" v-model="option.text" />
|
||||
</div>
|
||||
<button type="button" class="btn b-0 control remove" v-on:click="removeOption($event, index)">
|
||||
<vc:icon symbol="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addOption($event)">
|
||||
<vc:icon symbol="new" />
|
||||
Add Option
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group" v-if="field.type !== 'fieldset'">
|
||||
<label for="field-editor-field-value" class="form-label">Default Value</label>
|
||||
<input id="field-editor-field-value" class="form-control" v-model="field.value" />
|
||||
</div>
|
||||
<div class="form-group" v-if="field.type !== 'fieldset'">
|
||||
<label for="field-editor-field-helpText" class="form-label">Helper Text</label>
|
||||
<input id="field-editor-field-helpText" class="form-control" v-model="field.helpText" />
|
||||
<div class="form-text">Additional text to provide an explanation for the field</div>
|
||||
</div>
|
||||
<div class="form-group form-check" v-if="field.type !== 'fieldset'">
|
||||
<input id="field-editor-field-required" type="checkbox" class="form-check-input" v-model="field.required" />
|
||||
<label for="field-editor-field-required" class="form-check-label">Required Field</label>
|
||||
</div>
|
||||
<div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'select'">
|
||||
<input id="field-editor-field-constant" type="checkbox" class="form-check-input" v-model="field.constant" />
|
||||
<label for="field-editor-field-constant" class="form-check-label">Constant</label>
|
||||
<div class="form-text">The user will not be able to change the field's value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>Select a field to edit</div>
|
||||
</template>
|
||||
<template id="fields-editor">
|
||||
<div>
|
||||
<div class="fields list-group" :class="{ 'list-group-flush': path.length }" :data-path="path.join(',')" v-sortable="{ handle: '.drag', onUpdate (event) { const { path } = this.el.dataset; $emit('sort-fields', event, (path.indexOf(',') !== -1 ? path.split(',') : [])) } }">
|
||||
<div v-for="(field, index) in fields" :key="field.name" class="d-flex align-items-start gap-2 list-group-item" :class="{ active: field === selectedField }" v-on:click.stop="$emit('select-field', $event, path, index)">
|
||||
<button type="button" class="btn b-0 control drag" :disabled="fields.length === 1">
|
||||
<vc:icon symbol="drag" />
|
||||
</button>
|
||||
<div class="field flex-grow-1">
|
||||
<component :is="getFieldComponent(field.type)" v-bind="field" :path="path.concat(field.name)" :selected-field="selectedField" v-on="$listeners" />
|
||||
</div>
|
||||
<button type="button" class="btn b-0 control remove" v-on:click="$emit('remove-field', $event, path, index)">
|
||||
<vc:icon symbol="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link py-0 px-2 mt-2 mb-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="$emit('add-field', $event, path)">
|
||||
<vc:icon symbol="new" />
|
||||
Add Form Element
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template id="field-type-input">
|
||||
<div class="form-group mb-0">
|
||||
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
|
||||
<input class="form-control" :id="name" :name="name" :type="type" v-model="value" />
|
||||
<div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-text="helpText"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="field-type-textarea">
|
||||
<div class="form-group mb-0">
|
||||
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
|
||||
<textarea class="form-control" :id="name" :name="name" v-model="value"></textarea>
|
||||
<div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-text="helpText"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="field-type-select">
|
||||
<div class="form-group mb-0">
|
||||
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
|
||||
<select class="form-select" :id="name" :name="name">
|
||||
<option v-for="option in options" :key="option.value" :value="option.value" :selected="option.value === value" v-text="option.text"></option>
|
||||
</select>
|
||||
<div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-text="helpText"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="field-type-fieldset">
|
||||
<fieldset>
|
||||
<legend class="h5 mt-1 mb-2" v-text="label"></legend>
|
||||
<fields-editor :path="path" :fields="fields" :selected-field="selectedField" v-on="$listeners" class="nested-fields" />
|
||||
</fieldset>
|
||||
</template>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vue-sortable/sortable.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vue-sortable/vue-sortable.js" asp-append-version="true"></script>
|
||||
<script src="~/js/form-editor.js" asp-append-version="true"></script>
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
|
||||
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||
<h3 class="mb-0">
|
||||
<span>@ViewData["Title"]</span>
|
||||
<a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="More information...">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
|
||||
@if (!isNew)
|
||||
@ -49,31 +175,63 @@
|
||||
</div>
|
||||
</div>
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" style="max-width: 27rem;">
|
||||
<label asp-for="Name" class="form-label" data-required></label>
|
||||
<input asp-for="Name" class="form-control" required/>
|
||||
<input asp-for="Name" class="form-control" required />
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-4 gap-3">
|
||||
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
|
||||
<div>
|
||||
<label asp-for="Public"></label>
|
||||
<div class="form-text" style="max-width:27rem">
|
||||
<div class="form-text">
|
||||
Standalone mode, which can be used to generate invoices
|
||||
independent of payment requests or apps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<label asp-for="FormConfig" class="form-label" data-required></label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span>Templates:</span>
|
||||
<button type="button" class="btn btn-link p-0" data-form-template="email">Email</button>
|
||||
<button type="button" class="btn btn-link p-0" data-form-template="address">Address</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="FormEditor">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
|
||||
<ul class="nav nav-pills gap-4" id="form-editor-tab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false">Code</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="fw-semibold">Templates</span>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('email')" id="ApplyEmailTemplate">Email</button>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('address')" id="ApplyAddressTemplate">Address</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-lg-7 mb-4 mb-lg-0">
|
||||
<fields-editor :path="[]"
|
||||
:fields="fields"
|
||||
:selected-field="selectedField"
|
||||
v-on:add-field="addField"
|
||||
v-on:sort-fields="sortFields"
|
||||
v-on:select-field="selectField"
|
||||
v-on:remove-field="removeField"
|
||||
class="bg-tile pb-2 rounded" />
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<field-editor :field="selectedField" class="bg-tile p-4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<textarea asp-for="FormConfig" class="form-control" rows="10" cols="21"></textarea>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<label asp-for="FormConfig" class="form-label" data-required>Form JSON</label>
|
||||
</div>
|
||||
<textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
|
||||
<span asp-validation-for="FormConfig" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -163,7 +163,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a v-if="srvModel.receiptLink" class="btn btn-primary rounded-pill w-100" :href="srvModel.receiptLink" :target="isModal ? '_top' : null" v-t="'view_receipt'" id="receipt-btn"></a>
|
||||
<a v-if="srvModel.receiptLink" class="btn btn-primary rounded-pill w-100" :href="srvModel.receiptLink" :target="isModal ? '_top' : null" v-t="'view_receipt'" id="ReceiptLink"></a>
|
||||
<a v-if="storeLink" class="btn btn-secondary rounded-pill w-100" :href="storeLink" :target="isModal ? '_top' : null" v-html="$t('return_to_store', { storeName: srvModel.storeName })" id="StoreLink"></a>
|
||||
<button v-else-if="isModal" class="btn btn-secondary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
|
||||
</div>
|
||||
@ -198,9 +198,10 @@
|
||||
<span class="fw-semibold" v-t="'view_details'"></span>
|
||||
<vc:icon symbol="caret-down" />
|
||||
</button>
|
||||
<p class="text-center mt-3" v-html="replaceNewlines($t('invoice_expired_body', { storeName: srvModel.storeName, minutes: @Model.MaxTimeMinutes }))"></p>
|
||||
<p class="text-center mt-3" v-html="replaceNewlines($t(isPaidPartial ? 'invoice_paidpartial_body' : 'invoice_expired_body', { storeName: srvModel.storeName, minutes: srvModel.maxTimeMinutes }))"></p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a v-if="isPaidPartial && srvModel.storeSupportUrl" class="btn btn-primary rounded-pill w-100" :href="srvModel.storeSupportUrl" v-t="'contact_us'" id="ContactLink"></a>
|
||||
<a v-if="storeLink" class="btn btn-primary rounded-pill w-100" :href="storeLink" :target="isModal ? '_top' : null" v-html="$t('return_to_store', { storeName: srvModel.storeName })" id="StoreLink"></a>
|
||||
<button v-else-if="isModal" class="btn btn-primary rounded-pill w-100" v-on:click="close" v-t="'Close'"></button>
|
||||
</div>
|
||||
|
@ -1,4 +1,3 @@
|
||||
@using BTCPayServer.Client.Models
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@ -68,6 +67,91 @@
|
||||
const response = await fetch(url, { method, body })
|
||||
await handleRefundResponse(response)
|
||||
})
|
||||
|
||||
function checkCustomAmount() {
|
||||
const $refundForm = document.getElementById('RefundForm');
|
||||
const currency = $refundForm.querySelector('#CustomCurrency').value;
|
||||
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
|
||||
const invoiceCurrency = $refundForm.querySelector('#InvoiceCurrency').value;
|
||||
const amount = parseFloat($refundForm.querySelector('#CustomAmount').value);
|
||||
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
|
||||
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
|
||||
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
|
||||
|
||||
let isOverpaying = false;
|
||||
if (currency === cryptoCode) {
|
||||
isOverpaying = amount > Math.max(cryptoAmountNow, cryptoAmountThen);
|
||||
} else if (currency === invoiceCurrency) {
|
||||
isOverpaying = amount > fiatAmount;
|
||||
}
|
||||
document.getElementById('CustomAmountWarning').hidden = !isOverpaying;
|
||||
}
|
||||
delegate('change', '#CustomAmount', checkCustomAmount);
|
||||
delegate('change', '#CustomCurrency', checkCustomAmount);
|
||||
|
||||
function updateSubtractPercentageResult() {
|
||||
const $refundForm = document.getElementById('RefundForm');
|
||||
const $result = document.getElementById('SubtractPercentageResult');
|
||||
const $selectedRefundOption = $refundForm.querySelector('[name="SelectedRefundOption"]:checked');
|
||||
if (!$selectedRefundOption) {
|
||||
$result.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const refundOption = $selectedRefundOption.value;
|
||||
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
|
||||
const customCurrency = $refundForm.querySelector('#CustomCurrency').value;
|
||||
const invoiceCurrency = $refundForm.querySelector('#InvoiceCurrency').value;
|
||||
const customAmount = parseFloat($refundForm.querySelector('#CustomAmount').value);
|
||||
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
|
||||
const overpaidAmount = parseFloat($refundForm.querySelector('#OverpaidAmount').value);
|
||||
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
|
||||
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
|
||||
const cryptoDivisibility = parseInt($refundForm.querySelector('#CryptoDivisibility').value);
|
||||
const invoiceDivisibility = parseInt($refundForm.querySelector('#InvoiceDivisibility').value);
|
||||
const percentage = parseFloat($refundForm.querySelector('#SubtractPercentage').value);
|
||||
const isInvalid = isNaN(percentage);
|
||||
|
||||
let amount = null;
|
||||
let currency = cryptoCode;
|
||||
let divisibility = cryptoDivisibility;
|
||||
switch (refundOption) {
|
||||
case 'RateThen':
|
||||
amount = cryptoAmountThen;
|
||||
break;
|
||||
case 'CurrentRate':
|
||||
amount = cryptoAmountNow;
|
||||
break;
|
||||
case 'OverpaidAmount':
|
||||
amount = overpaidAmount;
|
||||
break;
|
||||
case 'Fiat':
|
||||
amount = fiatAmount;
|
||||
currency = invoiceCurrency;
|
||||
divisibility = invoiceDivisibility;
|
||||
break;
|
||||
case 'Custom':
|
||||
amount = customAmount;
|
||||
currency = customCurrency;
|
||||
divisibility = customCurrency === invoiceCurrency ? invoiceDivisibility : cryptoDivisibility;
|
||||
break;
|
||||
}
|
||||
|
||||
if (amount == null || isInvalid) {
|
||||
$result.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log({ refundOption, isInvalid, amount, currency })
|
||||
const reduceByAmount = (amount * (percentage / 100));
|
||||
const refundAmount = (amount - reduceByAmount).toFixed(divisibility);
|
||||
$result.innerText = `= ${refundAmount} ${currency} refund`;
|
||||
$result.hidden = false;
|
||||
}
|
||||
delegate('change', '[name="SelectedRefundOption"]', updateSubtractPercentageResult);
|
||||
delegate('change', '#SubtractPercentage', updateSubtractPercentageResult);
|
||||
delegate('change', '#CustomCurrency', updateSubtractPercentageResult);
|
||||
delegate('change', '#CustomAmount', updateSubtractPercentageResult);
|
||||
</script>
|
||||
}
|
||||
|
||||
@ -227,7 +311,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="fw-semibold">Total Fiat Due</th>
|
||||
<td>@Model.Fiat</td>
|
||||
<td><span data-sensitive>@Model.Fiat</span></td>
|
||||
</tr>
|
||||
@if (!string.IsNullOrEmpty(Model.RefundEmail))
|
||||
{
|
||||
@ -401,7 +485,7 @@
|
||||
<section class="mt-4 d-print-none">
|
||||
<h3 class="mb-3">Webhooks</h3>
|
||||
<div class="table-responsive-xl">
|
||||
<table class="table table-hover table-responsive-md mb-5">
|
||||
<table class="table table-hover mb-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
@ -478,7 +562,7 @@
|
||||
<section class="mt-4">
|
||||
<h3 class="mb-3">Refunds</h3>
|
||||
<div class="table-responsive-xl">
|
||||
<table class="table table-hover table-responsive-md mb-5">
|
||||
<table class="table table-hover mb-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pull Payment</th>
|
||||
|
@ -5,9 +5,25 @@
|
||||
@model InvoicesModel
|
||||
@{
|
||||
ViewData.SetActivePage(InvoiceNavPages.Index, "Invoices");
|
||||
var storeIds = string.Join("", Model.StoreIds.Select(storeId => $",storeid:{storeId}"));
|
||||
if (this.Context.GetRouteValue("storeId") is string)
|
||||
storeIds = string.Empty;
|
||||
|
||||
var statusFilterCount = CountArrayFilter("status") + CountArrayFilter("exceptionstatus") + (HasBooleanFilter("includearchived") ? 1 : 0) + (HasBooleanFilter("unusual") ? 1 : 0);
|
||||
var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate");
|
||||
var appFilterCount = Model.Apps.Count(app => HasArrayFilter("orderid", app.AppOrderId));
|
||||
}
|
||||
|
||||
@functions
|
||||
{
|
||||
private int CountArrayFilter(string type) =>
|
||||
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
|
||||
|
||||
private bool HasArrayFilter(string type, string key = null) =>
|
||||
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
|
||||
|
||||
private bool HasBooleanFilter(string key) =>
|
||||
Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
|
||||
|
||||
private bool HasCustomDateFilter() =>
|
||||
Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
|
||||
}
|
||||
|
||||
@section PageHeadContent
|
||||
@ -16,6 +32,16 @@
|
||||
.invoice-payments {
|
||||
padding-left: var(--btcpay-space-l);
|
||||
}
|
||||
.dropdown > .btn {
|
||||
min-width: 7rem;
|
||||
padding-left: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
@@media (max-width: 568px) {
|
||||
#SearchText {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@ -59,7 +85,7 @@
|
||||
|
||||
var dtpStartDate = $("#dtpStartDate").val();
|
||||
if (dtpStartDate !== null && dtpStartDate !== "") {
|
||||
filterString = "startDate%3A" + dtpStartDate;
|
||||
filterString = "startdate%3A" + dtpStartDate;
|
||||
}
|
||||
|
||||
var dtpEndDate = $("#dtpEndDate").val();
|
||||
@ -67,7 +93,7 @@
|
||||
if (filterString !== "") {
|
||||
filterString += ",";
|
||||
}
|
||||
filterString += "endDate%3A" + dtpEndDate;
|
||||
filterString += "enddate%3A" + dtpEndDate;
|
||||
}
|
||||
|
||||
if (filterString !== "") {
|
||||
@ -143,6 +169,14 @@
|
||||
<div class="flex-fill">
|
||||
<p class="mb-2">Invoices are documents issued by the seller to a buyer to collect payment.</p>
|
||||
<p class="mb-3">An invoice must be paid within a defined time interval at a fixed exchange rate to protect the issuer from price fluctuations.</p>
|
||||
<p class="mb-3">
|
||||
You can also apply filters to your search by searching for <code>filtername:value</code>.
|
||||
Be sure to split your search parameters with comma. Supported filters are:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>orderid:id</code> for filtering a specific order</li>
|
||||
<li><code>itemcode:code</code> for filtering a specific type of item purchased through the pos or crowdfund apps</li>
|
||||
</ul>
|
||||
<a href="https://docs.btcpayserver.org/Invoices/" target="_blank" rel="noreferrer noopener">Learn More</a>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-toggle="collapse" data-bs-target="#descriptor" aria-expanded="false" aria-label="Close">
|
||||
@ -200,75 +234,103 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="help" class="row collapse">
|
||||
<div class="col-xl-8 pb-3">
|
||||
<p>
|
||||
You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.
|
||||
Be sure to split your search parameters with comma, for example:<br />
|
||||
<code>startdate:2019-04-25 13:00:00, status:paid</code>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
You can also apply filters to your search by searching for <code>filtername:value</code>, supported filters are:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>storeid:id</code> for filtering a specific store</li>
|
||||
<li><code>orderid:id</code> for filtering a specific order</li>
|
||||
<li><code>itemcode:code</code> for filtering a specific type of item purchased through the pos or crowdfund apps</li>
|
||||
<li><code>status:(expired|invalid|complete|confirmed|paid|new)</code> for filtering a specific status</li>
|
||||
<li><code>exceptionstatus:(paidover|paidlate|paidpartial)</code> for filtering a specific exception state</li>
|
||||
<li><code>unusual:(true|false)</code> for filtering invoices which might requires merchant attention (those invalid or with an exceptionstatus)</li>
|
||||
<li><code>startdate:yyyy-MM-dd HH:mm:ss</code> getting invoices that were created after certain date</li>
|
||||
<li><code>enddate:yyyy-MM-dd HH:mm:ss</code> getting invoices that were created before certain date</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<form class="@(Model.Invoices.Count > 0 ? "col-xl-7 col-xxl-8 " : "")mb-4" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
|
||||
<input type="hidden" asp-for="Count" />
|
||||
<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 @(Model.Invoices.Any() ? "col-xl-7 col-xxl-8" : null)" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
|
||||
<input asp-for="Count" type="hidden" />
|
||||
<input asp-for="TimezoneOffset" type="hidden" />
|
||||
<div class="input-group">
|
||||
<a href="#help" class="input-group-text text-secondary text-decoration-none" data-bs-toggle="collapse">
|
||||
<span class="fa fa-filter"></span>
|
||||
</a>
|
||||
<input asp-for="SearchTerm" class="form-control" />
|
||||
<button type="submit" class="btn btn-secondary" title="Search invoice">
|
||||
<span class="fa fa-search"></span> Search
|
||||
<input asp-for="SearchTerm" type="hidden" value="@Model.Search.WithoutSearchText()"/>
|
||||
<input asp-for="SearchText" class="form-control" placeholder="Search…" />
|
||||
<div class="dropdown">
|
||||
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@if (statusFilterCount > 0)
|
||||
{
|
||||
<span>@statusFilterCount Status</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>All Status</span>
|
||||
}
|
||||
</button>
|
||||
<button type="button" id="SearchOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-end" aria-labelledby="SearchOptionsToggle">
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="status:invalid@{@storeIds}">Invalid Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="status:processing,status:settled@{@storeIds}">Settled Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidLate@{@storeIds}">Settled Late Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidPartial@{@storeIds}">Settled Partial Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidOver@{@storeIds}">Settled Over Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="unusual:true@{@storeIds}">Unusual Invoices</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="includearchived:true@{@storeIds}" id="SearchOptionsIncludeArchived">Archived Invoices</a>
|
||||
<div role="separator" class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-24h@{@storeIds}">Last 24 hours</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-3d@{@storeIds}">Last 3 days</a>
|
||||
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-7d@{@storeIds}">Last 7 days</a>
|
||||
<button type="button" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#customRangeModal">Custom Range</button>
|
||||
<div role="separator" class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="?searchTerm=">Unfiltered</a>
|
||||
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "invalid")" class="dropdown-item @(HasArrayFilter("status", "invalid") ? "custom-active" : "")">Invalid</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "processing")" class="dropdown-item @(HasArrayFilter("status", "processing") ? "custom-active" : "")">Processing</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "settled")" class="dropdown-item @(HasArrayFilter("status", "settled") ? "custom-active" : "")">Settled</a>
|
||||
<hr class="dropdown-divider">
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidLate")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidLate") ? "custom-active" : "")">Settled Late</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidPartial")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidPartial") ? "custom-active" : "")">Settled Partial</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidOver")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidOver") ? "custom-active" : "")">Settled Over</a>
|
||||
<hr class="dropdown-divider">
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("unusual", "true")" class="dropdown-item @(HasBooleanFilter("unusual") ? "custom-active" : "")">Unusual</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived">Archived</a>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.Apps.Any())
|
||||
{
|
||||
<div class="dropdown">
|
||||
<button id="AppOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@if (appFilterCount > 0)
|
||||
{
|
||||
<span>@appFilterCount Plugin@(appFilterCount > 1 ? "s" : "")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>All Plugins</span>
|
||||
}
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="AppOptionsToggle">
|
||||
@foreach (var app in Model.Apps)
|
||||
{
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("orderid", app.AppOrderId)" class="dropdown-item @(HasArrayFilter("orderid", app.AppOrderId) ? "custom-active" : "")">@app.AppName</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="dropdown">
|
||||
<button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@if (hasDateFilter)
|
||||
{
|
||||
if (HasArrayFilter("startdate", "-1d"))
|
||||
{
|
||||
<span>24 Hours</span>
|
||||
}
|
||||
else if (HasArrayFilter("startdate", "-3d"))
|
||||
{
|
||||
<span>3 Days</span>
|
||||
}
|
||||
else if (HasArrayFilter("startdate", "-7d"))
|
||||
{
|
||||
<span>7 Days</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Custom</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>All Time</span>
|
||||
}
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-1d")" class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")">Last 24 hours</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-3d")" class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")">Last 3 days</a>
|
||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-7d")" class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")">Last 7 days</a>
|
||||
<button type="button" class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")" data-bs-toggle="modal" data-bs-target="#customRangeModal">Custom Range</button>
|
||||
</div>
|
||||
</div>
|
||||
<span asp-validation-for="SearchTerm" class="text-danger"></span>
|
||||
</form>
|
||||
|
||||
@if (Model.Invoices.Count > 0)
|
||||
@if (Model.Invoices.Any())
|
||||
{
|
||||
<form method="post" id="MassAction" asp-action="MassAction" class="">
|
||||
<div class="d-inline-flex align-items-center pb-2 float-xl-end mb-2 gap-3">
|
||||
<input type="hidden" name="storeId" value="@Model.StoreId" />
|
||||
<div class="dropdown order-xl-1">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-xl-end" aria-labelledby="ActionsDropdownToggle">
|
||||
<button type="submit" class="dropdown-item" name="command" value="archive" id="ActionsDropdownArchive">Archive</button>
|
||||
@if (Model.IncludeArchived)
|
||||
@if (HasBooleanFilter("includearchived"))
|
||||
{
|
||||
<button type="submit" asp-action="MassAction" class="dropdown-item" name="command" value="unarchive" id="ActionsDropdownUnarchive">Unarchive</button>
|
||||
}
|
||||
@ -276,7 +338,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown d-inline-flex align-items-center gap-3">
|
||||
<button class="btn btn-secondary dropdown-toggle order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Export
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
|
||||
@ -375,7 +437,9 @@
|
||||
<span class="badge bg-warning">Refund</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
@if (invoice.ShowCheckout)
|
||||
{
|
||||
|
@ -35,12 +35,12 @@
|
||||
<vc:truncate-center text="@payment.Address" classes="truncate-center-id" />
|
||||
</td>
|
||||
}
|
||||
<td class="text-nowrap text-end">@payment.Rate</td>
|
||||
<td class="text-nowrap text-end">@payment.Paid</td>
|
||||
<td class="text-nowrap text-end">@payment.Due</td>
|
||||
<td class="text-nowrap text-end"><span data-sensitive>@payment.Rate</span></td>
|
||||
<td class="text-nowrap text-end"><span data-sensitive>@payment.Paid</span></td>
|
||||
<td class="text-nowrap text-end"><span data-sensitive>@payment.Due</span></td>
|
||||
@if (invoice.Overpaid)
|
||||
{
|
||||
<td class="text-nowrap text-end">@payment.Overpaid</td>
|
||||
<td class="text-nowrap text-end"><span data-sensitive>@payment.Overpaid</span></td>
|
||||
}
|
||||
</tr>
|
||||
var details = payment.PaymentMethodRaw.GetPaymentMethodDetails();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user